Commit 1ebdbda1bb9 for woocommerce

commit 1ebdbda1bb91de6b79f683257386a47818812814
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date:   Thu Oct 8 12:36:39 2026 +0200

    Subscriptions engine: plans as records with plan views and a plan write facade (#69519)

    Subscriptions engine: store plans as records with one plan facade

    - Store plan billing, pricing and delivery policies as opaque payloads and drop description, category, sort order and merchant code
    - Add plan meta, registered plan statuses and schema 2.6.0
    - Read and write plans through Api\Plans with PlanView results, replacing Api\SellingPlans
    - Scope plan updates to the owning extension and run its validate_plan hook on every write
    - Slim the plans REST routes to opaque CRUD and refuse unusable billing cadences when building a BillingPolicy

diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-plans-as-records b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-plans-as-records
new file mode 100644
index 00000000000..01ce2372a16
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-plans-as-records
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Subscriptions engine package is not released yet; the plans-as-records rework needs no changelog entry.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
index e14b0b36028..6ba3ae6a144 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
@@ -366,7 +366,7 @@ final class Contracts {
 	 * authenticated user at the REST boundary), never inferred, so it never returns
 	 * another customer's contracts. Each view projects the stored contract fields (items
 	 * and addresses not loaded); a caller needing plan terms resolves `selling_plan_id`
-	 * through {@see SellingPlans}.
+	 * through {@see Plans::get()}.
 	 *
 	 * The status filter applies before paging, so a page holds `$limit` matching contracts.
 	 *
@@ -402,7 +402,7 @@ final class Contracts {
 	 * contract it does not own.
 	 *
 	 * The returned view projects the stored contract fields with items and addresses; a
-	 * caller needing plan terms resolves `selling_plan_id` through {@see SellingPlans}.
+	 * caller needing plan terms resolves `selling_plan_id` through {@see Plans::get()}.
 	 *
 	 * @param int $contract_id Contract id.
 	 * @param int $customer_id Customer that must own the contract.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/PlanValidationException.php b/packages/php/woocommerce-subscriptions-engine/src/Api/PlanValidationException.php
new file mode 100644
index 00000000000..78db1c0d60f
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/PlanValidationException.php
@@ -0,0 +1,48 @@
+<?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
new file mode 100644
index 00000000000..667d2801fbc
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
@@ -0,0 +1,397 @@
+<?php
+/**
+ * Plans - the engine's public plan facade (reads and writes).
+ *
+ * Extensions create, update and read plans through explicit argument arrays: the engine records
+ * the payloads it is given and interprets none of them. It checks integrity only (a
+ * non-empty name, a registered status, object-shaped policies), then lets the plan's
+ * owning extension validate the write through `woocommerce_subscriptions_engine_validate_plan`.
+ * Any caller may read any plan; an update names the plan's owning extension and never
+ * reaches a plan of another one (authorization is the caller's concern). Reads return
+ * read-only {@see PlanView}s. The engine opens no transaction and keeps no cache.
+ *
+ * Billing payload contract: the engine reads one plan payload itself. Until every
+ * contract carries a plan snapshot, renewal and reactivation fall back to the live
+ * plan's `billing_policy` and read it with {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy::from_array()}
+ * (a string `period` of day, week, month or year, a positive int `interval`, and
+ * optional cycle bounds and trial). A payload of another shape is still stored, but
+ * renewal parks such a contract and reactivation rolls it without a cadence. The
+ * snapshot's `billing_policy` is read the same way, and one that fails the rule falls
+ * back to the live plan.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api
+ */
+
+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\Integration\Storage\PlanRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Public plan facade: reads and writes.
+ *
+ * Final and static-only: a stateless entry point, not an extension seam.
+ */
+final class Plans {
+
+	/**
+	 * Keys accepted by {@see self::create()} and {@see self::update()}, as a key map. The
+	 * `extension_slug` sets the owning extension on create and scopes the write on update; it is never
+	 * written on update. The other keys are the plan fields.
+	 *
+	 * @var array<string, true>
+	 */
+	private const PLAN_KEYS = array(
+		'extension_slug'  => true,
+		'name'            => true,
+		'status'          => true,
+		'billing_policy'  => true,
+		'pricing_policy'  => true,
+		'delivery_policy' => true,
+	);
+
+	/**
+	 * Keys accepted by {@see self::list()}, as a key map.
+	 *
+	 * @var array<string, true>
+	 */
+	private const LIST_KEYS = array(
+		'extension_slug' => true,
+		'status'         => true,
+		'ids'            => true,
+		'limit'          => true,
+		'offset'         => true,
+	);
+
+	/**
+	 * Default `limit` of {@see self::list()}.
+	 */
+	private const DEFAULT_LIST_LIMIT = 200;
+
+	/**
+	 * Logger source.
+	 */
+	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.
+	/**
+	 * Create a plan.
+	 *
+	 * Unknown keys raise a `_doing_it_wrong()` notice and are ignored.
+	 *
+	 * @param array<string, mixed> $args Plan fields: `extension_slug` (required, the owning
+	 *                                   extension), `name` (required, non-empty), `status` (a
+	 *                                   registered plan status, default `active`), and
+	 *                                   `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 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'] );
+
+		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.
+		}
+
+		self::validate_with_extension( $plan );
+
+		( new PlanRepository() )->insert( $plan );
+
+		return PlanView::from_plan( $plan );
+	}
+
+	/**
+	 * Write the given fields to an existing plan of the given extension.
+	 *
+	 * Takes the keys of {@see self::create()}. `extension_slug` is required, as on create,
+	 * but here it scopes the write to that extension and is never written (a plan's extension never changes):
+	 * the plan is read and written only where its row carries that slug, so a plan of
+	 * another extension reads as missing and is never written. Only the columns of the
+	 * 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.
+	 *
+	 * @param int                  $plan_id Plan id.
+	 * @param array<string, mixed> $args    `extension_slug` (required, the owning extension)
+	 *                                      and the fields to write.
+	 * @return PlanView|null The row as read before the write plus the written fields (a column
+	 *                       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 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'] );
+
+		$repository = new PlanRepository();
+		$plan       = $repository->find( $plan_id, $extension_slug );
+		if ( null === $plan ) {
+			return null;
+		}
+
+		$fields = array_keys( $filtered_args );
+		if ( array() === $fields ) {
+			return PlanView::from_plan( $plan );
+		}
+
+		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::validate_with_extension( $plan );
+
+		if ( ! $repository->update_fields( $plan, $fields ) ) {
+			return null;
+		}
+
+		return PlanView::from_plan( $plan );
+	}
+
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
+
+	/**
+	 * Add a meta value to a plan, like `add_post_meta()`. A key may hold several values.
+	 * The plan is not looked up: meta for an unknown plan id is a caller error.
+	 *
+	 * @param int    $plan_id Plan id.
+	 * @param string $key     Meta key.
+	 * @param mixed  $value   Meta value; serialized when not scalar.
+	 * @param bool   $unique  When true, add nothing if the key already exists. Advisory: checked
+	 *                        before the insert with no unique index, so concurrent adds can both write.
+	 * @return int|null The meta row id; null when `$unique` and the key exists.
+	 * @throws InvalidArgumentException If `$key` is empty.
+	 */
+	public static function add_meta( int $plan_id, string $key, $value, bool $unique = false ): ?int {
+		return ( new PlanRepository() )->add_meta( $plan_id, $key, $value, $unique );
+	}
+
+	/**
+	 * Update a plan's meta values for `$key`, like `update_post_meta()`: adds the key
+	 * when absent, else rewrites every value, or only the values equal to `$prev_value`.
+	 * The absent-key check runs before the write with no unique index, so it is not a lock.
+	 * The plan is not looked up: meta for an unknown plan id is a caller error.
+	 *
+	 * @param int    $plan_id    Plan id.
+	 * @param string $key        Meta key.
+	 * @param mixed  $value      New value; serialized when not scalar.
+	 * @param mixed  $prev_value Only update values equal to this; null updates all. Any other
+	 *                           value ('' and false included) matches literally.
+	 * @return bool True when a value was added or changed; false when nothing changed.
+	 * @throws InvalidArgumentException If `$key` is empty.
+	 */
+	public static function update_meta( int $plan_id, string $key, $value, $prev_value = null ): bool {
+		return ( new PlanRepository() )->update_meta( $plan_id, $key, $value, $prev_value );
+	}
+
+	/**
+	 * Delete a plan's meta values for `$key`, like `delete_post_meta()`.
+	 *
+	 * @param int    $plan_id Plan id.
+	 * @param string $key     Meta key.
+	 * @param mixed  $value   Only delete values equal to this; null deletes every value for the key.
+	 *                        Any other value ('' and false included) matches literally.
+	 * @return bool True when at least one value was deleted.
+	 * @throws InvalidArgumentException If `$key` is empty.
+	 */
+	public static function delete_meta( int $plan_id, string $key, $value = null ): bool {
+		return ( new PlanRepository() )->delete_meta( $plan_id, $key, $value );
+	}
+
+	/**
+	 * Read plan meta (WordPress `get_post_meta()` semantics), oldest value first.
+	 *
+	 * @param int    $plan_id Plan id.
+	 * @param string $key     Meta key; empty for every key.
+	 * @param bool   $single  With a key: return the first value only.
+	 * @return mixed Empty key: values grouped by key. Key + `$single`: the first value, or ''
+	 *               when absent. Key only: the list of values (`[]` when absent).
+	 */
+	public static function get_meta( int $plan_id, string $key = '', bool $single = false ) {
+		return ( new PlanRepository() )->get_meta( $plan_id, $key, $single );
+	}
+
+	/**
+	 * Fetch a plan by id, in any status.
+	 *
+	 * @param int $plan_id Plan id.
+	 * @return PlanView|null The plan, or null when none exists.
+	 */
+	public static function get( int $plan_id ): ?PlanView {
+		$plan = ( new PlanRepository() )->find( $plan_id );
+
+		return null === $plan ? null : PlanView::from_plan( $plan );
+	}
+
+	/**
+	 * List plans, oldest id first. Without args: every extension's plans in every status.
+	 *
+	 * Archived plans are never purged and count toward `limit`, so pass `status` (for
+	 * example `active`) when reading a catalog to sell from. An empty list filter matches
+	 * nothing. Unknown keys raise a `_doing_it_wrong()` notice and are ignored.
+	 *
+	 * @param array<string, mixed> $args {
+	 *     Optional. Query args.
+	 *
+	 *     @type string|string[] $extension_slug Owning extension slug, or a list of them (duplicates are
+	 *                                           ignored; `any` is refused). Absent: every extension.
+	 *     @type string|string[] $status         Plan status, or a list of them. Absent: every status.
+	 *     @type int[]           $ids            Only these plan ids: a list of positive integers (digit
+	 *                                           strings are cast). A non-list or a non-positive id throws.
+	 *     @type int             $limit          Maximum plans to return, a positive integer. Default 200.
+	 *     @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.
+	 */
+	public static function list( array $args = array() ): array {
+		$filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::LIST_KEYS );
+
+		$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 ),
+		);
+		if ( array_key_exists( 'extension_slug', $filtered_args ) ) {
+			$extension_slugs = array_values( array_unique( ArgumentValidator::validate_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.' );
+			}
+			$query['extension_slugs'] = $extension_slugs;
+		}
+		if ( array_key_exists( 'status', $filtered_args ) ) {
+			$query['status'] = ArgumentValidator::validate_string_list( 'status', $filtered_args['status'] );
+		}
+		if ( array_key_exists( 'ids', $filtered_args ) ) {
+			$query['ids'] = ArgumentValidator::validate_id_list( 'ids', $filtered_args['ids'] );
+		}
+
+		return array_map(
+			static function ( Plan $plan ): PlanView {
+				return PlanView::from_plan( $plan );
+			},
+			( new PlanRepository() )->query( $query )
+		);
+	}
+
+	// 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.
+	 *
+	 * @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).
+	 */
+	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;
+			}
+		}
+	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
+
+	/**
+	 * Let the plan's extension validate the would-be plan before it is written.
+	 *
+	 * @param Plan $plan The would-be plan (unsaved on create).
+	 * @throws PlanValidationException If 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 {
+		$errors         = new WP_Error();
+		$extension_slug = (string) $plan->get_extension_slug();
+
+		try {
+			/**
+			 * Fires before every plan write (PHP facade or REST), on create and on update,
+			 * 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.
+			 * The view is the would-be state after the write (its id is 0 on create) and is
+			 * read-only.
+			 *
+			 * 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`
+			 * throws here, which refuses every plan write, so update such callbacks together
+			 * with this engine version.
+			 *
+			 * @param WP_Error $errors Error collector.
+			 * @param PlanView $plan   The would-be plan.
+			 * @param string   $extension_slug Owning extension slug.
+			 */
+			do_action( 'woocommerce_subscriptions_engine_validate_plan', $errors, PlanView::from_plan( $plan ), $extension_slug );
+		} catch ( Throwable $e ) {
+			wc_get_logger()->error(
+				sprintf( 'Plans: plan validation for extension "%s" (plan %s) threw: %s', $extension_slug, null === $plan->get_id() ? 'new' : (string) $plan->get_id(), $e->getMessage() ),
+				array(
+					'source'         => self::LOG_SOURCE,
+					'extension_slug' => $extension_slug,
+					'plan_id'        => $plan->get_id(),
+				)
+			);
+
+			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.
+		}
+	}
+}
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 8e3ef301141..54994231399 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
@@ -1,6 +1,8 @@
 <?php
 /**
- * REST controller for subscription engine plans.
+ * REST controller for subscription engine plans: opaque CRUD over the plan facade (the
+ * paged, searchable collection reads the repository). Policies pass through as JSON
+ * objects, never parsed or merged.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Rest
  */
@@ -9,13 +11,16 @@ 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\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\ValueObject\BillingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\RESTPermissions;
 use InvalidArgumentException;
-use Throwable;
+use RuntimeException;
 use WP_Error;
 use WP_REST_Controller;
 use WP_REST_Request;
@@ -37,8 +42,25 @@ final class PlansController extends WP_REST_Controller {

 	private const DEFAULT_PER_PAGE = 20;

+	/**
+	 * Writable plan fields (the owning `extension_slug` comes from the request).
+	 *
+	 * @var array<int, string>
+	 */
+	private const WRITE_FIELDS = array( 'name', 'status', 'billing_policy', 'pricing_policy', 'delivery_policy' );
+
+	/**
+	 * Logger source.
+	 */
 	private const LOG_SOURCE = 'woocommerce-subscriptions-engine';

+	/**
+	 * Columns the collection may be ordered by.
+	 *
+	 * @var array<int, string>
+	 */
+	private const ORDERBY = array( 'id', 'name', 'date_created_gmt', 'date_updated_gmt' );
+
 	/**
 	 * Plans repository.
 	 *
@@ -113,17 +135,17 @@ final class PlansController extends WP_REST_Controller {
 							'required'    => false,
 						),
 						'status'         => array(
-							'description' => __( 'Status of the plans to query.', 'woocommerce-subscriptions-engine' ),
-							'type'        => 'string',
-							'required'    => false,
-							'enum'        => array( Plan::STATUS_ACTIVE, Plan::STATUS_ARCHIVED ),
+							'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' ),
 						),
 						'orderby'        => array(
 							'description' => __( 'Order by field for the plan query.', 'woocommerce-subscriptions-engine' ),
 							'type'        => 'string',
 							'required'    => false,
-							'enum'        => array( 'id', 'name', 'status', 'sort_order' ),
-							'default'     => 'sort_order',
+							'enum'        => self::ORDERBY,
+							'default'     => 'id',
 						),
 						'order'          => array(
 							'description' => __( 'Order direction for the plan query.', 'woocommerce-subscriptions-engine' ),
@@ -144,18 +166,6 @@ final class PlansController extends WP_REST_Controller {
 			)
 		);

-		register_rest_route(
-			self::REST_NAMESPACE,
-			'/' . self::REST_BASE . '/reorder',
-			array(
-				array(
-					'methods'             => WP_REST_Server::CREATABLE,
-					'callback'            => array( $this, 'reorder_items' ),
-					'permission_callback' => array( $this, 'permissions_check' ),
-				),
-			)
-		);
-
 		register_rest_route(
 			self::REST_NAMESPACE,
 			'/' . self::REST_BASE . '/(?P<id>[\d]+)',
@@ -191,6 +201,24 @@ 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.
 	 *
@@ -204,7 +232,7 @@ final class PlansController extends WP_REST_Controller {
 		}

 		$page     = max( 1, Coercion::coerce_int( $request->get_param( 'page' ), 1 ) );
-		$per_page = $this->resolve_per_page( $request );
+		$per_page = $this->get_per_page( $request );
 		$args     = array(
 			'limit'           => $per_page,
 			'offset'          => ( $page - 1 ) * $per_page,
@@ -225,7 +253,7 @@ final class PlansController extends WP_REST_Controller {
 			array_map(
 				function ( Plan $plan ) use ( $request ): array {
 					$prepared = $this->prepare_response_for_collection(
-						$this->prepare_item_for_response( $plan, $request )
+						$this->prepare_item_for_response( PlanView::from_plan( $plan ), $request )
 					);

 					return is_array( $prepared ) ? $prepared : array();
@@ -251,8 +279,8 @@ final class PlansController extends WP_REST_Controller {
 			return $extension_slug;
 		}

-		$plan = $this->plan_repository->find( Coercion::coerce_int( $request->get_param( 'id' ) ), $extension_slug );
-		if ( ! $plan instanceof Plan ) {
+		$plan = Plans::get( Coercion::coerce_int( $request->get_param( 'id' ) ) );
+		if ( null === $plan || $extension_slug !== $plan->get_extension_slug() ) {
 			return $this->not_found_error();
 		}

@@ -260,7 +288,7 @@ final class PlansController extends WP_REST_Controller {
 	}

 	/**
-	 * Create one global plan.
+	 * Create one plan owned by the request's extension slug.
 	 *
 	 * @param WP_REST_Request $request Request.
 	 * @return WP_REST_Response|WP_Error
@@ -271,40 +299,17 @@ final class PlansController extends WP_REST_Controller {
 			return $extension_slug;
 		}

-		$name = $this->string_param( $request, 'name' );
-		if ( '' === $name ) {
-			return $this->invalid_error( __( 'Plan name is required.', 'woocommerce-subscriptions-engine' ) );
-		}
-
-		$billing_policy = $request->get_param( 'billing_policy' );
-		if ( ! is_array( $billing_policy ) ) {
-			return $this->invalid_error( __( 'billing_policy is required.', 'woocommerce-subscriptions-engine' ) );
-		}
+		$args                   = $this->get_write_args( $request );
+		$args['extension_slug'] = $extension_slug;

 		try {
-			$billing_policy = $this->associative_array( $billing_policy, 'billing_policy must be an object.' );
-
-			$plan = Plan::create(
-				array(
-					'name'           => $name,
-					'description'    => $this->nullable_string_param( $request, 'description' ),
-					'billing_policy' => BillingPolicy::from_array( $billing_policy ),
-					'pricing_policy' => $this->pricing_policy_from_param( $request->get_param( 'pricing_policy' ), null ),
-					'category'       => $this->string_param( $request, 'category', Plan::DEFAULT_CATEGORY ),
-					'status'         => $this->string_param( $request, 'status', Plan::STATUS_ACTIVE ),
-					'sort_order'     => Coercion::coerce_int( $request->get_param( 'sort_order' ) ),
-					'extension_slug' => $extension_slug,
-				)
-			);
-
-			$errors = $this->validate_with_owner( $plan, $extension_slug );
-			if ( is_wp_error( $errors ) ) {
-				return $this->as_bad_request( $errors );
-			}
-
-			$this->plan_repository->insert( $plan );
-		} catch ( Throwable $e ) {
+			$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 ( RuntimeException $e ) {
+			return $this->write_failed_error( $e, 'woocommerce_subscriptions_engine_plan_create_failed' );
 		}

 		$response = rest_ensure_response( $this->prepare_item_for_response( $plan, $request ) );
@@ -314,7 +319,8 @@ final class PlansController extends WP_REST_Controller {
 	}

 	/**
-	 * Partially update a plan.
+	 * Partially update a plan: only the present fields are written, and a present
+	 * policy replaces the stored payload.
 	 *
 	 * @param WP_REST_Request $request Request.
 	 * @return WP_REST_Response|WP_Error
@@ -325,130 +331,45 @@ final class PlansController extends WP_REST_Controller {
 			return $extension_slug;
 		}

-		$plan = $this->plan_repository->find( Coercion::coerce_int( $request->get_param( 'id' ) ), $extension_slug );
-		if ( ! $plan instanceof Plan ) {
-			return $this->not_found_error();
-		}
+		$args                   = $this->get_write_args( $request );
+		$args['extension_slug'] = $extension_slug;

 		try {
-			if ( $request->has_param( 'name' ) ) {
-				$name = $this->string_param( $request, 'name' );
-				if ( '' === $name ) {
-					return $this->invalid_error( __( 'Plan name is required.', 'woocommerce-subscriptions-engine' ) );
-				}
-				$plan->set_name( $name );
-			}
-
-			if ( $request->has_param( 'description' ) ) {
-				$plan->set_description( $this->nullable_string_param( $request, 'description' ) );
-			}
-
-			if ( $request->has_param( 'billing_policy' ) ) {
-				$billing_policy = $request->get_param( 'billing_policy' );
-				if ( ! is_array( $billing_policy ) ) {
-					return $this->invalid_error( __( 'billing_policy must be an object.', 'woocommerce-subscriptions-engine' ) );
-				}
-				$billing_policy = $this->associative_array( $billing_policy, 'billing_policy must be an object.' );
-				$plan->set_billing_policy(
-					BillingPolicy::from_array(
-						array_merge( $plan->get_billing_policy()->to_array(), $billing_policy )
-					)
-				);
-			}
-
-			if ( $request->has_param( 'pricing_policy' ) ) {
-				$plan->set_pricing_policy(
-					$this->pricing_policy_from_param( $request->get_param( 'pricing_policy' ), $plan->get_pricing_policy() )
-				);
-			}
-
-			if ( $request->has_param( 'status' ) ) {
-				$plan->set_status( $this->string_param( $request, 'status', Plan::STATUS_ACTIVE ) );
-			}
-
-			if ( $request->has_param( 'sort_order' ) ) {
-				$plan->set_sort_order( Coercion::coerce_int( $request->get_param( 'sort_order' ) ) );
-			}
-
-			$errors = $this->validate_with_owner( $plan, $extension_slug );
-			if ( is_wp_error( $errors ) ) {
-				return $this->as_bad_request( $errors );
-			}
-
-			if ( ! $this->plan_repository->update( $plan ) ) {
-				return new WP_Error(
-					'woocommerce_subscriptions_engine_plan_update_failed',
-					__( 'The plan could not be saved.', 'woocommerce-subscriptions-engine' ),
-					array( 'status' => 500 )
-				);
-			}
-		} catch ( Throwable $e ) {
+			// 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 ( RuntimeException $e ) {
+			return $this->write_failed_error( $e, 'woocommerce_subscriptions_engine_plan_update_failed' );
 		}

-		return rest_ensure_response( $this->prepare_item_for_response( $plan, $request ) );
-	}
-
-	/**
-	 * Reorder plans.
-	 *
-	 * @param WP_REST_Request $request Request.
-	 * @return WP_REST_Response|WP_Error
-	 */
-	public function reorder_items( $request ) {
-		$extension_slug = $this->get_single_extension_slug( $request );
-		if ( $extension_slug instanceof WP_Error ) {
-			return $extension_slug;
-		}
-
-		$ids = $request->get_param( 'ids' );
-		if ( ! is_array( $ids ) ) {
-			return $this->invalid_error( __( 'ids must be an array of plan ids.', 'woocommerce-subscriptions-engine' ) );
-		}
-
-		$sort_order_by_id = array();
-		$response_ids     = array();
-		foreach ( array_values( $ids ) as $index => $raw_id ) {
-			$id = Coercion::coerce_nullable_int( $raw_id );
-			if ( null === $id || $id <= 0 ) {
-				return $this->invalid_error( __( 'ids must contain only positive integers.', 'woocommerce-subscriptions-engine' ) );
-			}
-			if ( isset( $sort_order_by_id[ $id ] ) ) {
-				return $this->invalid_error( __( 'ids must not contain duplicate plan ids.', 'woocommerce-subscriptions-engine' ) );
-			}
-			$sort_order_by_id[ $id ] = $index;
-			$response_ids[]          = $id;
-		}
-
-		if ( ! $this->plan_repository->reorder( $extension_slug, $sort_order_by_id ) ) {
-			return new WP_Error(
-				'woocommerce_subscriptions_engine_reorder_failed',
-				__( 'Plan reorder failed.', 'woocommerce-subscriptions-engine' ),
-				array( 'status' => 500 )
-			);
+		if ( null === $plan ) {
+			return $this->not_found_error();
 		}

-		return rest_ensure_response( array( 'ids' => $response_ids ) );
+		return rest_ensure_response( $this->prepare_item_for_response( $plan, $request ) );
 	}

 	/**
-	 * Serialize a plan.
+	 * Serialize a plan view.
 	 *
-	 * @param Plan            $item    Plan.
+	 * @param PlanView        $item    Plan view.
 	 * @param WP_REST_Request $request Request.
 	 * @return WP_REST_Response
 	 */
 	public function prepare_item_for_response( $item, $request ) {
 		$data = array(
-			'id'             => $item->get_id(),
-			'name'           => $item->get_name(),
-			'description'    => $item->get_description(),
-			'scope'          => 'global',
-			'status'         => $item->get_status(),
-			'sort_order'     => $item->get_sort_order(),
-			'extension_slug' => $item->get_extension_slug(),
-			'billing_policy' => $item->get_billing_policy()->to_array(),
-			'pricing_policy' => $item->get_pricing_policy(),
+			'id'               => $item->get_id(),
+			'extension_slug'   => $item->get_extension_slug(),
+			'status'           => $item->get_status(),
+			'name'             => $item->get_name(),
+			'billing_policy'   => self::as_json_object( $item->get_billing_policy() ),
+			'pricing_policy'   => self::as_json_object( $item->get_pricing_policy() ),
+			'delivery_policy'  => self::as_json_object( $item->get_delivery_policy() ),
+			'date_created_gmt' => $item->get_date_created_gmt(),
+			'date_updated_gmt' => $item->get_date_updated_gmt(),
 		);

 		$context = Coercion::coerce_string( $request->get_param( 'context' ), 'view' );
@@ -489,16 +410,15 @@ final class PlansController extends WP_REST_Controller {
 				'sanitize_callback' => 'sanitize_text_field',
 			),
 			'status'   => array(
-				'description'       => __( 'Limit result set to plans with a status.', 'woocommerce-subscriptions-engine' ),
+				'description'       => __( 'Limit result set to plans with a registered plan status.', 'woocommerce-subscriptions-engine' ),
 				'type'              => 'string',
-				'enum'              => Plan::ALLOWED_STATUSES,
-				'sanitize_callback' => 'sanitize_key',
+				'validate_callback' => array( $this, 'validate_status_param' ),
 			),
 			'orderby'  => array(
 				'description'       => __( 'Sort collection by object attribute.', 'woocommerce-subscriptions-engine' ),
 				'type'              => 'string',
-				'default'           => 'sort_order',
-				'enum'              => array( 'id', 'name', 'sort_order', 'date_created_gmt', 'date_updated_gmt' ),
+				'default'           => 'id',
+				'enum'              => self::ORDERBY,
 				'sanitize_callback' => 'sanitize_key',
 			),
 			'order'    => array(
@@ -527,54 +447,57 @@ final class PlansController extends WP_REST_Controller {
 			'title'      => 'subscription_engine_plan',
 			'type'       => 'object',
 			'properties' => array(
-				'id'             => array(
+				'id'               => array(
 					'description' => __( 'Unique identifier for the plan.', 'woocommerce-subscriptions-engine' ),
 					'type'        => 'integer',
 					'context'     => array( 'view' ),
 					'readonly'    => true,
 				),
-				'name'           => array(
-					'description' => __( 'Display name.', 'woocommerce-subscriptions-engine' ),
-					'type'        => 'string',
-					'context'     => array( 'view', 'edit' ),
-				),
-				'description'    => array(
-					'description' => __( 'Optional description.', 'woocommerce-subscriptions-engine' ),
+				'extension_slug'   => array(
+					'description' => __( 'Owning extension slug.', 'woocommerce-subscriptions-engine' ),
 					'type'        => array( 'string', 'null' ),
 					'context'     => array( 'view', 'edit' ),
 				),
-				'scope'          => array(
-					'description' => __( 'Plan scope.', 'woocommerce-subscriptions-engine' ),
+				'status'           => array(
+					'description' => __( 'Plan status (any registered plan status).', 'woocommerce-subscriptions-engine' ),
 					'type'        => 'string',
-					'context'     => array( 'view' ),
-					'readonly'    => true,
-				),
-				'status'         => array(
-					'description' => __( 'Plan status.', 'woocommerce-subscriptions-engine' ),
-					'type'        => 'string',
-					'enum'        => Plan::ALLOWED_STATUSES,
 					'context'     => array( 'view', 'edit' ),
+					'arg_options' => array(
+						'validate_callback' => array( $this, 'validate_status_param' ),
+					),
 				),
-				'sort_order'     => array(
-					'description' => __( 'Manual sort order.', 'woocommerce-subscriptions-engine' ),
-					'type'        => 'integer',
+				'name'             => array(
+					'description' => __( 'Display name.', 'woocommerce-subscriptions-engine' ),
+					'type'        => 'string',
 					'context'     => array( 'view', 'edit' ),
 				),
-				'extension_slug' => array(
-					'description' => __( 'Owning extension slug.', 'woocommerce-subscriptions-engine' ),
-					'type'        => array( 'string', 'null' ),
+				'billing_policy'   => array(
+					'description' => __( 'Billing payload of the owning extension.', 'woocommerce-subscriptions-engine' ),
+					'type'        => array( 'object', 'null' ),
 					'context'     => array( 'view', 'edit' ),
 				),
-				'billing_policy' => array(
-					'description' => __( 'Billing policy.', 'woocommerce-subscriptions-engine' ),
-					'type'        => 'object',
+				'pricing_policy'   => array(
+					'description' => __( 'Pricing payload of the owning extension.', 'woocommerce-subscriptions-engine' ),
+					'type'        => array( 'object', 'null' ),
 					'context'     => array( 'view', 'edit' ),
 				),
-				'pricing_policy' => array(
-					'description' => __( 'Pricing policy.', 'woocommerce-subscriptions-engine' ),
+				'delivery_policy'  => array(
+					'description' => __( 'Delivery payload of the owning extension.', 'woocommerce-subscriptions-engine' ),
 					'type'        => array( 'object', 'null' ),
 					'context'     => array( 'view', 'edit' ),
 				),
+				'date_created_gmt' => array(
+					'description' => __( 'Creation time (GMT).', 'woocommerce-subscriptions-engine' ),
+					'type'        => array( 'string', 'null' ),
+					'context'     => array( 'view' ),
+					'readonly'    => true,
+				),
+				'date_updated_gmt' => array(
+					'description' => __( 'Last update time (GMT).', 'woocommerce-subscriptions-engine' ),
+					'type'        => array( 'string', 'null' ),
+					'context'     => array( 'view' ),
+					'readonly'    => true,
+				),
 			),
 		);

@@ -582,11 +505,11 @@ final class PlansController extends WP_REST_Controller {
 	}

 	/**
-	 * Resolve per_page.
+	 * The requested page size: capped at the maximum, and the default when below 1 or not a number.
 	 *
 	 * @param WP_REST_Request $request Request.
 	 */
-	private function resolve_per_page( WP_REST_Request $request ): int {
+	private function get_per_page( WP_REST_Request $request ): int {
 		$value = Coercion::coerce_int( $request->get_param( 'per_page' ), self::DEFAULT_PER_PAGE );
 		if ( $value < 1 ) {
 			return self::DEFAULT_PER_PAGE;
@@ -596,62 +519,48 @@ final class PlansController extends WP_REST_Controller {
 	}

 	/**
-	 * Build the pricing payload from a request param. Provided top-level keys
-	 * replace existing ones; omitted keys keep their stored value.
+	 * Collect the present writable params as facade args, passed through as given
+	 * (the name is sanitized); the facade validates them.
 	 *
-	 * @param mixed                     $value    Request value.
-	 * @param array<string, mixed>|null $existing Existing payload.
-	 * @return array<string, mixed>|null
-	 * @throws InvalidArgumentException If the param is not an object or null.
+	 * @param WP_REST_Request $request Request.
+	 * @return array<string, mixed>
 	 */
-	private function pricing_policy_from_param( $value, ?array $existing ): ?array {
-		if ( null === $value ) {
-			return null;
-		}
-
-		if ( ! is_array( $value ) ) {
-			throw new InvalidArgumentException( 'pricing_policy must be an object or null.' );
+	private function get_write_args( WP_REST_Request $request ): array {
+		$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 = $this->associative_array( $value, 'pricing_policy must be an object or null.' );
-
-		return array_replace( $existing ?? array(), $value );
+		return $args;
 	}

 	/**
-	 * Let the owning extension validate the plan before it is stored. A plan whose
-	 * owner registers no callback is stored without owner validation.
+	 * Present a stored policy as a JSON object: an empty payload serializes as `{}`.
 	 *
-	 * @param Plan   $plan           Plan about to be written.
-	 * @param string $extension_slug Owning extension slug.
-	 * @return WP_Error|null Errors rejecting the write (500 if a callback threw), or null when valid.
+	 * @param array<string, mixed>|null $policy Policy payload.
+	 * @return array<string, mixed>|object|null
 	 */
-	private function validate_with_owner( Plan $plan, string $extension_slug ): ?WP_Error {
-		$errors = new WP_Error();
+	private static function as_json_object( ?array $policy ) {
+		if ( array() === $policy ) {
+			return (object) array();
+		}

-		try {
-			/**
-			 * Fires before a plan is written so the owning extension can validate it.
-			 *
-			 * Add errors to $errors to reject the write; act only on your own $extension_slug.
-			 * The plan is a copy: changes to it are not stored. Runs on create and update
-			 * (including status-only updates), not on reorder.
-			 *
-			 * @param WP_Error $errors         Error collector.
-			 * @param Plan     $plan           Plan about to be written; its id is null on create.
-			 * @param string   $extension_slug Owning extension slug.
-			 */
-			do_action( 'woocommerce_subscriptions_engine_validate_plan', $errors, clone $plan, $extension_slug );
-		} catch ( Throwable $e ) {
-			wc_get_logger()->error(
-				sprintf( 'PlansController: plan validation for extension "%s" (plan %s) threw: %s', $extension_slug, null === $plan->get_id() ? 'new' : (string) $plan->get_id(), $e->getMessage() ),
-				array(
-					'source'         => self::LOG_SOURCE,
-					'extension_slug' => $extension_slug,
-					'plan_id'        => $plan->get_id(),
-				)
-			);
+		return $policy;
+	}

+	/**
+	 * Map a failed write to a 500. The facade wraps a throwing validation callback
+	 * (the cause is chained, and the facade logs it); a failed insert or update carries
+	 * no cause and is logged here with the database error. `Api\Plans` documents this
+	 * on its create and update `@throws`, and PlansTest pins both sides.
+	 *
+	 * @param RuntimeException $e    Failure.
+	 * @param string           $code Error code for a failed insert or update.
+	 */
+	private function write_failed_error( RuntimeException $e, string $code ): WP_Error {
+		if ( $e->getPrevious() instanceof \Throwable ) {
 			return new WP_Error(
 				'woocommerce_subscriptions_engine_plan_validation_failed',
 				__( 'The plan could not be validated.', 'woocommerce-subscriptions-engine' ),
@@ -659,7 +568,16 @@ final class PlansController extends WP_REST_Controller {
 			);
 		}

-		return $errors->has_errors() ? $errors : null;
+		wc_get_logger()->error(
+			sprintf( 'PlansController: the plan write failed: %s', $e->getMessage() ),
+			array( 'source' => self::LOG_SOURCE )
+		);
+
+		return new WP_Error(
+			$code,
+			__( 'The plan could not be saved.', 'woocommerce-subscriptions-engine' ),
+			array( 'status' => 500 )
+		);
 	}

 	/**
@@ -744,51 +662,16 @@ final class PlansController extends WP_REST_Controller {
 	}

 	/**
-	 * Read a string param.
+	 * A sanitized string param.
 	 *
 	 * @param WP_REST_Request $request  Request.
 	 * @param string          $key      Param key.
 	 * @param string          $fallback Fallback.
 	 */
-	private function string_param( WP_REST_Request $request, string $key, string $fallback = '' ): string {
+	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 ) );
 	}

-	/**
-	 * Read a nullable string param.
-	 *
-	 * @param WP_REST_Request $request Request.
-	 * @param string          $key     Param key.
-	 */
-	private function nullable_string_param( WP_REST_Request $request, string $key ): ?string {
-		$value = Coercion::coerce_nullable_string( $request->get_param( $key ) );
-		if ( null === $value || '' === $value ) {
-			return null;
-		}
-
-		return sanitize_text_field( $value );
-	}
-
-	/**
-	 * Normalize a REST object payload to a string-keyed array.
-	 *
-	 * @param array<array-key, mixed> $value   Request value.
-	 * @param string                  $message Error message.
-	 * @return array<string, mixed>
-	 * @throws InvalidArgumentException If the array is not object-shaped.
-	 */
-	private function associative_array( array $value, string $message ): array {
-		$data = array();
-		foreach ( $value as $key => $item ) {
-			if ( ! is_string( $key ) ) {
-				throw new InvalidArgumentException( esc_html( $message ) );
-			}
-			$data[ $key ] = $item;
-		}
-
-		return $data;
-	}
-
 	/**
 	 * Not-found error.
 	 */
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/SellingPlans.php b/packages/php/woocommerce-subscriptions-engine/src/Api/SellingPlans.php
deleted file mode 100644
index f795ff5526b..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/SellingPlans.php
+++ /dev/null
@@ -1,98 +0,0 @@
-<?php
-/**
- * SellingPlans - the engine's public catalog read facade.
- *
- * The one surface consumers import to read the plans catalog: list an
- * extension's active plans and fetch specific plans by id for selection and
- * display UIs. Which products a plan applies to is consumer-owned - the
- * engine stores the catalog, not product attachment. The facade hides the
- * internal `Integration\` repositories behind a stable boundary, so the
- * internals stay refactorable. Strictly additive-only, like every `Api\`
- * surface.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Api
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
-
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Public selling-plans catalog read facade.
- *
- * Each extension constructs one instance scoped to its own slugs and reuses
- * it for every read - the slug scope is fixed at construction so call sites
- * never carry it around. Instances are cheap and hold no state beyond the
- * scope, so constructing more is harmless. Final: a facade over the engine
- * internals, not an extension seam.
- */
-final class SellingPlans {
-
-	/**
-	 * Query limit for plan lookups; high enough that a plan catalog is never
-	 * truncated by the repository's default of 50.
-	 *
-	 * @var int
-	 */
-	private const PLAN_QUERY_LIMIT = 200;
-
-	/**
-	 * Extension slugs this instance reads plans for.
-	 *
-	 * @var array<int, string>
-	 */
-	private $extension_slugs;
-
-	/**
-	 * Scope the facade to the calling extension's slugs.
-	 *
-	 * @param array<int, string> $extension_slugs Extension slugs to read plans for.
-	 */
-	public function __construct( array $extension_slugs ) {
-		$this->extension_slugs = $extension_slugs;
-	}
-
-	/**
-	 * List the scoped extensions' active plans in display order - the read
-	 * behind a plan-selection UI.
-	 *
-	 * @return array<int, Plan> Plans in display order.
-	 */
-	public function list_plans(): array {
-		return ( new PlanRepository() )->query(
-			array(
-				'status'          => Plan::STATUS_ACTIVE,
-				'extension_slugs' => $this->extension_slugs,
-				'limit'           => self::PLAN_QUERY_LIMIT,
-			)
-		);
-	}
-
-	/**
-	 * Fetch the active plans among the given ids owned by the scoped
-	 * extensions, in display order - the read behind rendering a stored plan
-	 * selection.
-	 *
-	 * Ids that are unknown, archived, or owned by an out-of-scope extension
-	 * are simply absent from the result. An empty or invalid id list yields
-	 * an empty array.
-	 *
-	 * @param array<int, int> $plan_ids Plan ids to fetch.
-	 * @return array<int, Plan> Plans in display order.
-	 */
-	public function get_plans( array $plan_ids ): array {
-		return ( new PlanRepository() )->query(
-			array(
-				'status'          => Plan::STATUS_ACTIVE,
-				'extension_slugs' => $this->extension_slugs,
-				'ids'             => $plan_ids,
-				'limit'           => self::PLAN_QUERY_LIMIT,
-			)
-		);
-	}
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/View/PlanView.php b/packages/php/woocommerce-subscriptions-engine/src/Api/View/PlanView.php
new file mode 100644
index 00000000000..29a5dec7858
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/View/PlanView.php
@@ -0,0 +1,132 @@
+<?php
+/**
+ * PlanView - a read-only view of a selling plan at the `Api\` boundary.
+ *
+ * Consumers read plans through this view instead of the Core entity. Getters may be
+ * added, never removed. The three policies are the owning extension's opaque
+ * payloads, returned as stored. The engine itself reads only `billing_policy`, as
+ * the renewal fallback for contracts without a plan snapshot (see {@see \Automattic\WooCommerce\SubscriptionsEngine\Api\Plans}).
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api\View
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api\View;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Immutable plan view.
+ */
+final class PlanView {
+
+	/**
+	 * Plan row values keyed by field name.
+	 *
+	 * @var array{id: int, extension_slug: ?string, status: string, name: string, billing_policy: ?array<string, mixed>, pricing_policy: ?array<string, mixed>, delivery_policy: ?array<string, mixed>, date_created_gmt: ?string, date_updated_gmt: ?string}
+	 */
+	private $fields;
+
+	/**
+	 * Use {@see self::from_plan()}.
+	 */
+	private function __construct() {
+	}
+
+	/**
+	 * Build a view of a plan. An unsaved plan (the would-be plan a create
+	 * validates) has id 0.
+	 *
+	 * @internal Built by the engine `Api\` facades only.
+	 *
+	 * @param Plan $plan Plan entity.
+	 */
+	public static function from_plan( Plan $plan ): self {
+		$view         = new self();
+		$view->fields = array(
+			'id'               => (int) $plan->get_id(),
+			'extension_slug'   => $plan->get_extension_slug(),
+			'status'           => $plan->get_status(),
+			'name'             => $plan->get_name(),
+			'billing_policy'   => $plan->get_billing_policy(),
+			'pricing_policy'   => $plan->get_pricing_policy(),
+			'delivery_policy'  => $plan->get_delivery_policy(),
+			'date_created_gmt' => $plan->get_date_created_gmt(),
+			'date_updated_gmt' => $plan->get_date_updated_gmt(),
+		);
+
+		return $view;
+	}
+
+	/**
+	 * Plan id; 0 for a plan that is not stored yet.
+	 */
+	public function get_id(): int {
+		return $this->fields['id'];
+	}
+
+	/**
+	 * Owning extension slug, or null.
+	 */
+	public function get_extension_slug(): ?string {
+		return $this->fields['extension_slug'];
+	}
+
+	/**
+	 * Plan status slug.
+	 */
+	public function get_status(): string {
+		return $this->fields['status'];
+	}
+
+	/**
+	 * Display name.
+	 */
+	public function get_name(): string {
+		return $this->fields['name'];
+	}
+
+	/**
+	 * Billing payload of the owning extension, or null.
+	 *
+	 * @return array<string, mixed>|null
+	 */
+	public function get_billing_policy(): ?array {
+		return $this->fields['billing_policy'];
+	}
+
+	/**
+	 * Pricing payload of the owning extension, or null.
+	 *
+	 * @return array<string, mixed>|null
+	 */
+	public function get_pricing_policy(): ?array {
+		return $this->fields['pricing_policy'];
+	}
+
+	/**
+	 * Delivery payload of the owning extension, or null.
+	 *
+	 * @return array<string, mixed>|null
+	 */
+	public function get_delivery_policy(): ?array {
+		return $this->fields['delivery_policy'];
+	}
+
+	/**
+	 * Creation time (GMT, `Y-m-d H:i:s`), or null for an unsaved plan.
+	 */
+	public function get_date_created_gmt(): ?string {
+		return $this->fields['date_created_gmt'];
+	}
+
+	/**
+	 * Last update time (GMT, `Y-m-d H:i:s`), or null for an unsaved plan.
+	 */
+	public function get_date_updated_gmt(): ?string {
+		return $this->fields['date_updated_gmt'];
+	}
+}
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 c71a6926a51..4e51b175c81 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
@@ -1,7 +1,7 @@
 <?php
 /**
- * Plan - a subscription selling plan: cadence, pricing, and delivery policy for
- * one or more products.
+ * Plan - a stored selling plan record. Its billing, pricing and delivery policies
+ * are opaque payloads of the owning extension; the engine checks their shape only.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
  */
@@ -10,9 +10,7 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;

-use InvalidArgumentException;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\DeliveryPolicy;
+use DomainException;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;

 defined( 'ABSPATH' ) || exit;
@@ -25,16 +23,6 @@ defined( 'ABSPATH' ) || exit;
  */
 final class Plan {

-	public const DEFAULT_CATEGORY = 'SUBSCRIPTION';
-
-	public const DEFAULT_STATUS = 'active';
-
-	public const STATUS_ACTIVE = 'active';
-
-	public const STATUS_ARCHIVED = 'archived';
-
-	public const ALLOWED_STATUSES = array( self::STATUS_ACTIVE, self::STATUS_ARCHIVED );
-
 	/**
 	 * Plan id, or null before it is persisted.
 	 *
@@ -50,171 +38,106 @@ final class Plan {
 	private $name;

 	/**
-	 * Optional description.
+	 * Registered plan status.
 	 *
-	 * @var string|null
+	 * @var string
 	 */
-	private $description;
+	private $status;

 	/**
-	 * Billing cadence. Required - every plan has one.
+	 * Owning extension slug.
 	 *
-	 * @var BillingPolicy
+	 * @var string|null
 	 */
-	private $billing_policy;
+	private $extension_slug;

 	/**
-	 * Optional delivery policy.
+	 * Billing payload, owned and interpreted by the plan's extension.
 	 *
-	 * @var DeliveryPolicy|null
+	 * @var array<string, mixed>|null
 	 */
-	private $delivery_policy;
+	private $billing_policy;

 	/**
-	 * Optional pricing payload, owned and interpreted by the plan's extension.
+	 * Pricing payload, owned and interpreted by the plan's extension.
 	 *
 	 * @var array<string, mixed>|null
 	 */
 	private $pricing_policy;

 	/**
-	 * Plan category.
-	 *
-	 * @var string
-	 */
-	private $category;
-
-	/**
-	 * Merchant lifecycle status.
-	 *
-	 * @var string
-	 */
-	private $status;
-
-	/**
-	 * Manual display order.
+	 * Delivery payload, owned and interpreted by the plan's extension.
 	 *
-	 * @var int
+	 * @var array<string, mixed>|null
 	 */
-	private $sort_order;
+	private $delivery_policy;

 	/**
-	 * Optional stable external identifier, unique at the storage layer - the
-	 * consumer-side dedup key. Immutable post-create.
+	 * Creation time (GMT, `Y-m-d H:i:s`) as stored, or null before insert.
 	 *
 	 * @var string|null
 	 */
-	private $merchant_code;
+	private $date_created_gmt;

 	/**
-	 * Owning extension slug, or null until owner semantics are assigned.
+	 * Last update time (GMT, `Y-m-d H:i:s`) as stored, or null before insert.
 	 *
 	 * @var string|null
 	 */
-	private $extension_slug;
+	private $date_updated_gmt;

 	/**
-	 * Use {@see self::create()} or {@see self::from_storage()}.
+	 * Use {@see self::create()} or {@see self::from_storage()}. Coerces each attribute
+	 * to its property type; unknown keys are ignored, missing keys take the default.
 	 *
-	 * @param int|null                  $id              Plan id, or null before save.
-	 * @param string                    $name            Display name.
-	 * @param string|null               $description     Optional description.
-	 * @param BillingPolicy             $billing_policy  Billing cadence.
-	 * @param DeliveryPolicy|null       $delivery_policy Optional delivery policy.
-	 * @param array<string, mixed>|null $pricing_policy  Optional pricing payload.
-	 * @param string                    $category        Plan category.
-	 * @param string                    $status          Merchant lifecycle status.
-	 * @param int                       $sort_order      Manual display order.
-	 * @param string|null               $merchant_code   Optional stable external identifier.
-	 * @param string|null               $extension_slug  Owning extension slug.
-	 */
-	private function __construct(
-		?int $id,
-		string $name,
-		?string $description,
-		BillingPolicy $billing_policy,
-		?DeliveryPolicy $delivery_policy,
-		?array $pricing_policy,
-		string $category,
-		string $status,
-		int $sort_order,
-		?string $merchant_code,
-		?string $extension_slug
-	) {
-		self::validate_status( $status );
-
-		$this->id              = $id;
-		$this->name            = $name;
-		$this->description     = $description;
-		$this->billing_policy  = $billing_policy;
-		$this->delivery_policy = $delivery_policy;
-		$this->pricing_policy  = $pricing_policy;
-		$this->category        = $category;
-		$this->status          = $status;
-		$this->sort_order      = $sort_order;
-		$this->merchant_code   = $merchant_code;
-		$this->extension_slug  = $extension_slug;
+	 * @param array<string, mixed> $data Raw attributes keyed by property name.
+	 */
+	private function __construct( array $data ) {
+		$this->id               = Coercion::coerce_nullable_int( $data['id'] ?? null );
+		$this->name             = Coercion::coerce_string( $data['name'] ?? null );
+		$this->status           = Coercion::coerce_string( $data['status'] ?? null, PlanStatus::ACTIVE );
+		$this->extension_slug   = Coercion::coerce_nullable_string( $data['extension_slug'] ?? null );
+		$this->billing_policy   = Coercion::coerce_nullable_string_keyed( $data['billing_policy'] ?? null );
+		$this->pricing_policy   = Coercion::coerce_nullable_string_keyed( $data['pricing_policy'] ?? null );
+		$this->delivery_policy  = Coercion::coerce_nullable_string_keyed( $data['delivery_policy'] ?? null );
+		$this->date_created_gmt = Coercion::coerce_nullable_string( $data['date_created_gmt'] ?? null );
+		$this->date_updated_gmt = Coercion::coerce_nullable_string( $data['date_updated_gmt'] ?? null );
 	}

 	/**
-	 * Build a new, unsaved plan.
+	 * Build a new, unsaved plan. `extension_slug` and a non-empty `name` are required.
 	 *
-	 * @param array<string, mixed> $args Plan attributes.
-	 * @throws InvalidArgumentException If pricing_policy is not an object (string-keyed array) or null.
+	 * @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.
 	 */
 	public static function create( array $args ): self {
-		$pricing_policy = self::assert_object_or_null( $args['pricing_policy'] ?? null );
+		// 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'] );

-		$billing_policy = $args['billing_policy'] ?? null;
-		if ( ! $billing_policy instanceof BillingPolicy ) {
-			throw new InvalidArgumentException( 'Plan: billing_policy is required and must be a BillingPolicy instance.' );
-		}
+		// 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 );

-		$delivery_policy = $args['delivery_policy'] ?? null;
-		if ( null !== $delivery_policy && ! $delivery_policy instanceof DeliveryPolicy ) {
-			throw new InvalidArgumentException( 'Plan: delivery_policy must be a DeliveryPolicy instance or null.' );
-		}
+		$plan = new self( $args );

-		return new self(
-			null,
-			Coercion::coerce_string( $args['name'] ?? null ),
-			Coercion::coerce_nullable_string( $args['description'] ?? null ),
-			$billing_policy,
-			$delivery_policy,
-			$pricing_policy,
-			Coercion::coerce_string( $args['category'] ?? null, self::DEFAULT_CATEGORY ),
-			Coercion::coerce_string( $args['status'] ?? null, self::DEFAULT_STATUS ),
-			Coercion::coerce_int( $args['sort_order'] ?? null, 0 ),
-			Coercion::coerce_nullable_string( $args['merchant_code'] ?? null ),
-			Coercion::coerce_nullable_string( $args['extension_slug'] ?? null )
-		);
+		self::assert_name( $plan->name );
+		self::assert_status( $plan->status );
+		self::assert_extension_slug( $plan->extension_slug );
+
+		return $plan;
 	}

 	/**
-	 * Hydrate from a stored row. Policy columns arrive JSON-decoded.
+	 * Hydrate from a stored row, without validation. Policy columns arrive JSON-decoded.
 	 *
-	 * The pricing payload is checked only for shape (object or null); its
-	 * semantics belong to the owning extension.
+	 * The stored status is taken as is: a status registered by a since-deactivated
+	 * extension still hydrates.
 	 *
 	 * @param array<string, mixed> $row Decoded plan row.
-	 * @throws InvalidArgumentException If the stored pricing_policy is not an object.
 	 */
 	public static function from_storage( array $row ): self {
-		$pricing_policy = self::assert_object_or_null( $row['pricing_policy'] ?? null );
-
-		return new self(
-			isset( $row['id'] ) ? Coercion::coerce_int( $row['id'] ) : null,
-			Coercion::coerce_string( $row['name'] ?? null ),
-			Coercion::coerce_nullable_string( $row['description'] ?? null ),
-			BillingPolicy::from_array( is_array( $row['billing_policy'] ?? null ) ? $row['billing_policy'] : array() ),
-			isset( $row['delivery_policy'] ) && is_array( $row['delivery_policy'] ) ? DeliveryPolicy::from_array( $row['delivery_policy'] ) : null,
-			$pricing_policy,
-			Coercion::coerce_string( $row['category'] ?? null, self::DEFAULT_CATEGORY ),
-			Coercion::coerce_string( $row['status'] ?? null, self::DEFAULT_STATUS ),
-			Coercion::coerce_int( $row['sort_order'] ?? null, 0 ),
-			Coercion::coerce_nullable_string( $row['merchant_code'] ?? null ),
-			Coercion::coerce_nullable_string( $row['extension_slug'] ?? null )
-		);
+		return new self( $row );
 	}

 	/**
@@ -243,62 +166,66 @@ final class Plan {
 	/**
 	 * Set the display name.
 	 *
-	 * @param string $name Display name.
+	 * @param string $name Display name; must not be empty.
+	 * @throws DomainException If the name is empty.
 	 */
 	public function set_name( string $name ): void {
+		self::assert_name( $name );
 		$this->name = $name;
 	}

 	/**
-	 * Optional description.
+	 * Plan status.
 	 */
-	public function get_description(): ?string {
-		return $this->description;
+	public function get_status(): string {
+		return $this->status;
 	}

 	/**
-	 * Set the description.
+	 * Set the plan status. Setting the current status is a no-op, so a hydrated
+	 * unregistered status survives it.
 	 *
-	 * @param string|null $description Description.
+	 * @param string $status Plan status; must be registered.
+	 * @throws DomainException If the status is not registered.
 	 */
-	public function set_description( ?string $description ): void {
-		$this->description = $description;
-	}
+	public function set_status( string $status ): void {
+		if ( $status === $this->status ) {
+			return;
+		}

-	/**
-	 * Billing cadence.
-	 */
-	public function get_billing_policy(): BillingPolicy {
-		return $this->billing_policy;
+		self::assert_status( $status );
+		$this->status = $status;
 	}

 	/**
-	 * Set the billing cadence.
-	 *
-	 * @param BillingPolicy $billing_policy Billing cadence.
+	 * Owning extension slug, or null.
 	 */
-	public function set_billing_policy( BillingPolicy $billing_policy ): void {
-		$this->billing_policy = $billing_policy;
+	public function get_extension_slug(): ?string {
+		return $this->extension_slug;
 	}

 	/**
-	 * Optional delivery policy.
+	 * Billing payload, as stored.
+	 *
+	 * @return array<string, mixed>|null
 	 */
-	public function get_delivery_policy(): ?DeliveryPolicy {
-		return $this->delivery_policy;
+	public function get_billing_policy(): ?array {
+		return $this->billing_policy;
 	}

 	/**
-	 * Set the delivery policy.
+	 * Replace the billing payload.
 	 *
-	 * @param DeliveryPolicy|null $delivery_policy Delivery policy.
+	 * @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).
 	 */
-	public function set_delivery_policy( ?DeliveryPolicy $delivery_policy ): void {
-		$this->delivery_policy = $delivery_policy;
+	public function set_billing_policy( ?array $billing_policy ): void {
+		self::assert_policy( 'billing_policy', $billing_policy );
+		$this->billing_policy = Coercion::coerce_nullable_string_keyed( $billing_policy );
 	}

 	/**
-	 * Optional pricing payload, as stored.
+	 * Pricing payload, as stored.
 	 *
 	 * @return array<string, mixed>|null
 	 */
@@ -307,142 +234,139 @@ final class Plan {
 	}

 	/**
-	 * Set the pricing payload.
+	 * Replace the pricing payload.
 	 *
-	 * @param array<string, mixed>|null $pricing_policy Pricing payload.
-	 * @throws InvalidArgumentException If pricing_policy is not an object (string-keyed array).
+	 * @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).
 	 */
 	public function set_pricing_policy( ?array $pricing_policy ): void {
-		$this->pricing_policy = self::assert_object_or_null( $pricing_policy );
+		self::assert_policy( 'pricing_policy', $pricing_policy );
+		$this->pricing_policy = Coercion::coerce_nullable_string_keyed( $pricing_policy );
 	}

 	/**
-	 * Plan category.
-	 */
-	public function get_category(): string {
-		return $this->category;
-	}
-
-	/**
-	 * Set the plan category.
+	 * Delivery payload, as stored.
 	 *
-	 * @param string $category Plan category.
-	 */
-	public function set_category( string $category ): void {
-		$this->category = $category;
-	}
-
-	/**
-	 * Merchant lifecycle status.
+	 * @return array<string, mixed>|null
 	 */
-	public function get_status(): string {
-		return $this->status;
+	public function get_delivery_policy(): ?array {
+		return $this->delivery_policy;
 	}

 	/**
-	 * Set the merchant lifecycle status.
+	 * Replace the delivery payload.
 	 *
-	 * @param string $status Plan status.
-	 * @throws InvalidArgumentException If the status is unknown.
+	 * @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).
 	 */
-	public function set_status( string $status ): void {
-		self::validate_status( $status );
-		$this->status = $status;
+	public function set_delivery_policy( ?array $delivery_policy ): void {
+		self::assert_policy( 'delivery_policy', $delivery_policy );
+		$this->delivery_policy = Coercion::coerce_nullable_string_keyed( $delivery_policy );
 	}

 	/**
-	 * Manual display order.
+	 * Creation time (GMT) as stored, or null before insert.
 	 */
-	public function get_sort_order(): int {
-		return $this->sort_order;
+	public function get_date_created_gmt(): ?string {
+		return $this->date_created_gmt;
 	}

 	/**
-	 * Set the manual display order.
+	 * Assign the creation time after a successful insert.
 	 *
-	 * @param int $sort_order Sort order.
+	 * @param string $date_created_gmt Stored creation time (GMT, `Y-m-d H:i:s`).
 	 */
-	public function set_sort_order( int $sort_order ): void {
-		$this->sort_order = $sort_order;
+	public function set_date_created_gmt( string $date_created_gmt ): void {
+		$this->date_created_gmt = $date_created_gmt;
 	}

 	/**
-	 * Optional stable external identifier, or null. Immutable post-create.
+	 * Last update time (GMT) as stored, or null before insert.
 	 */
-	public function get_merchant_code(): ?string {
-		return $this->merchant_code;
+	public function get_date_updated_gmt(): ?string {
+		return $this->date_updated_gmt;
 	}

 	/**
-	 * Owning extension slug, or null.
+	 * Assign the update time after a successful write.
+	 *
+	 * @param string $date_updated_gmt Stored update time (GMT, `Y-m-d H:i:s`).
 	 */
-	public function get_extension_slug(): ?string {
-		return $this->extension_slug;
+	public function set_date_updated_gmt( string $date_updated_gmt ): void {
+		$this->date_updated_gmt = $date_updated_gmt;
 	}

 	/**
-	 * Serialize to the storage column shape (excluding generated id/timestamps).
-	 *
-	 * Policy value objects are returned as arrays and the pricing payload as
-	 * stored; the repository JSON-encodes them.
+	 * Serialize to the storage column shape (excluding the id and timestamps).
+	 * Policies are returned as arrays or null; the repository JSON-encodes them.
 	 *
 	 * @return array<string, mixed>
 	 */
 	public function to_storage(): array {
 		return array(
 			'name'            => $this->name,
-			'description'     => $this->description,
-			'billing_policy'  => $this->billing_policy->to_array(),
-			'delivery_policy' => null !== $this->delivery_policy ? $this->delivery_policy->to_array() : null,
-			'pricing_policy'  => $this->pricing_policy,
-			'category'        => $this->category,
 			'status'          => $this->status,
-			'sort_order'      => $this->sort_order,
-			'merchant_code'   => $this->merchant_code,
 			'extension_slug'  => $this->extension_slug,
+			'billing_policy'  => $this->billing_policy,
+			'pricing_policy'  => $this->pricing_policy,
+			'delivery_policy' => $this->delivery_policy,
 		);
 	}

 	/**
-	 * Validate a plan lifecycle status.
+	 * Refuse an empty or whitespace-only name.
 	 *
-	 * @param string $status Status to validate.
-	 * @throws InvalidArgumentException If the status is unknown.
-	 */
-	private static function validate_status( string $status ): void {
-		if ( ! in_array( $status, self::ALLOWED_STATUSES, true ) ) {
-			throw new InvalidArgumentException(
-				sprintf( 'Plan: invalid status "%s".', $status )
-			);
+	 * @param string $name Name to check.
+	 * @throws DomainException If `$name` is empty.
+	 */
+	private static function assert_name( string $name ): void {
+		if ( '' === trim( $name ) ) {
+			throw new DomainException( 'Plan: name is required and must be a non-empty string.' );
 		}
 	}

 	/**
-	 * Accept a pricing payload only as an object (string-keyed array) or null.
-	 * An empty array is accepted (a JSON `{}` decodes to it).
+	 * Refuse a status that is not a registered plan status.
 	 *
-	 * @param mixed $value Candidate payload.
-	 * @return array<string, mixed>|null
-	 * @throws InvalidArgumentException If the value is a list or not an array.
+	 * @param string $status Status to check.
+	 * @throws DomainException If `$status` is not registered.
 	 */
-	private static function assert_object_or_null( $value ): ?array {
-		if ( null === $value ) {
-			return null;
+	private static function assert_status( string $status ): void {
+		if ( ! PlanStatus::is_registered( $status ) ) {
+			throw new DomainException( sprintf( 'Plan: status "%s" is not registered.', $status ) );
 		}
+	}

-		$message = 'Plan: pricing_policy must be an object (string-keyed array) or null.';
-		if ( ! is_array( $value ) ) {
-			throw new InvalidArgumentException( $message );
+	/**
+	 * Refuse a missing or empty owning extension slug.
+	 *
+	 * @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 {
+		if ( null === $extension_slug || '' === $extension_slug ) {
+			throw new DomainException( 'Plan: extension_slug is required and must be a non-empty string.' );
 		}
+	}

-		$out = array();
-		foreach ( $value as $key => $item ) {
-			if ( ! is_string( $key ) ) {
-				throw new InvalidArgumentException( $message );
-			}
-			$out[ $key ] = $item;
+	/**
+	 * 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.
+	 *
+	 * @param string $field Policy field name, for the error message.
+	 * @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 {
+		if ( null === $value ) {
+			return;
 		}

-		return $out;
+		$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 ) );
+		}
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/PlanStatus.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/PlanStatus.php
new file mode 100644
index 00000000000..848a91085d8
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/PlanStatus.php
@@ -0,0 +1,69 @@
+<?php
+/**
+ * PlanStatus - the engine's default plan status slugs plus read helpers over
+ * the {@see StatusRegistry}.
+ *
+ * Plan status is opaque engine data. The defaults are shared slugs and carry no
+ * engine meaning; the engine enforces no transitions. Extensions may register
+ * more through {@see StatusRegistry::register()}. The {@see Plan} entity refuses
+ * to write a status that is not registered.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * PlanStatus value/helper class.
+ */
+final class PlanStatus {
+
+	public const ACTIVE   = 'active';
+	public const ARCHIVED = 'archived';
+
+	/**
+	 * The engine's default plan statuses (the registry seed).
+	 *
+	 * @return array<int, string>
+	 */
+	public static function get_defaults(): array {
+		return array(
+			self::ACTIVE,
+			self::ARCHIVED,
+		);
+	}
+
+	/**
+	 * Every registered plan status: the engine defaults, then extension
+	 * registrations.
+	 *
+	 * @return array<int, string>
+	 */
+	public static function get_all(): array {
+		return StatusRegistry::get_all( StatusRegistry::KIND_PLAN );
+	}
+
+	/**
+	 * Whether `$status` is a registered plan status (an engine default or an
+	 * extension registration). Write paths accept only registered statuses.
+	 *
+	 * @param string $status Status to check.
+	 */
+	public static function is_registered( string $status ): bool {
+		return StatusRegistry::is_registered( StatusRegistry::KIND_PLAN, $status );
+	}
+
+	/**
+	 * Whether `$status` is a well-formed status slug (lowercase letters and digits in
+	 * words joined by single hyphens, at most 20 characters), registered or not.
+	 *
+	 * @param string $status Status to check.
+	 */
+	public static function is_valid( string $status ): bool {
+		return StatusRegistry::is_valid_slug( $status );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/StatusRegistry.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/StatusRegistry.php
index 6ef258dac51..5d0f8c1e92e 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/StatusRegistry.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/StatusRegistry.php
@@ -1,10 +1,10 @@
 <?php
 /**
- * StatusRegistry - the set of registered contract and cycle statuses.
+ * StatusRegistry - the set of registered contract, cycle and plan statuses.
  *
  * Statuses are opaque engine data: the engine ships a default set per kind
- * ({@see ContractStatus::get_defaults()}, {@see CycleStatus::get_defaults()}) and
- * extensions may register more. The registry holds slugs only - no labels, no
+ * ({@see ContractStatus::get_defaults()}, {@see CycleStatus::get_defaults()},
+ * {@see PlanStatus::get_defaults()}) and extensions may register more. The registry holds slugs only - no labels, no
  * transitions, no meaning - and is global (not per owner). Registration is the
  * write-path allowlist: entity setters and the cycle status write refuse a slug
  * that is not registered. Stored values outside the registry (for example one
@@ -39,6 +39,11 @@ final class StatusRegistry {
 	 */
 	public const KIND_CYCLE = 'cycle';

+	/**
+	 * Plan status kind.
+	 */
+	public const KIND_PLAN = 'plan';
+
 	/**
 	 * Longest accepted slug (the status columns are `varchar(20)`).
 	 */
@@ -67,7 +72,7 @@ final class StatusRegistry {
 	 * Idempotent: registering a default or an already-registered slug changes
 	 * nothing.
 	 *
-	 * @param string $kind One of {@see self::KIND_CONTRACT} or {@see self::KIND_CYCLE}.
+	 * @param string $kind One of {@see self::KIND_CONTRACT}, {@see self::KIND_CYCLE} or {@see self::KIND_PLAN}.
 	 * @param string $slug Status slug; must satisfy {@see self::is_valid_slug()}.
 	 * @throws InvalidArgumentException When the kind is unknown or the slug is malformed.
 	 */
@@ -95,7 +100,7 @@ final class StatusRegistry {
 	 * Whether `$slug` is a registered status (a default or an extension
 	 * registration) for `$kind`.
 	 *
-	 * @param string $kind One of {@see self::KIND_CONTRACT} or {@see self::KIND_CYCLE}.
+	 * @param string $kind One of {@see self::KIND_CONTRACT}, {@see self::KIND_CYCLE} or {@see self::KIND_PLAN}.
 	 * @param string $slug Status slug.
 	 * @throws InvalidArgumentException When the kind is unknown.
 	 */
@@ -107,14 +112,24 @@ final class StatusRegistry {
 	 * Every registered status for `$kind`: the engine defaults first, then
 	 * extension registrations in registration order.
 	 *
-	 * @param string $kind One of {@see self::KIND_CONTRACT} or {@see self::KIND_CYCLE}.
+	 * @param string $kind One of {@see self::KIND_CONTRACT}, {@see self::KIND_CYCLE} or {@see self::KIND_PLAN}.
 	 * @return array<int, string>
 	 * @throws InvalidArgumentException When the kind is unknown.
 	 */
 	public static function get_all( string $kind ): array {
 		self::assert_known_kind( $kind );

-		$defaults = self::KIND_CONTRACT === $kind ? ContractStatus::get_defaults() : CycleStatus::get_defaults();
+		switch ( $kind ) {
+			case self::KIND_CONTRACT:
+				$defaults = ContractStatus::get_defaults();
+				break;
+			case self::KIND_CYCLE:
+				$defaults = CycleStatus::get_defaults();
+				break;
+			default:
+				$defaults = PlanStatus::get_defaults();
+				break;
+		}

 		return array_merge( $defaults, self::$registered[ $kind ] ?? array() );
 	}
@@ -146,7 +161,7 @@ final class StatusRegistry {
 	 * @throws InvalidArgumentException When the kind is unknown.
 	 */
 	private static function assert_known_kind( string $kind ): void {
-		if ( self::KIND_CONTRACT !== $kind && self::KIND_CYCLE !== $kind ) {
+		if ( ! in_array( $kind, array( self::KIND_CONTRACT, self::KIND_CYCLE, self::KIND_PLAN ), true ) ) {
 			throw new InvalidArgumentException(
 				sprintf( 'StatusRegistry: unknown status kind "%s".', $kind )
 			);
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/Coercion.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/Coercion.php
index 574d60974a4..fc2ad32cfb1 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/Coercion.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/Coercion.php
@@ -116,6 +116,18 @@ final class Coercion {
 		return $result;
 	}

+	/**
+	 * Coerce a value to a string-keyed array, or null when it is not an array. Only the
+	 * declared type changes: PHP keeps an integer-like key as an int, so a list stays a list.
+	 *
+	 * @param mixed $value The raw value.
+	 * @return array<string, mixed>|null
+	 * @internal Engine implementation detail. Not part of the supported extension API.
+	 */
+	public static function coerce_nullable_string_keyed( $value ): ?array {
+		return is_array( $value ) ? self::coerce_string_keyed( $value ) : null;
+	}
+
 	/**
 	 * Coerce a value to a list of string-keyed rows. A non-array yields an empty
 	 * list; non-array rows are skipped.
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 fe7df76f64c..7fa2ee8e14b 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
@@ -1,8 +1,16 @@
 <?php
 /**
- * BillingPolicy - typed value object for a plan's billing cadence and trial.
+ * BillingPolicy - an optional parser for a billing cadence and trial payload.
  *
- * Mirrors the `billing_policy` JSON column shape. Shape:
+ * An extension may use {@see self::from_array()} to parse its plan billing arrays. The
+ * engine stores plan policies opaquely and does not construct this on plan writes. It
+ * does read one payload with it: renewal and reactivation parse a contract's plan
+ * snapshot `billing_policy`, and the live plan's `billing_policy` when the contract has
+ * no usable snapshot policy, both through {@see self::from_array()}, so a plan whose
+ * contracts the engine renews must store this shape there. A policy always has a
+ * usable cadence: construction refuses an unknown period or a non-positive interval.
+ * The array
+ * shape it parses:
  *   {
  *     period:         'day' | 'week' | 'month' | 'year',
  *     interval:       int,
@@ -79,8 +87,15 @@ final class BillingPolicy {
 	 * @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.
 	 */
 	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 );

 		$this->period         = $period;
@@ -93,7 +108,8 @@ final class BillingPolicy {
 	/**
 	 * Hydrate from the JSON-decoded `billing_policy` column shape.
 	 *
-	 * Missing nullable keys default to null. `period` and `interval` are required.
+	 * Missing nullable keys default to null. `period` and `interval` are required, and
+	 * must form a usable cadence (see the constructor).
 	 *
 	 * @param array<string, mixed> $data Decoded billing_policy row.
 	 * @throws DomainException If the data is not valid.
@@ -176,19 +192,11 @@ final class BillingPolicy {
 	 *
 	 * @param DateTimeImmutable $anchor The moment the next cycle is computed from.
 	 * @return DateTimeImmutable The next renewal moment in UTC.
-	 * @throws DomainException If `period` is unknown or `interval` is not positive.
 	 */
 	public function compute_next_renewal_from( DateTimeImmutable $anchor ): DateTimeImmutable {
-		if ( $this->interval <= 0 ) {
-			throw new DomainException(
-				sprintf( 'BillingPolicy::compute_next_renewal_from(): interval must be positive, got %d.', $this->interval )
-			);
-		}
-
-		$unit = $this->normalize_unit( $this->period, 'period' );
-		$utc  = $anchor->setTimezone( new DateTimeZone( 'UTC' ) );
+		$utc = $anchor->setTimezone( new DateTimeZone( 'UTC' ) );

-		return $utc->modify( sprintf( '+%d %s', $this->interval, $unit ) );
+		return $utc->modify( sprintf( '+%d %s', $this->interval, $this->period ) );
 	}

 	/**
@@ -200,7 +208,7 @@ final class BillingPolicy {
 	 *
 	 * @param DateTimeImmutable $contract_start Moment the contract was created.
 	 * @return DateTimeImmutable The first renewal moment in UTC.
-	 * @throws DomainException If trial length is not positive or trial unit is unknown.
+	 * @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 ) {
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/DeliveryPolicy.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/DeliveryPolicy.php
index c7099062158..2fed06c187a 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/DeliveryPolicy.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/DeliveryPolicy.php
@@ -1,10 +1,12 @@
 <?php
 /**
- * DeliveryPolicy - typed value object for a plan's delivery anchors, cutoff,
- * and intent.
+ * DeliveryPolicy - an optional parser for a delivery anchors, cutoff, and
+ * intent payload.
  *
- * Mirrors the `delivery_policy` JSON column shape, deliberately thin for now.
- * Shape:
+ * An opt-in parser for extensions: an extension may use {@see self::from_array()} to
+ * parse its plan delivery arrays. No engine code consumes it yet; the engine stores
+ * plan policies opaquely and does not construct this on plan reads or writes.
+ * Deliberately thin for now. The array shape it parses:
  *   {
  *     anchors: [{ type: 'MONTHDAY', day: int }, { type: 'YEARDAY', day: int, month: int }, ...],
  *     cutoff:  ?mixed,
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 020b88710ef..f02b1e23ac2 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
@@ -101,29 +101,21 @@ final class PlanSnapshot {
 	}

 	/**
-	 * The frozen billing cadence, reconstructed from the snapshot payload.
+	 * The frozen billing cadence captured at signup, parsed with the engine's renewal
+	 * rule ({@see BillingPolicy::from_array()}), so it holds after
+	 * the source plan is edited or deleted. Null when the payload carries no billing
+	 * policy array; throws when one is present but unusable, so a caller can log why
+	 * before falling back.
 	 *
-	 * Sourced from the `billing_policy` entry captured at signup, NOT the live plan -
-	 * so a consumer reads the cadence a contract is billed under straight off the
-	 * snapshot, even after the plan it came from is edited or deleted, with no live
-	 * {@see \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository}
-	 * join. Returns null when the payload carries no (or an unreadable) billing policy,
-	 * so a caller degrades to "no cadence" rather than fataling.
+	 * @throws DomainException If the stored policy does not parse or has no usable cadence.
 	 */
-	public function get_billing_policy(): ?BillingPolicy {
+	public function read_billing_policy(): ?BillingPolicy {
 		$policy = $this->data['billing_policy'] ?? null;
 		if ( ! is_array( $policy ) ) {
 			return null;
 		}

-		try {
-			return BillingPolicy::from_array( self::string_keyed( $policy ) );
-		} catch ( DomainException $e ) {
-			// A structurally-invalid stored policy degrades to "no cadence" rather than
-			// fataling the read; snapshots this engine writes always carry a valid policy.
-			unset( $e );
-			return null;
-		}
+		return BillingPolicy::from_array( Coercion::coerce_string_keyed( $policy ) );
 	}

 	/**
@@ -136,7 +128,7 @@ final class PlanSnapshot {
 	public function get_pricing_policy(): ?array {
 		$policy = $this->data['pricing_policy'] ?? null;

-		return is_array( $policy ) ? self::string_keyed( $policy ) : null;
+		return is_array( $policy ) ? Coercion::coerce_string_keyed( $policy ) : null;
 	}

 	/**
@@ -157,21 +149,4 @@ final class PlanSnapshot {
 	public function to_payload(): array {
 		return $this->data;
 	}
-
-	/**
-	 * Re-key a nested payload array as string-keyed for the typed value-object factory.
-	 * A no-op at runtime (decoded JSON object keys are already strings); it recovers the
-	 * string-keyed type that erases to `array<int|string, mixed>`.
-	 *
-	 * @param array<int|string, mixed> $value Nested payload array.
-	 * @return array<string, mixed>
-	 */
-	private static function string_keyed( array $value ): array {
-		$out = array();
-		foreach ( $value as $key => $item ) {
-			$out[ (string) $key ] = $item;
-		}
-
-		return $out;
-	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
index 5f9bba82130..3200a85e860 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
@@ -30,7 +30,6 @@ use DomainException;
 use RuntimeException;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal\RenewalCalculator;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
@@ -224,16 +223,30 @@ final class Reactivation {
 	 * The billing policy the forward roll steps by: the contract's own frozen plan
 	 * terms first (the snapshot is what the contract actually bills under - the same
 	 * source the renewal money-path resolves), falling back to the live selling plan
-	 * for a contract with no snapshot, and null when neither resolves.
+	 * (parsing its billing payload) when the contract has no snapshot or its snapshot
+	 * policy does not parse or has no usable cadence (logged), and null when neither
+	 * resolves (a live plan with a null or unusable billing payload is logged too). Both sources are read with the renewal rule
+	 * ({@see BillingPolicy::from_array()}), so the forward roll never
+	 * throws on a stored payload.
 	 *
 	 * @param Contract $contract The contract.
 	 */
 	private function billing_policy( Contract $contract ): ?BillingPolicy {
 		$snapshot = $contract->get_plan_snapshot();
 		if ( null !== $snapshot ) {
-			$policy = $snapshot->get_billing_policy();
-			if ( $policy instanceof BillingPolicy ) {
-				return $policy;
+			try {
+				$policy = $snapshot->read_billing_policy();
+				if ( null !== $policy ) {
+					return $policy;
+				}
+			} catch ( DomainException $e ) {
+				wc_get_logger()->warning(
+					sprintf( 'Reactivation: contract %d has an unreadable plan-snapshot billing policy; falling back to the live plan. %s', (int) $contract->get_id(), $e->getMessage() ),
+					array(
+						'source'      => self::LOG_SOURCE,
+						'contract_id' => (int) $contract->get_id(),
+					)
+				);
 			}
 		}

@@ -243,7 +256,37 @@ final class Reactivation {
 		}

 		$plan = $this->plans->find( $plan_id );
+		if ( null === $plan ) {
+			return null;
+		}

-		return $plan instanceof Plan ? $plan->get_billing_policy() : null;
+		$billing = $plan->get_billing_policy();
+		if ( null === $billing ) {
+			wc_get_logger()->warning(
+				sprintf( 'Reactivation: contract %d has a live plan %d with no billing policy; a past-due next payment is floored at now.', (int) $contract->get_id(), (int) $plan_id ),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => (int) $contract->get_id(),
+					'plan_id'     => (int) $plan_id,
+				)
+			);
+
+			return null;
+		}
+
+		try {
+			return BillingPolicy::from_array( $billing );
+		} catch ( DomainException $e ) {
+			wc_get_logger()->warning(
+				sprintf( 'Reactivation: contract %d has an unreadable live plan billing policy; a past-due next payment is floored at now. %s', (int) $contract->get_id(), $e->getMessage() ),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => (int) $contract->get_id(),
+					'plan_id'     => (int) $plan_id,
+				)
+			);
+
+			return null;
+		}
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
index fc5960553f5..14c465279ca 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
@@ -39,7 +39,6 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 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\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal\RenewalCalculator;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
@@ -388,9 +387,12 @@ final class RenewalEngine {
 	/**
 	 * Resolve the billing policy the next cycle bills under, from the contract's own plan
 	 * snapshot - the live source of truth, so a contract updated since an earlier cycle bills
-	 * on its current terms. Falls back to the contract's selling plan when it carries no
-	 * snapshot, and returns null when neither resolves (a deleted plan) so the caller skips
-	 * gracefully rather than mis-billing.
+	 * on its current terms. Falls back to parsing the live selling plan's billing payload when
+	 * the contract carries no snapshot, or one whose billing policy is absent or unusable (that
+	 * case is logged), and returns null when neither resolves (a deleted plan, or a live
+	 * billing payload that is null, does not parse or has no usable cadence; the payload
+	 * cases are logged) so the caller parks the contract rather than mis-billing or
+	 * retrying every tick.
 	 *
 	 * @param Contract $contract The contract being renewed.
 	 * @return BillingPolicy|null The billing policy, or null when unresolvable.
@@ -398,21 +400,21 @@ final class RenewalEngine {
 	private function resolve_billing_policy( Contract $contract ): ?BillingPolicy {
 		$snapshot = $this->resolve_plan_snapshot( $contract );
 		if ( $snapshot instanceof PlanSnapshot ) {
-			$payload = $snapshot->to_array();
-			if ( isset( $payload['billing_policy'] ) && is_array( $payload['billing_policy'] ) ) {
-				try {
-					return BillingPolicy::from_array( self::string_keyed( $payload['billing_policy'] ) );
-				} catch ( \DomainException $e ) {
-					// A corrupt stored policy must not crash the scheduled run; fall through to the
-					// live plan below so the renewal can still resolve on current terms.
-					wc_get_logger()->warning(
-						sprintf( 'RenewalEngine: contract %d has an unreadable plan-snapshot billing policy; falling back to the live plan. %s', (int) $contract->get_id(), $e->getMessage() ),
-						array(
-							'source'      => self::LOG_SOURCE,
-							'contract_id' => (int) $contract->get_id(),
-						)
-					);
+			try {
+				$policy = $snapshot->read_billing_policy();
+				if ( null !== $policy ) {
+					return $policy;
 				}
+			} catch ( \DomainException $e ) {
+				// A corrupt stored policy must not crash the scheduled run; fall through to the
+				// live plan below so the renewal can still resolve on current terms.
+				wc_get_logger()->warning(
+					sprintf( 'RenewalEngine: contract %d has an unreadable plan-snapshot billing policy; falling back to the live plan. %s', (int) $contract->get_id(), $e->getMessage() ),
+					array(
+						'source'      => self::LOG_SOURCE,
+						'contract_id' => (int) $contract->get_id(),
+					)
+				);
 			}
 		}

@@ -422,7 +424,38 @@ final class RenewalEngine {
 		}

 		$plan = $this->plans->find( $plan_id );
-		return $plan instanceof Plan ? $plan->get_billing_policy() : null;
+		if ( null === $plan ) {
+			return null;
+		}
+
+		$billing = $plan->get_billing_policy();
+		if ( null === $billing ) {
+			wc_get_logger()->warning(
+				sprintf( 'RenewalEngine: contract %d has a live plan %d with no billing policy; the renewal cannot be processed.', (int) $contract->get_id(), (int) $plan_id ),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => (int) $contract->get_id(),
+					'plan_id'     => (int) $plan_id,
+				)
+			);
+
+			return null;
+		}
+
+		try {
+			return BillingPolicy::from_array( $billing );
+		} catch ( \DomainException $e ) {
+			wc_get_logger()->warning(
+				sprintf( 'RenewalEngine: contract %d has an unreadable live plan billing policy; the renewal cannot be processed. %s', (int) $contract->get_id(), $e->getMessage() ),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => (int) $contract->get_id(),
+					'plan_id'     => (int) $plan_id,
+				)
+			);
+
+			return null;
+		}
 	}

 	/**
@@ -983,20 +1016,6 @@ final class RenewalEngine {
 		return is_numeric( $value ) ? (int) $value : 0;
 	}

-	/**
-	 * Coerce a decoded array to a string-keyed array for the typed value-object factories.
-	 *
-	 * @param array<mixed, mixed> $value The decoded array.
-	 * @return array<string, mixed>
-	 */
-	private static function string_keyed( array $value ): array {
-		$out = array();
-		foreach ( $value as $key => $item ) {
-			$out[ (string) $key ] = $item;
-		}
-		return $out;
-	}
-
 	/**
 	 * Attempt the gateway charge for `$renewal_order`.
 	 *
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php
index dead97a62cb..196f44d6367 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php
@@ -1,6 +1,7 @@
 <?php
 /**
- * PlanRepository - persistence for {@see Plan} entities.
+ * PlanRepository - persistence for {@see Plan} entities. The three policy columns are
+ * stored as the opaque JSON payloads the entity carries; null stays SQL NULL.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage
  */
@@ -44,86 +45,63 @@ final class PlanRepository {
 	private const ORDERBY_COLUMNS = array(
 		'id'               => 'id',
 		'name'             => 'name',
-		'sort_order'       => 'sort_order',
-		'status'           => 'status',
 		'date_created_gmt' => 'date_created_gmt',
 		'date_updated_gmt' => 'date_updated_gmt',
 	);

 	/**
-	 * Insert a new plan and stamp its id back onto the entity.
-	 *
-	 * `merchant_code` uniqueness is DB-enforced per extension (composite UNIQUE
-	 * with `extension_slug`, NULLs distinct): a duplicate code within one
-	 * extension fails the insert and surfaces as the RuntimeException.
+	 * Insert a new plan and stamp its id and stored dates back onto the entity.
 	 *
 	 * @param Plan $plan Plan to insert.
 	 * @return int The new plan id.
-	 * @throws \RuntimeException If the insert fails, including on a duplicate merchant_code.
+	 * @throws \RuntimeException If the insert fails.
 	 */
 	public function insert( Plan $plan ): int {
 		global $wpdb;

 		$now  = gmdate( 'Y-m-d H:i:s' );
-		$data = $plan->to_storage();
+		$data = $this->get_row_data( $plan );
+
+		$data['date_created_gmt'] = $now;
+		$data['date_updated_gmt'] = $now;

 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-		$inserted = $wpdb->insert(
-			SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS ),
-			array(
-				'name'             => $data['name'],
-				'description'      => $data['description'],
-				'billing_policy'   => wp_json_encode( $data['billing_policy'] ),
-				'delivery_policy'  => null !== $data['delivery_policy'] ? wp_json_encode( $data['delivery_policy'] ) : null,
-				'inventory_policy' => null,
-				'pricing_policy'   => null !== $data['pricing_policy'] ? wp_json_encode( $data['pricing_policy'] ) : null,
-				'category'         => $data['category'],
-				'status'           => $data['status'],
-				'sort_order'       => $data['sort_order'],
-				'merchant_code'    => $data['merchant_code'],
-				'extension_slug'   => $data['extension_slug'],
-				'date_created_gmt' => $now,
-				'date_updated_gmt' => $now,
-			)
-		);
+		$inserted = $wpdb->insert( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS ), $data );

 		if ( false === $inserted ) {
-			throw new \RuntimeException( 'Failed to insert plan.' );
+			throw new \RuntimeException( sprintf( 'Failed to insert plan: %s', esc_html( $wpdb->last_error ) ) );
 		}

 		$id = (int) $wpdb->insert_id;
 		$plan->set_id( $id );
+		$plan->set_date_created_gmt( $now );
+		$plan->set_date_updated_gmt( $now );

 		return $id;
 	}

 	/**
-	 * Fetch a plan by id and (optionally) extension slug.
-	 * Most usages from applications should specify the extension slug
-	 * to guard against cross-application collisions.
+	 * Fetch a plan by id, in any status, of any extension or (when given) only of the given extension.
 	 *
 	 * @param int         $id             Plan id.
-	 * @param string|null $extension_slug Extension slug to filter plans by.
-	 * @return Plan|null Hydrated plan, or null if not found.
+	 * @param string|null $extension_slug Owning extension slug to scope the read to; null reads any extension.
+	 * @return Plan|null Hydrated plan, or null if not found (also when it belongs to another extension).
 	 */
 	public function find( int $id, ?string $extension_slug = null ): ?Plan {
 		global $wpdb;

 		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );

-		$extension_clause = '';
-		$params           = array( $id );
-		if ( null !== $extension_slug && 'any' !== $extension_slug ) {
-			$extension_clause = ' AND extension_slug = %s';
-			$params[]         = $extension_slug;
+		if ( null === $extension_slug ) {
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+			$sql = $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id );
+		} else {
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+			$sql = $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d AND extension_slug = %s", $id, $extension_slug );
 		}

-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-		$row = $wpdb->get_row(
-			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-			$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d {$extension_clause}", $params ),
-			ARRAY_A
-		);
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
+		$row = $wpdb->get_row( $sql, ARRAY_A );

 		if ( null === $row ) {
 			return null;
@@ -136,12 +114,14 @@ final class PlanRepository {
 	 * Query plans.
 	 *
 	 * Supported args: limit, offset, search, status, extension_slugs, ids,
-	 * orderby, order. `extension_slugs` filters by owning extension: a list
-	 * of slugs (a single-slug list unfolds to an equality match) or
-	 * `array( 'any' )` to skip the scope. `ids` filters to plans whose id is
-	 * in the given int list; it composes with the other filters and is
-	 * honored by count(). Results default to manual order, oldest id as a
-	 * stable tiebreaker.
+	 * orderby, order. `status` is a slug or a list of slugs (an empty list or a
+	 * non-string entry matches nothing). `extension_slugs` filters by owning
+	 * extension: a list of slugs (a single-slug list unfolds to an equality
+	 * match) or `array( 'any' )` to skip the scope. `ids` filters to plans whose
+	 * id is in the given int list; it composes with the other filters and is
+	 * honored by count(). `search` matches the name. `orderby` is one of `id`
+	 * (default), `name`, `date_created_gmt`, `date_updated_gmt`, with the id
+	 * ascending as a stable tiebreaker.
 	 *
 	 * @param array<string, mixed> $args Query args.
 	 * @return array<int, Plan>
@@ -173,7 +153,7 @@ final class PlanRepository {
 			if ( ! is_array( $row ) ) {
 				continue;
 			}
-			$plans[] = $this->hydrate_row( self::string_keyed_array( $row ) );
+			$plans[] = $this->hydrate_row( Coercion::coerce_string_keyed( $row ) );
 		}

 		return $plans;
@@ -209,16 +189,21 @@ final class PlanRepository {
 	}

 	/**
-	 * Persist changes to an existing plan.
+	 * Write only the given columns of an existing plan's row (plus its update time, stamped
+	 * back onto the entity), so columns a concurrent writer changed in between keep its
+	 * values. The write is scoped to the plan's extension: it matches the row by id and the
+	 * entity's extension slug, so a row of another extension is never written. Plan meta is
+	 * never touched. Existence (by id and extension slug) is checked only when the write changes
+	 * nothing. Opens no transaction: a caller's transaction covers the write.
 	 *
-	 * `merchant_code` is immutable post-create and intentionally not written here,
-	 * same as `id`.
-	 *
-	 * @param Plan $plan Plan to update. Must have an id.
-	 * @return bool True on success.
-	 * @throws \RuntimeException If the plan has no id.
+	 * @param Plan               $plan   Plan to read the values from. Must have an id and an extension slug.
+	 * @param array<int, string> $fields Columns to write: `name`, `status`, `billing_policy`,
+	 *                                   `pricing_policy`, `delivery_policy`.
+	 * @return bool False when no row of the plan's extension has its id (nothing is written).
+	 * @throws \InvalidArgumentException If a field is not a writable column.
+	 * @throws \RuntimeException If the plan has no id or no extension slug, or the update fails.
 	 */
-	public function update( Plan $plan ): bool {
+	public function update_fields( Plan $plan, array $fields ): bool {
 		global $wpdb;

 		$id = $plan->get_id();
@@ -226,37 +211,82 @@ final class PlanRepository {
 			throw new \RuntimeException( 'Cannot update a plan that has no id.' );
 		}

-		$data = $plan->to_storage();
+		$extension_slug = $plan->get_extension_slug();
+		if ( null === $extension_slug || '' === $extension_slug ) {
+			throw new \RuntimeException( 'Cannot update a plan that has no extension slug.' );
+		}
+
+		$row     = $this->get_row_data( $plan );
+		$columns = array();
+		foreach ( $fields as $field ) {
+			if ( 'extension_slug' === $field || ! array_key_exists( $field, $row ) ) {
+				throw new \InvalidArgumentException( esc_html( sprintf( 'Cannot update plan field "%s".', $field ) ) );
+			}
+			$columns[ $field ] = $row[ $field ];
+		}
+
+		$now = gmdate( 'Y-m-d H:i:s' );

 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
 		$updated = $wpdb->update(
 			SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS ),
+			array_merge( $columns, array( 'date_updated_gmt' => $now ) ),
 			array(
-				'name'             => $data['name'],
-				'description'      => $data['description'],
-				'billing_policy'   => wp_json_encode( $data['billing_policy'] ),
-				'delivery_policy'  => null !== $data['delivery_policy'] ? wp_json_encode( $data['delivery_policy'] ) : null,
-				'pricing_policy'   => null !== $data['pricing_policy'] ? wp_json_encode( $data['pricing_policy'] ) : null,
-				'category'         => $data['category'],
-				'status'           => $data['status'],
-				'sort_order'       => $data['sort_order'],
-				'extension_slug'   => $data['extension_slug'],
-				'date_updated_gmt' => gmdate( 'Y-m-d H:i:s' ),
-			),
-			array( 'id' => $id )
+				'id'             => $id,
+				'extension_slug' => $extension_slug,
+			)
 		);

-		return false !== $updated;
+		if ( false === $updated ) {
+			throw new \RuntimeException( sprintf( 'Failed to update plan %d: %s', (int) $id, esc_html( $wpdb->last_error ) ) );
+		}
+
+		// Zero changed rows: no row of this extension, or identical values written within the same second.
+		if ( 0 === $updated && ! $this->exists( $id, $extension_slug ) ) {
+			return false;
+		}
+
+		$plan->set_date_updated_gmt( $now );
+
+		return true;
+	}
+
+	/**
+	 * Whether a plan row exists, of any extension or (when given) of the given extension.
+	 *
+	 * @param int         $id             Plan id.
+	 * @param string|null $extension_slug Owning extension slug to scope the check to; null checks any extension.
+	 */
+	public function exists( int $id, ?string $extension_slug = null ): bool {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
+
+		if ( null === $extension_slug ) {
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+			$sql = $wpdb->prepare( "SELECT id FROM {$table} WHERE id = %d", $id );
+		} else {
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+			$sql = $wpdb->prepare( "SELECT id FROM {$table} WHERE id = %d AND extension_slug = %s", $id, $extension_slug );
+		}
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
+		$found = $wpdb->get_var( $sql );
+
+		return null !== $found;
 	}

 	/**
-	 * Delete a plan by id and (optionally) extension slug.
+	 * Delete a plan and its meta rows by id and (optionally) extension slug.
 	 * Most usages from applications should specify the extension slug
 	 * to guard against cross-application operations.
 	 *
+	 * A failed delete throws, so a caller's transaction can roll back.
+	 *
 	 * @param int         $id             Plan id.
 	 * @param string|null $extension_slug Extension slug for the plan.
 	 * @return bool True when a row was removed.
+	 * @throws \RuntimeException If the plan row or its meta rows fail to delete.
 	 */
 	public function delete( int $id, ?string $extension_slug = null ): bool {
 		global $wpdb;
@@ -270,75 +300,175 @@ final class PlanRepository {
 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
 		$deleted = $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS ), $where );

-		return (bool) $deleted;
+		if ( false === $deleted ) {
+			throw new \RuntimeException( sprintf( 'Failed to delete plan %d: %s', (int) $id, esc_html( $wpdb->last_error ) ) );
+		}
+
+		if ( 0 === $deleted ) {
+			return false;
+		}
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$deleted_meta = $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLAN_META ), array( 'plan_id' => $id ) );
+
+		if ( false === $deleted_meta ) {
+			throw new \RuntimeException( sprintf( 'Failed to delete meta rows for plan %d: %s', (int) $id, esc_html( $wpdb->last_error ) ) );
+		}
+
+		return true;
 	}

 	/**
-	 * Persist manual sort-order values for plans in one extension.
+	 * Add a meta row for a plan, like `add_post_meta()`.
 	 *
-	 * @param string          $extension_slug   Extension slug for the plans to operate on.
-	 * @param array<int, int> $sort_order_by_id Map of plan id => sort order.
-	 * @return bool True when every update succeeds.
+	 * @param int    $plan_id Plan id.
+	 * @param string $key     Meta key.
+	 * @param mixed  $value   Meta value; serialized when not scalar.
+	 * @param bool   $unique  When true, add nothing if the key already exists. Advisory:
+	 *                        checked before the insert with no unique index.
+	 * @return int|null The new meta row id, or null when `$unique` and the key exists.
+	 * @throws \InvalidArgumentException If `$key` is empty.
+	 * @throws \RuntimeException If the insert fails.
 	 */
-	public function reorder( string $extension_slug, array $sort_order_by_id ): bool {
+	public function add_meta( int $plan_id, string $key, $value, bool $unique = false ): ?int {
 		global $wpdb;

-		if ( ! self::is_valid_extension_slug( $extension_slug ) ) {
-			return false;
+		if ( '' === $key ) {
+			throw new \InvalidArgumentException( 'Plan meta key must not be empty.' );
+		}
+
+		if ( $unique && array() !== $this->find_meta_values( $plan_id, $key ) ) {
+			return null;
+		}
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+		$inserted = $wpdb->insert(
+			SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLAN_META ),
+			array(
+				'plan_id'    => $plan_id,
+				'meta_key'   => $key,
+				'meta_value' => maybe_serialize( $value ),
+			)
+		);
+
+		if ( false === $inserted ) {
+			throw new \RuntimeException( sprintf( 'Failed to add plan meta "%s" for plan %d: %s', esc_html( $key ), (int) $plan_id, esc_html( $wpdb->last_error ) ) );
 		}

-		if ( array() === $sort_order_by_id ) {
+		return (int) $wpdb->insert_id;
+	}
+
+	/**
+	 * Update a plan's meta rows for `$key`, like `update_post_meta()`: adds a row when
+	 * the key is absent, else rewrites every row for the key, or only the rows holding
+	 * `$prev_value`. The absent-key check runs before the write with no unique index.
+	 *
+	 * @param int    $plan_id    Plan id.
+	 * @param string $key        Meta key.
+	 * @param mixed  $value      New value; serialized when not scalar.
+	 * @param mixed  $prev_value Only update rows holding this value; null updates all rows for the key.
+	 *                           Any other value ('' and false included) matches literally.
+	 * @return bool True when a row was added or at least one row changed.
+	 * @throws \InvalidArgumentException If `$key` is empty.
+	 * @throws \RuntimeException If a write fails.
+	 */
+	public function update_meta( int $plan_id, string $key, $value, $prev_value = null ): bool {
+		global $wpdb;
+
+		if ( '' === $key ) {
+			throw new \InvalidArgumentException( 'Plan meta key must not be empty.' );
+		}
+
+		if ( array() === $this->find_meta_values( $plan_id, $key ) ) {
+			$this->add_meta( $plan_id, $key, $value );
 			return true;
 		}

-		$ok  = true;
-		$now = gmdate( 'Y-m-d H:i:s' );
+		$where = array(
+			'plan_id'  => $plan_id,
+			'meta_key' => $key,
+		);
+		if ( null !== $prev_value ) {
+			$where['meta_value'] = maybe_serialize( $prev_value );
+		}

-		$plans_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
-		$ids         = array_map( 'intval', array_keys( $sort_order_by_id ) );
-		foreach ( $ids as $id ) {
-			if ( $id <= 0 ) {
-				return false;
-			}
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+		$updated = $wpdb->update(
+			SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLAN_META ),
+			array( 'meta_value' => maybe_serialize( $value ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+			$where
+		);
+
+		if ( false === $updated ) {
+			throw new \RuntimeException( sprintf( 'Failed to update plan meta "%s" for plan %d: %s', esc_html( $key ), (int) $plan_id, esc_html( $wpdb->last_error ) ) );
 		}

-		$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
-		$params       = array_merge( array( $extension_slug ), $ids );
+		return $updated > 0;
+	}

-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
-		$matched_ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM {$plans_table} WHERE extension_slug = %s AND id IN ({$placeholders})", $params ) );
-		$matched_ids = is_array( $matched_ids )
-			? array_unique(
-				array_map(
-					static function ( $matched_id ): int {
-						return Coercion::coerce_int( $matched_id );
-					},
-					$matched_ids
-				)
-			)
-			: array();
-		if ( count( $matched_ids ) !== count( $ids ) ) {
-			return false;
+	/**
+	 * Delete a plan's meta rows for `$key`, like `delete_post_meta()`.
+	 *
+	 * @param int    $plan_id Plan id.
+	 * @param string $key     Meta key.
+	 * @param mixed  $value   Only delete rows holding this value; null deletes every row for the key.
+	 *                        Any other value ('' and false included) matches literally.
+	 * @return bool True when at least one row was deleted.
+	 * @throws \InvalidArgumentException If `$key` is empty.
+	 * @throws \RuntimeException If the delete fails.
+	 */
+	public function delete_meta( int $plan_id, string $key, $value = null ): bool {
+		global $wpdb;
+
+		if ( '' === $key ) {
+			throw new \InvalidArgumentException( 'Plan meta key must not be empty.' );
 		}

-		foreach ( $sort_order_by_id as $id => $sort_order ) {
-			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-			$updated = $wpdb->update(
-				$plans_table,
-				array(
-					'sort_order'       => (int) $sort_order,
-					'date_updated_gmt' => $now,
-				),
-				array(
-					'id'             => (int) $id,
-					'extension_slug' => $extension_slug,
-				)
-			);
+		$where = array(
+			'plan_id'  => $plan_id,
+			'meta_key' => $key,
+		);
+		if ( null !== $value ) {
+			$where['meta_value'] = maybe_serialize( $value );
+		}
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$deleted = $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLAN_META ), $where );

-			$ok = $ok && false !== $updated;
+		if ( false === $deleted ) {
+			throw new \RuntimeException( sprintf( 'Failed to delete plan meta "%s" for plan %d: %s', esc_html( $key ), (int) $plan_id, esc_html( $wpdb->last_error ) ) );
 		}

-		return $ok;
+		return $deleted > 0;
+	}
+
+	/**
+	 * Read plan meta (WordPress `get_post_meta()` semantics), values unserialized,
+	 * oldest row first.
+	 *
+	 * @param int    $plan_id Plan id.
+	 * @param string $key     Meta key; empty for every key.
+	 * @param bool   $single  With a key: return the first value only.
+	 * @return mixed Empty key: `array<string, array<int, mixed>>` of all keys. Key + `$single`:
+	 *               the first value, or '' when absent. Key only: the list of values (`[]` when absent).
+	 */
+	public function get_meta( int $plan_id, string $key = '', bool $single = false ) {
+		if ( '' === $key ) {
+			$all = array();
+			foreach ( $this->find_meta_rows( $plan_id, null ) as $row ) {
+				$all[ $row['meta_key'] ][] = maybe_unserialize( $row['meta_value'] );
+			}
+
+			return $all;
+		}
+
+		$values = $this->find_meta_values( $plan_id, $key );
+
+		if ( $single ) {
+			return array() === $values ? '' : $values[0];
+		}
+
+		return $values;
 	}

 	/**
@@ -353,10 +483,26 @@ final class PlanRepository {
 		$clauses = array();
 		$params  = array();

-		$status = Coercion::coerce_string( $args['status'] ?? null );
-		if ( '' !== $status ) {
-			$clauses[] = 'status = %s';
-			$params[]  = $status;
+		if ( array_key_exists( 'status', $args ) && null !== $args['status'] ) {
+			$statuses = is_array( $args['status'] ) ? array_values( $args['status'] ) : array( $args['status'] );
+			$valid    = array();
+			foreach ( $statuses as $status ) {
+				if ( ! is_string( $status ) || '' === $status ) {
+					$valid = array();
+					break;
+				}
+				$valid[ $status ] = $status;
+			}
+
+			if ( array() === $valid ) {
+				$clauses[] = self::MATCH_NOTHING;
+			} elseif ( 1 === count( $valid ) ) {
+				$clauses[] = 'status = %s';
+				$params[]  = reset( $valid );
+			} else {
+				$clauses[] = 'status IN (' . implode( ',', array_fill( 0, count( $valid ), '%s' ) ) . ')';
+				$params    = array_merge( $params, array_values( $valid ) );
+			}
 		}

 		if ( array_key_exists( 'extension_slugs', $args ) && null !== $args['extension_slugs'] ) {
@@ -428,8 +574,7 @@ final class PlanRepository {
 		$search = Coercion::coerce_string( $args['search'] ?? null );
 		if ( '' !== $search ) {
 			$like      = '%' . $wpdb->esc_like( $search ) . '%';
-			$clauses[] = '(name LIKE %s OR description LIKE %s)';
-			$params[]  = $like;
+			$clauses[] = 'name LIKE %s';
 			$params[]  = $like;
 		}

@@ -453,13 +598,11 @@ final class PlanRepository {
 	 */
 	private function build_order_clause( array $args ): string {
 		$orderby_arg = Coercion::coerce_string( $args['orderby'] ?? null );
-		$orderby     = isset( self::ORDERBY_COLUMNS[ $orderby_arg ] )
-			? self::ORDERBY_COLUMNS[ $orderby_arg ]
-			: 'sort_order';
+		$orderby     = self::ORDERBY_COLUMNS[ $orderby_arg ] ?? 'id';
 		$order       = 'desc' === strtolower( Coercion::coerce_string( $args['order'] ?? null ) ) ? 'DESC' : 'ASC';

-		if ( 'sort_order' === $orderby ) {
-			return "ORDER BY sort_order {$order}, id ASC";
+		if ( 'id' === $orderby ) {
+			return "ORDER BY id {$order}";
 		}

 		return "ORDER BY {$orderby} {$order}, id ASC";
@@ -480,6 +623,22 @@ final class PlanRepository {
 		return true;
 	}

+	/**
+	 * The writable plan columns for `$plan`, policies JSON-encoded (null stays null).
+	 *
+	 * @param Plan $plan Plan.
+	 * @return array<string, mixed>
+	 */
+	private function get_row_data( Plan $plan ): array {
+		$data = $plan->to_storage();
+
+		foreach ( self::JSON_COLUMNS as $column ) {
+			$data[ $column ] = null !== $data[ $column ] ? wp_json_encode( $data[ $column ] ) : null;
+		}
+
+		return $data;
+	}
+
 	/**
 	 * Hydrate a database row into a plan.
 	 *
@@ -496,9 +655,8 @@ final class PlanRepository {
 	/**
 	 * Decode a JSON column into an array.
 	 *
-	 * A SQL NULL column stays null so nullable policy columns
-	 * (delivery_policy, pricing_policy) round-trip back to null rather than to
-	 * an empty value object. A present-but-empty value decodes to an array.
+	 * A SQL NULL column stays null so the nullable policy columns round-trip
+	 * back to null. A present-but-empty value decodes to an empty array.
 	 *
 	 * @param mixed $value Raw column value.
 	 * @return array<mixed>|null
@@ -518,19 +676,53 @@ final class PlanRepository {
 	}

 	/**
-	 * Normalize a database row to string keys.
+	 * Unserialized values stored under `$key` for a plan, oldest first.
 	 *
-	 * @param array<array-key, mixed> $row Raw row.
-	 * @return array<string, mixed>
+	 * @param int    $plan_id Plan id.
+	 * @param string $key     Meta key.
+	 * @return array<int, mixed>
+	 */
+	private function find_meta_values( int $plan_id, string $key ): array {
+		$values = array();
+		foreach ( $this->find_meta_rows( $plan_id, $key ) as $row ) {
+			$values[] = maybe_unserialize( $row['meta_value'] );
+		}
+
+		return $values;
+	}
+
+	/**
+	 * Raw meta rows for a plan, optionally for one key, by id ascending.
+	 *
+	 * @param int         $plan_id Plan id.
+	 * @param string|null $key     Meta key, or null for every key.
+	 * @return array<int, array{meta_key: string, meta_value: string}>
 	 */
-	private static function string_keyed_array( array $row ): array {
-		$data = array();
-		foreach ( $row as $key => $value ) {
-			if ( is_string( $key ) ) {
-				$data[ $key ] = $value;
+	private function find_meta_rows( int $plan_id, ?string $key ): array {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLAN_META );
+
+		if ( null === $key ) {
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+			$rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, meta_value FROM {$table} WHERE plan_id = %d ORDER BY id ASC", $plan_id ), ARRAY_A );
+		} else {
+			// The engine's own plan-meta columns, not post/order meta; the
+			// slow-meta-query heuristic does not apply.
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+			$rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, meta_value FROM {$table} WHERE plan_id = %d AND meta_key = %s ORDER BY id ASC", $plan_id, $key ), ARRAY_A );
+		}
+
+		$result = array();
+		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
+			if ( is_array( $row ) ) {
+				$result[] = array(
+					'meta_key'   => Coercion::coerce_string( $row['meta_key'] ?? null ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+					'meta_value' => Coercion::coerce_string( $row['meta_value'] ?? null ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+				);
 			}
 		}

-		return $data;
+		return $result;
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
index c2bf1c54a99..ad3450d39c1 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
@@ -46,13 +46,17 @@ final class SchemaInstaller {
 	 * 2.5.0 - contracts `customer_id`, `currency`, `selling_plan_id`, `start_gmt` nullable;
 	 *         contract_meta indexes `meta_key_value` and `contract_meta_key_value` (HPOS
 	 *         shape) replace `contract_key`; pre-freeze, recreate the tables.
+	 * 2.6.0 - plans drop `description`, `category`, `sort_order`, `merchant_code`,
+	 *         `inventory_policy` and their indexes; `billing_policy` nullable; an
+	 *         `extension_status (extension_slug, status)` index; new selling_plan_meta
+	 *         table (HPOS-style indexes); pre-freeze, recreate the tables.
 	 *
 	 * Pre-freeze, tables are recreated rather than migrated. dbDelta adds columns but
 	 * does not change an existing column's nullability or drop unused ones, so a dev box
 	 * on an earlier schema must drop and recreate the tables (and clear VERSION_OPTION)
 	 * to pick up such changes - in-place ALTERs and backfills arrive with the freeze.
 	 */
-	private const VERSION = '2.5.0';
+	private const VERSION = '2.6.0';

 	/**
 	 * Option key tracking the installed schema version.
@@ -63,6 +67,7 @@ final class SchemaInstaller {
 	 * Logical table identifiers - keys map to unprefixed table names.
 	 */
 	public const TABLE_PLANS              = 'plans';
+	public const TABLE_PLAN_META          = 'plan_meta';
 	public const TABLE_CONTRACTS          = 'contracts';
 	public const TABLE_CONTRACT_ITEMS     = 'contract_items';
 	public const TABLE_CONTRACT_ADDRESSES = 'contract_addresses';
@@ -171,6 +176,7 @@ final class SchemaInstaller {
 	private static function get_table_names( string $prefix ): array {
 		return array(
 			self::TABLE_PLANS              => $prefix . 'wc_selling_plans',
+			self::TABLE_PLAN_META          => $prefix . 'wc_selling_plan_meta',
 			self::TABLE_CONTRACTS          => $prefix . 'wc_subscription_contracts',
 			self::TABLE_CONTRACT_ITEMS     => $prefix . 'wc_subscription_contract_items',
 			self::TABLE_CONTRACT_ADDRESSES => $prefix . 'wc_subscription_contract_addresses',
@@ -193,6 +199,7 @@ final class SchemaInstaller {
 	 */
 	private static function get_table_definitions( array $names, string $collate ): array {
 		$plans              = $names[ self::TABLE_PLANS ];
+		$plan_meta          = $names[ self::TABLE_PLAN_META ];
 		$contracts          = $names[ self::TABLE_CONTRACTS ];
 		$contract_items     = $names[ self::TABLE_CONTRACT_ITEMS ];
 		$contract_addresses = $names[ self::TABLE_CONTRACT_ADDRESSES ];
@@ -200,32 +207,35 @@ final class SchemaInstaller {
 		$cycles             = $names[ self::TABLE_CYCLES ];
 		$snapshots          = $names[ self::TABLE_SNAPSHOTS ];

-		// `merchant_code` is DB-enforced-unique per extension (composite with
-		// `extension_slug`) for idempotency on consumer-supplied codes - each consumer
-		// owns its own code namespace; NULLs are treated as distinct, so consumers that
-		// do not use merchant codes are unaffected. `extension_slug` records the creating
-		// extension's registered slug. Nullable while owner identifier/registration
-		// semantics are still open; tightened additively once decided.
+		// Mirrors the HPOS orders meta table indexes, including its meta_value prefix length.
+		$meta_value_index_length = max( min( absint( apply_filters( 'woocommerce_database_max_index_length', 191 ) ), 767 ) - 8 - 100 - 1, 20 );
+
+		// The three policies are opaque JSON payloads of the owning extension; the engine
+		// checks their shape only. `extension_slug` is the owner (nullable while owner
+		// registration semantics are still open). `extension_status` keys owner-scoped
+		// reads filtered by status.
 		$plans_sql = "CREATE TABLE {$plans} (
   id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
   name VARCHAR(255) NOT NULL,
-  description TEXT NULL,
-  billing_policy JSON NOT NULL,
+  billing_policy JSON NULL,
   delivery_policy JSON NULL,
-  inventory_policy JSON NULL,
   pricing_policy JSON NULL,
-  category VARCHAR(32) NOT NULL DEFAULT 'SUBSCRIPTION',
   status VARCHAR(20) NOT NULL DEFAULT 'active',
-  sort_order INT NOT NULL DEFAULT 0,
-  merchant_code VARCHAR(64) NULL,
   extension_slug VARCHAR(64) NULL,
   date_created_gmt DATETIME NOT NULL,
   date_updated_gmt DATETIME NOT NULL,
   PRIMARY KEY  (id),
-  UNIQUE KEY extension_merchant_code (extension_slug, merchant_code),
-  KEY category (category),
-  KEY status_sort (status, sort_order, id),
-  KEY extension_slug (extension_slug)
+  KEY extension_status (extension_slug, status)
+) {$collate};";
+
+		$plan_meta_sql = "CREATE TABLE {$plan_meta} (
+  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+  plan_id BIGINT UNSIGNED NOT NULL,
+  meta_key VARCHAR(255) NOT NULL,
+  meta_value LONGTEXT NULL,
+  PRIMARY KEY  (id),
+  KEY meta_key_value (meta_key(50), meta_value(20)),
+  KEY plan_meta_key_value (plan_id, meta_key(100), meta_value({$meta_value_index_length}))
 ) {$collate};";

 		// The contract row is the live source of truth: the totals and stamps are live
@@ -305,9 +315,6 @@ final class SchemaInstaller {
   PRIMARY KEY  (contract_id, address_type)
 ) {$collate};";

-		// Mirrors the HPOS orders meta table indexes, including its meta_value prefix length.
-		$meta_value_index_length = max( min( absint( apply_filters( 'woocommerce_database_max_index_length', 191 ) ), 767 ) - 8 - 100 - 1, 20 );
-
 		$contract_meta_sql = "CREATE TABLE {$contract_meta} (
   id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
   contract_id BIGINT UNSIGNED NOT NULL,
@@ -378,6 +385,7 @@ final class SchemaInstaller {

 		return array(
 			$plans_sql,
+			$plan_meta_sql,
 			$contracts_sql,
 			$contract_items_sql,
 			$contract_addresses_sql,
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php
index ad8aa3a6cae..22b17374ad5 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php
@@ -1,6 +1,6 @@
 <?php
 /**
- * Argument validators shared by the public write facades.
+ * Argument validators shared by the public facades.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Support
  */
@@ -22,7 +22,7 @@ defined( 'ABSPATH' ) || exit;
 /**
  * Validate caller argument values and return them normalized.
  *
- * @internal Engine implementation detail shared by the `Api\` write facades, not part of the public API.
+ * @internal Engine implementation detail shared by the `Api\` facades, not part of the public API.
  */
 final class ArgumentValidator {

@@ -60,7 +60,7 @@ final class ArgumentValidator {
 	 * @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}$/', $value ) ) ) {
+		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.' );
 		}

@@ -82,6 +82,21 @@ final class ArgumentValidator {
 		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.
 	 *
@@ -109,10 +124,7 @@ final class ArgumentValidator {
 			return null;
 		}

-		if ( is_string( $value ) && 1 === preg_match( '/^[0-9]+$/', $value ) ) {
-			$value = (int) $value;
-		}
-
+		$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 ) ) );
 		}
@@ -120,6 +132,88 @@ final class ArgumentValidator {
 		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.
 	 *
@@ -174,7 +268,7 @@ final class ArgumentValidator {
 	 * @throws InvalidArgumentException If the value is not a list of arrays.
 	 */
 	public static function validate_list_of_arrays( string $key, $value ): array {
-		if ( ! is_array( $value ) || ( array() !== $value && array_keys( $value ) !== range( 0, count( $value ) - 1 ) ) ) {
+		if ( ! self::is_list( $value ) ) {
 			throw new InvalidArgumentException( sprintf( '"%s" must be a list of arrays.', esc_html( $key ) ) );
 		}

@@ -233,4 +327,28 @@ final class ArgumentValidator {

 		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 e7ff1730f5c..9f794ac9712 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
@@ -22,11 +22,8 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 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\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;

 /**
@@ -1159,15 +1156,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	 * @return Contract The persisted contract with cycle 1 billed.
 	 */
 	private function sign_up_contract( int $customer_id = 0 ): Contract {
-		$plan = Plan::create(
-			array(
-				'name'           => 'Monthly',
-				'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
-				'category'       => Plan::DEFAULT_CATEGORY,
-				'extension_slug' => 'engine-tests',
-			)
-		);
-		( new PlanRepository() )->insert( $plan );
+		$plan = $this->plan_view( $this->make_plan() );

 		$order = new WC_Order();
 		$order->set_currency( 'USD' );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/PlansTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/PlansTest.php
new file mode 100644
index 00000000000..c68a368c62d
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/PlansTest.php
@@ -0,0 +1,1112 @@
+<?php
+/**
+ * Integration tests for the Plans facade.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+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\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\Integration\Storage\PlanRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\Plans
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\PlanValidationException
+ */
+class PlansTest extends EngineIntegrationTestCase {
+
+	private const OWNER = 'acme-subs';
+
+	private const HOOK = 'woocommerce_subscriptions_engine_validate_plan';
+
+	public function tear_down(): void {
+		remove_all_actions( self::HOOK );
+		StatusRegistry::reset();
+		parent::tear_down();
+	}
+
+	/**
+	 * Create a plan with defaults.
+	 *
+	 * @param array<string, mixed> $overrides Arg overrides.
+	 */
+	private function create( array $overrides = array() ): int {
+		$plan = Plans::create(
+			array_merge(
+				array(
+					'extension_slug' => self::OWNER,
+					'name'           => 'Monthly',
+					'billing_policy' => array(
+						'period'   => 'month',
+						'interval' => 1,
+					),
+				),
+				$overrides
+			)
+		);
+
+		return $plan->get_id();
+	}
+
+	/**
+	 * Load a stored plan.
+	 *
+	 * @param int $id Plan id.
+	 */
+	private function stored( int $id ): Plan {
+		$plan = ( new PlanRepository() )->find( $id );
+		$this->assertInstanceOf( Plan::class, $plan );
+
+		return $plan;
+	}
+
+	/**
+	 * Number of stored plans.
+	 */
+	private function plan_count(): int {
+		return ( new PlanRepository() )->count();
+	}
+
+	/**
+	 * @testdox create stores the fields and returns the view.
+	 */
+	public function test_create_stores_the_fields_and_returns_the_view(): void {
+		$created = Plans::create(
+			array(
+				'extension_slug'  => self::OWNER,
+				'name'            => '  Box  ',
+				'billing_policy'  => array(
+					'period'   => 'month',
+					'interval' => 1,
+				),
+				'pricing_policy'  => array( 'policies' => array() ),
+				'delivery_policy' => array( 'anchor' => 1 ),
+			)
+		);
+		$id      = $created->get_id();
+
+		$this->assertEquals( Plans::get( $id ), $created, 'The returned view matches a fresh read.' );
+		$this->assertNotNull( $created->get_date_created_gmt() );
+
+		$plan = $this->stored( $id );
+		$this->assertSame( 'Box', $plan->get_name() );
+		$this->assertSame( self::OWNER, $plan->get_extension_slug() );
+		$this->assertSame( PlanStatus::ACTIVE, $plan->get_status() );
+		$this->assertSame(
+			array(
+				'period'   => 'month',
+				'interval' => 1,
+			),
+			$plan->get_billing_policy()
+		);
+		$this->assertSame( array( 'policies' => array() ), $plan->get_pricing_policy() );
+		$this->assertSame( array( 'anchor' => 1 ), $plan->get_delivery_policy() );
+	}
+
+	/**
+	 * @testdox create without policies stores nulls.
+	 */
+	public function test_create_without_policies_stores_nulls(): void {
+		$created = Plans::create(
+			array(
+				'extension_slug' => self::OWNER,
+				'name'           => 'Bare',
+			)
+		);
+		$plan    = $this->stored( $created->get_id() );
+
+		$this->assertNull( $plan->get_billing_policy() );
+		$this->assertNull( $plan->get_pricing_policy() );
+		$this->assertNull( $plan->get_delivery_policy() );
+	}
+
+	/**
+	 * @testdox create accepts a registered extension status.
+	 */
+	public function test_create_accepts_a_registered_extension_status(): void {
+		StatusRegistry::register( StatusRegistry::KIND_PLAN, 'seasonal' );
+
+		$this->assertSame( 'seasonal', $this->stored( $this->create( array( 'status' => 'seasonal' ) ) )->get_status() );
+	}
+
+	/**
+	 * @return array<string, array{0: array<string, mixed>}>
+	 */
+	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' => '' ) ),
+		);
+	}
+
+	/**
+	 * @testdox create rejects invalid args and stores nothing.
+	 * @dataProvider provide_invalid_create_args
+	 *
+	 * @param array<string, mixed> $overrides Invalid overrides.
+	 */
+	public function test_create_rejects_invalid_args_and_stores_nothing( array $overrides ): void {
+		$before = $this->plan_count();
+
+		try {
+			$this->create( $overrides );
+			$this->fail( 'Expected InvalidArgumentException.' );
+		} catch ( InvalidArgumentException $e ) {
+			$this->assertNotInstanceOf( PlanValidationException::class, $e );
+		}
+
+		$this->assertSame( $before, $this->plan_count() );
+	}
+
+	/**
+	 * @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() );
+		}
+	}
+
+	/**
+	 * @testdox create requires a name.
+	 */
+	public function test_create_requires_a_name(): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		Plans::create( array( 'extension_slug' => self::OWNER ) );
+	}
+
+	/**
+	 * @testdox an unknown create key is ignored with a notice.
+	 */
+	public function test_an_unknown_create_key_is_ignored_with_a_notice(): void {
+		$this->setExpectedIncorrectUsage( Plans::class . '::create' );
+		$messages = array();
+		add_action(
+			'doing_it_wrong_run',
+			static function ( $function_name, $message ) use ( &$messages ): void {
+				$messages[] = $message;
+			},
+			10,
+			2
+		);
+
+		$id = $this->create( array( 'sort_order' => 1 ) );
+
+		$this->assertSame( array( 'Unknown key "sort_order" ignored.' ), $messages );
+		$this->assertSame( 'Monthly', $this->stored( $id )->get_name(), 'The known keys beside the unknown one are written.' );
+	}
+
+	/**
+	 * @testdox update replaces a policy wholesale and null clears.
+	 */
+	public function test_update_replaces_a_policy_wholesale_and_null_clears(): void {
+		$id = $this->create(
+			array(
+				'pricing_policy' => array(
+					'a' => 1,
+					'b' => 2,
+				),
+			)
+		);
+
+		$this->assertInstanceOf(
+			PlanView::class,
+			Plans::update(
+				$id,
+				array(
+					'extension_slug' => self::OWNER,
+					'pricing_policy' => array( 'c' => 3 ),
+				)
+			)
+		);
+		$plan = $this->stored( $id );
+		$this->assertSame( array( 'c' => 3 ), $plan->get_pricing_policy() );
+		$this->assertSame(
+			array(
+				'period'   => 'month',
+				'interval' => 1,
+			),
+			$plan->get_billing_policy(),
+			'An omitted policy keeps its stored payload.'
+		);
+
+		$this->assertInstanceOf(
+			PlanView::class,
+			Plans::update(
+				$id,
+				array(
+					'extension_slug' => self::OWNER,
+					'pricing_policy' => null,
+				)
+			)
+		);
+		$this->assertNull( $this->stored( $id )->get_pricing_policy() );
+	}
+
+	/**
+	 * @testdox a status-only update writes the status and keeps the name.
+	 */
+	public function test_status_only_update(): void {
+		$id = $this->create();
+
+		$this->assertInstanceOf(
+			PlanView::class,
+			Plans::update(
+				$id,
+				array(
+					'extension_slug' => self::OWNER,
+					'status'         => PlanStatus::ARCHIVED,
+				)
+			)
+		);
+
+		$plan = $this->stored( $id );
+		$this->assertSame( PlanStatus::ARCHIVED, $plan->get_status() );
+		$this->assertSame( 'Monthly', $plan->get_name() );
+	}
+
+	/**
+	 * @testdox update of a missing plan returns null.
+	 */
+	public function test_update_of_a_missing_plan_returns_null(): void {
+		$this->assertNull(
+			Plans::update(
+				999999,
+				array(
+					'extension_slug' => self::OWNER,
+					'name'           => 'Nope',
+				)
+			)
+		);
+		$this->assertNull(
+			Plans::update(
+				0,
+				array(
+					'extension_slug' => self::OWNER,
+					'name'           => 'Nope',
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox update returns the plan as read plus the written fields, matching a fresh read (stored update time included).
+	 */
+	public function test_update_returns_the_view_with_the_written_fields(): void {
+		$id = $this->create();
+
+		$updated = Plans::update(
+			$id,
+			array(
+				'extension_slug' => self::OWNER,
+				'name'           => 'Renamed',
+			)
+		);
+
+		$this->assertInstanceOf( PlanView::class, $updated );
+		$this->assertSame( 'Renamed', $updated->get_name() );
+		$this->assertEquals( Plans::get( $id ), $updated, 'The returned view matches a fresh read.' );
+	}
+
+	/**
+	 * @testdox update returns null when the plan is deleted before the write.
+	 */
+	public function test_update_returns_null_when_the_plan_is_deleted_before_the_write(): void {
+		global $wpdb;
+
+		$id = $this->create();
+
+		// A concurrent delete lands after the facade read the plan and before its write.
+		$table    = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
+		$injected = false;
+		$race     = static function ( string $query ) use ( &$injected, $table, $id, $wpdb ): string {
+			if ( ! $injected && 0 === stripos( ltrim( $query ), 'UPDATE' ) && false !== strpos( $query, $table ) ) {
+				$injected = true;
+				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+				$wpdb->delete( $table, array( 'id' => $id ) );
+			}
+
+			return $query;
+		};
+		add_filter( 'query', $race );
+
+		try {
+			$updated = Plans::update(
+				$id,
+				array(
+					'extension_slug' => self::OWNER,
+					'name'           => 'Renamed',
+				)
+			);
+		} finally {
+			remove_filter( 'query', $race );
+		}
+
+		$this->assertTrue( $injected );
+		$this->assertNull( $updated );
+	}
+
+	/**
+	 * @testdox an unknown update key is ignored with a notice.
+	 */
+	public function test_an_unknown_update_key_is_ignored_with_a_notice(): void {
+		$id = $this->create();
+		$this->setExpectedIncorrectUsage( Plans::class . '::update' );
+
+		$updated = Plans::update(
+			$id,
+			array(
+				'extension_slug' => self::OWNER,
+				'sort_order'     => 1,
+				'name'           => 'Changed',
+			)
+		);
+		$this->assertInstanceOf( PlanView::class, $updated );
+
+		$this->assertSame( 'Changed', $this->stored( $id )->get_name(), 'The known key beside the unknown one is written.' );
+	}
+
+	/**
+	 * @testdox the extension slug passed to update is not written.
+	 */
+	public function test_the_extension_slug_passed_to_update_is_not_written(): void {
+		$id = $this->create();
+
+		$queries = array();
+		$capture = static function ( string $query ) use ( &$queries ): string {
+			$queries[] = $query;
+
+			return $query;
+		};
+		add_filter( 'query', $capture );
+
+		try {
+			$updated = Plans::update(
+				$id,
+				array(
+					'extension_slug' => self::OWNER,
+					'name'           => 'Changed',
+				)
+			);
+		} finally {
+			remove_filter( 'query', $capture );
+		}
+
+		$this->assertInstanceOf( PlanView::class, $updated );
+		$this->assertSame( self::OWNER, $updated->get_extension_slug() );
+
+		// Scoping (the WHERE side) is pinned by the cross-extension update tests; the SQL
+		// check covers only what stored state cannot show: the slug is never in a SET list.
+		$table   = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
+		$updates = array_values(
+			array_filter(
+				$queries,
+				static function ( string $query ) use ( $table ): bool {
+					return 0 === stripos( ltrim( $query ), 'UPDATE' ) && false !== strpos( $query, $table );
+				}
+			)
+		);
+		$this->assertNotEmpty( $updates );
+		foreach ( $updates as $update ) {
+			$where    = stripos( $update, ' WHERE ' );
+			$set_list = false === $where ? $update : substr( $update, 0, $where );
+			$this->assertDoesNotMatchRegularExpression( '/\bextension_slug\b/i', $set_list, 'The slug is not in the SET list.' );
+		}
+
+		$plan = $this->stored( $id );
+		$this->assertSame( self::OWNER, $plan->get_extension_slug() );
+		$this->assertSame( 'Changed', $plan->get_name() );
+	}
+
+	/**
+	 * @return array<string, array{0: array<string, mixed>}>
+	 */
+	public function provide_invalid_update_scopes(): array {
+		return array(
+			'missing slug'    => array( array( 'name' => 'Changed' ) ),
+			'null slug'       => array(
+				array(
+					'extension_slug' => null,
+					'name'           => 'Changed',
+				),
+			),
+			'empty slug'      => array(
+				array(
+					'extension_slug' => '',
+					'name'           => 'Changed',
+				),
+			),
+			'non-string slug' => array(
+				array(
+					'extension_slug' => 5,
+					'name'           => 'Changed',
+				),
+			),
+		);
+	}
+
+	/**
+	 * @testdox update without a valid extension slug throws and leaves the plan unchanged.
+	 * @dataProvider provide_invalid_update_scopes
+	 *
+	 * @param array<string, mixed> $args Update args without a valid extension slug.
+	 */
+	public function test_update_without_a_valid_extension_slug_throws( array $args ): 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->assertSame( $before, $this->stored( $id )->to_storage() );
+	}
+
+	/**
+	 * @testdox update under another extension slug returns null, leaves the plan unchanged and never asks that extension to validate.
+	 */
+	public function test_update_under_another_extension_slug_returns_null_and_writes_nothing(): void {
+		$id     = $this->create();
+		$before = $this->stored( $id )->to_storage();
+
+		$validated = 0;
+		add_action(
+			self::HOOK,
+			static function () use ( &$validated ): void {
+				++$validated;
+			}
+		);
+
+		$this->assertNull(
+			Plans::update(
+				$id,
+				array(
+					'extension_slug' => 'other-extension',
+					'name'           => 'Hijacked',
+				)
+			)
+		);
+		$this->assertNull( Plans::update( $id, array( 'extension_slug' => 'other-extension' ) ), 'A fieldless update is scoped too.' );
+
+		$this->assertSame( 0, $validated, 'The validate action never sees a plan of another extension.' );
+		$this->assertSame( $before, $this->stored( $id )->to_storage() );
+	}
+
+	/**
+	 * @testdox an update writing identical values within the same second still returns the view.
+	 */
+	public function test_an_identical_update_within_the_same_second_returns_the_view(): void {
+		$id   = $this->create();
+		$args = array(
+			'extension_slug' => self::OWNER,
+			'name'           => 'Same',
+		);
+
+		// The first write may change the row; the second, within the same second, changes none.
+		$views = array( Plans::update( $id, $args ), Plans::update( $id, $args ) );
+
+		foreach ( $views as $view ) {
+			$this->assertInstanceOf( PlanView::class, $view );
+			$this->assertSame( 'Same', $view->get_name() );
+		}
+	}
+
+	/**
+	 * @return array<string, array{0: array<string, mixed>}>
+	 */
+	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' ) ),
+			'valid then invalid'  => array(
+				array(
+					'name'   => 'Changed',
+					'status' => 'seasonal',
+				),
+			),
+		);
+	}
+
+	/**
+	 * @testdox update rejects invalid args and leaves the plan unchanged.
+	 * @dataProvider provide_invalid_update_args
+	 *
+	 * @param array<string, mixed> $args Invalid update args.
+	 */
+	public function test_update_rejects_invalid_args_and_leaves_the_plan_unchanged( array $args ): 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->assertSame( $before, $this->stored( $id )->to_storage() );
+	}
+
+	/**
+	 * @testdox update accepts the stored status after it is unregistered.
+	 */
+	public function test_update_accepts_the_stored_status_after_it_is_unregistered(): void {
+		StatusRegistry::register( StatusRegistry::KIND_PLAN, 'seasonal' );
+		$id = $this->create( array( 'status' => 'seasonal' ) );
+		StatusRegistry::reset();
+		$this->assertFalse( PlanStatus::is_registered( 'seasonal' ) );
+
+		$updated = Plans::update(
+			$id,
+			array(
+				'extension_slug' => self::OWNER,
+				'status'         => 'seasonal',
+				'name'           => 'Renamed',
+			)
+		);
+		$this->assertInstanceOf( PlanView::class, $updated );
+
+		$plan = $this->stored( $id );
+		$this->assertSame( 'seasonal', $plan->get_status() );
+		$this->assertSame( 'Renamed', $plan->get_name() );
+	}
+
+	/**
+	 * @testdox update writes only the present fields.
+	 */
+	public function test_update_writes_only_the_present_fields(): void {
+		global $wpdb;
+
+		$id = $this->create();
+
+		// A concurrent writer renames the plan while the extension validates a status change.
+		add_action(
+			self::HOOK,
+			static function () use ( $wpdb, $id ): void {
+				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+				$wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS ), array( 'name' => 'Renamed elsewhere' ), array( 'id' => $id ) );
+			}
+		);
+
+		$this->assertInstanceOf(
+			PlanView::class,
+			Plans::update(
+				$id,
+				array(
+					'extension_slug' => self::OWNER,
+					'status'         => PlanStatus::ARCHIVED,
+				)
+			)
+		);
+
+		$plan = $this->stored( $id );
+		$this->assertSame( PlanStatus::ARCHIVED, $plan->get_status() );
+		$this->assertSame( 'Renamed elsewhere', $plan->get_name(), 'The status-only update must not write the name it read.' );
+	}
+
+	/**
+	 * @testdox update with no fields returns the plan without validating or writing.
+	 */
+	public function test_update_with_no_fields_returns_the_plan_without_validating_or_writing(): void {
+		global $wpdb;
+
+		$id    = $this->create();
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$wpdb->query( $wpdb->prepare( "UPDATE {$table} SET date_updated_gmt = %s WHERE id = %d", '2020-01-01 00:00:00', $id ) );
+
+		$validated = 0;
+		add_action(
+			self::HOOK,
+			static function () use ( &$validated ): void {
+				++$validated;
+			}
+		);
+
+		$this->assertInstanceOf( PlanView::class, Plans::update( $id, array( 'extension_slug' => self::OWNER ) ) );
+		$this->assertSame( 0, $validated, 'An empty update has nothing to validate.' );
+		$this->assertSame( '2020-01-01 00:00:00', $this->stored( $id )->get_date_updated_gmt(), 'An empty update writes nothing.' );
+
+		$this->assertInstanceOf(
+			PlanView::class,
+			Plans::update(
+				$id,
+				array(
+					'extension_slug' => self::OWNER,
+					'name'           => 'Renamed',
+				)
+			)
+		);
+		$this->assertNotSame( '2020-01-01 00:00:00', $this->stored( $id )->get_date_updated_gmt(), 'A field update bumps the update time.' );
+	}
+
+	/**
+	 * @testdox the validate action receives the would-be state on create.
+	 */
+	public function test_validate_action_receives_the_would_be_state_on_create(): void {
+		$seen = array();
+		add_action(
+			self::HOOK,
+			static function ( $errors, $plan, $extension_slug ) use ( &$seen ): void {
+				$seen[] = array( $errors, $plan, $extension_slug );
+			},
+			10,
+			3
+		);
+
+		$this->create( array( 'name' => 'Seen' ) );
+
+		$this->assertCount( 1, $seen );
+		$this->assertInstanceOf( WP_Error::class, $seen[0][0] );
+		$this->assertInstanceOf( PlanView::class, $seen[0][1] );
+		$this->assertSame( 0, $seen[0][1]->get_id() );
+		$this->assertSame( 'Seen', $seen[0][1]->get_name() );
+		$this->assertSame( self::OWNER, $seen[0][1]->get_extension_slug() );
+		$this->assertSame( self::OWNER, $seen[0][2] );
+	}
+
+	/**
+	 * @testdox the validate action receives the would-be state on update.
+	 */
+	public function test_validate_action_receives_the_would_be_state_on_update(): void {
+		$id   = $this->create();
+		$seen = null;
+		add_action(
+			self::HOOK,
+			static function ( $errors, $plan ) use ( &$seen ): void {
+				unset( $errors );
+				$seen = $plan;
+			},
+			10,
+			2
+		);
+
+		Plans::update(
+			$id,
+			array(
+				'extension_slug' => self::OWNER,
+				'name'           => 'Renamed',
+				'pricing_policy' => array( 'x' => 1 ),
+			)
+		);
+
+		$this->assertInstanceOf( PlanView::class, $seen );
+		$this->assertSame( $id, $seen->get_id() );
+		$this->assertSame( 'Renamed', $seen->get_name() );
+		$this->assertSame( array( 'x' => 1 ), $seen->get_pricing_policy() );
+	}
+
+	/**
+	 * @testdox an added error refuses the create with its codes.
+	 */
+	public function test_an_added_error_refuses_the_create_with_its_codes(): void {
+		$before = $this->plan_count();
+		add_action(
+			self::HOOK,
+			static function ( $errors ): void {
+				$errors->add( 'acme_bad_pricing', 'Pricing is wrong.', array( 'status' => 400 ) );
+			}
+		);
+		add_action(
+			self::HOOK,
+			static function ( $errors ): void {
+				$errors->add( 'acme_bad_billing', 'Billing is wrong.' );
+			}
+		);
+
+		try {
+			$this->create();
+			$this->fail( 'Expected PlanValidationException.' );
+		} catch ( PlanValidationException $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->assertSame( $before, $this->plan_count() );
+	}
+
+	/**
+	 * @testdox an added error refuses the update.
+	 */
+	public function test_an_added_error_refuses_the_update(): void {
+		$id = $this->create();
+		add_action(
+			self::HOOK,
+			static function ( $errors ): void {
+				$errors->add( 'acme_no', 'No.' );
+			}
+		);
+
+		try {
+			Plans::update(
+				$id,
+				array(
+					'extension_slug' => self::OWNER,
+					'name'           => 'Refused',
+				)
+			);
+			$this->fail( 'Expected PlanValidationException.' );
+		} catch ( PlanValidationException $e ) {
+			$this->assertSame( array( 'acme_no' ), $e->get_errors()->get_error_codes() );
+		}
+
+		$this->assertSame( 'Monthly', $this->stored( $id )->get_name() );
+	}
+
+	/**
+	 * @testdox a throwing callback throws a runtime exception and stores nothing.
+	 */
+	public function test_a_throwing_callback_throws_a_runtime_exception_and_stores_nothing(): void {
+		$before = $this->plan_count();
+		add_action(
+			self::HOOK,
+			static function (): void {
+				throw new \LogicException( 'boom' );
+			}
+		);
+
+		try {
+			$this->create();
+			$this->fail( 'Expected RuntimeException.' );
+		} catch ( RuntimeException $e ) {
+			$this->assertInstanceOf( \LogicException::class, $e->getPrevious() );
+		}
+
+		$this->assertSame( $before, $this->plan_count() );
+	}
+
+	/**
+	 * @testdox a failed insert throws a runtime exception without a previous exception.
+	 */
+	public function test_a_failed_insert_throws_a_runtime_exception_without_a_previous_exception(): void {
+		global $wpdb;
+
+		$table        = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
+		$break_insert = static function ( string $query ) use ( $table ): string {
+			if ( 0 === stripos( ltrim( $query ), 'INSERT' ) && false !== strpos( $query, $table ) ) {
+				return 'INSERT INTO nonexistent_table_for_this_test (id) VALUES (1)';
+			}
+
+			return $query;
+		};
+		add_filter( 'query', $break_insert );
+		$suppressed = $wpdb->suppress_errors( true );
+
+		try {
+			$this->create();
+			$this->fail( 'Expected RuntimeException.' );
+		} catch ( RuntimeException $e ) {
+			// The REST controller reads a previous exception as "a validation callback threw".
+			$this->assertNull( $e->getPrevious() );
+		} finally {
+			$wpdb->suppress_errors( $suppressed );
+			remove_filter( 'query', $break_insert );
+		}
+	}
+
+	/**
+	 * @testdox the meta methods round-trip values.
+	 */
+	public function test_meta_methods_round_trip(): void {
+		$id = $this->create();
+
+		$this->assertIsInt( Plans::add_meta( $id, 'note', 'one' ) );
+		$this->assertNull( Plans::add_meta( $id, 'note', 'two', true ) );
+		$this->assertTrue( Plans::update_meta( $id, 'flag', array( 'a' => 1 ) ) );
+		$this->assertSame( array( 'one' ), Plans::get_meta( $id, 'note' ) );
+		$this->assertSame( array( 'a' => 1 ), Plans::get_meta( $id, 'flag', true ) );
+		$this->assertSame(
+			array(
+				'note' => array( 'one' ),
+				'flag' => array( array( 'a' => 1 ) ),
+			),
+			Plans::get_meta( $id )
+		);
+		$this->assertTrue( Plans::delete_meta( $id, 'note' ) );
+		$this->assertSame( '', Plans::get_meta( $id, 'note', true ) );
+	}
+
+	/**
+	 * @testdox meta reads for a missing plan are empty.
+	 */
+	public function test_meta_reads_for_a_missing_plan_are_empty(): void {
+		$this->assertFalse( Plans::delete_meta( 999999, 'note' ) );
+		$this->assertSame( '', Plans::get_meta( 999999, 'note', true ) );
+		$this->assertSame( array(), Plans::get_meta( 999999 ) );
+	}
+
+	/**
+	 * @testdox meta writes do not look up the plan.
+	 */
+	public function test_meta_writes_do_not_look_up_the_plan(): void {
+		$id = $this->create();
+		Plans::add_meta( $id, 'note', 'one' );
+
+		$queries = array();
+		$capture = static function ( $query ) use ( &$queries ) {
+			$queries[] = $query;
+			return $query;
+		};
+		add_filter( 'query', $capture );
+		try {
+			Plans::add_meta( $id, 'note', 'two' );
+			Plans::update_meta( $id, 'flag', 'on' );
+		} finally {
+			remove_filter( 'query', $capture );
+		}
+
+		$plans_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
+		foreach ( $queries as $query ) {
+			$this->assertDoesNotMatchRegularExpression( '/\\b' . preg_quote( $plans_table, '/' ) . '\\b/', $query, 'A meta write reads only the meta table.' );
+		}
+		$this->assertSame( array( 'one', 'two' ), Plans::get_meta( $id, 'note' ) );
+		$this->assertSame( 'on', Plans::get_meta( $id, 'flag', true ) );
+	}
+
+	/**
+	 * @testdox deleting a plan removes its meta.
+	 */
+	public function test_deleting_a_plan_removes_its_meta(): void {
+		$id = $this->create();
+		Plans::add_meta( $id, 'note', 'one' );
+
+		$this->assertTrue( ( new PlanRepository() )->delete( $id ) );
+
+		$this->assertSame( array(), Plans::get_meta( $id, 'note' ) );
+	}
+
+	/**
+	 * @testdox an empty meta key is rejected.
+	 */
+	public function test_an_empty_meta_key_is_rejected(): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		Plans::add_meta( $this->create(), '', 'x' );
+	}
+
+	/**
+	 * Map views to their ids.
+	 *
+	 * @param array<int, PlanView> $plans Views to map.
+	 * @return array<int, int>
+	 */
+	private static function plan_ids( array $plans ): array {
+		return array_map(
+			static function ( PlanView $plan ): int {
+				return $plan->get_id();
+			},
+			$plans
+		);
+	}
+
+	/**
+	 * @testdox get returns a plan in any status, and null for an unknown id.
+	 */
+	public function test_get_returns_a_plan_in_any_status(): void {
+		$archived_id = $this->create(
+			array(
+				'status'         => PlanStatus::ARCHIVED,
+				'pricing_policy' => array( 'opaque' => true ),
+			)
+		);
+
+		$plan = Plans::get( $archived_id );
+
+		$this->assertInstanceOf( PlanView::class, $plan );
+		$this->assertSame( $archived_id, $plan->get_id() );
+		$this->assertSame( PlanStatus::ARCHIVED, $plan->get_status() );
+		$this->assertSame( self::OWNER, $plan->get_extension_slug() );
+		$this->assertSame( array( 'opaque' => true ), $plan->get_pricing_policy() );
+		$this->assertNull( Plans::get( 999999 ) );
+		$this->assertNull( Plans::get( 0 ) );
+	}
+
+	/**
+	 * @testdox list without args returns every extension's plans in every status, oldest id first.
+	 */
+	public function test_list_without_args_returns_every_plan_in_id_order(): void {
+		$first_id    = $this->create( array( 'name' => 'Zulu' ) );
+		$archived_id = $this->create( array( 'status' => PlanStatus::ARCHIVED ) );
+		$foreign_id  = $this->create( array( 'extension_slug' => 'other-extension' ) );
+
+		$plans = Plans::list();
+
+		$this->assertSame( array( $first_id, $archived_id, $foreign_id ), self::plan_ids( $plans ) );
+		$this->assertContainsOnlyInstancesOf( PlanView::class, $plans );
+	}
+
+	/**
+	 * @testdox list filters by an extension slug or a list of them, ignoring duplicate slugs.
+	 */
+	public function test_list_filters_by_extension_slug(): void {
+		$own_id   = $this->create();
+		$other_id = $this->create( array( 'extension_slug' => 'other-extension' ) );
+		$this->create( array( 'extension_slug' => 'third-extension' ) );
+
+		$this->assertSame( array( $own_id ), self::plan_ids( Plans::list( array( 'extension_slug' => self::OWNER ) ) ) );
+		$this->assertSame(
+			array( $own_id, $other_id ),
+			self::plan_ids( Plans::list( array( 'extension_slug' => array( self::OWNER, 'other-extension' ) ) ) )
+		);
+		$this->assertSame( array( $own_id ), self::plan_ids( Plans::list( array( 'extension_slug' => array( self::OWNER, self::OWNER ) ) ) ) );
+	}
+
+	/**
+	 * @testdox list filters by a status or a list of them.
+	 */
+	public function test_list_filters_by_status(): void {
+		$active_id   = $this->create();
+		$archived_id = $this->create( array( 'status' => PlanStatus::ARCHIVED ) );
+
+		$this->assertSame( array( $active_id ), self::plan_ids( Plans::list( array( 'status' => PlanStatus::ACTIVE ) ) ) );
+		$this->assertSame(
+			array( $active_id, $archived_id ),
+			self::plan_ids( Plans::list( array( 'status' => array( PlanStatus::ACTIVE, PlanStatus::ARCHIVED ) ) ) )
+		);
+	}
+
+	/**
+	 * @testdox list filters by ids in id order regardless of the requested order, composing with the other filters.
+	 */
+	public function test_list_filters_by_ids(): void {
+		$first_id    = $this->create();
+		$second_id   = $this->create();
+		$excluded_id = $this->create();
+		$archived_id = $this->create( array( 'status' => PlanStatus::ARCHIVED ) );
+		$foreign_id  = $this->create( array( 'extension_slug' => 'other-extension' ) );
+		$requested   = array( $archived_id, $second_id, $first_id, $foreign_id, 999999 );
+
+		$plans = Plans::list(
+			array(
+				'extension_slug' => self::OWNER,
+				'ids'            => $requested,
+			)
+		);
+		$all   = self::plan_ids( $plans );
+		$this->assertSame( array( $first_id, $second_id, $archived_id ), $all );
+		$this->assertNotContains( $excluded_id, $all );
+
+		$active_plans = Plans::list(
+			array(
+				'extension_slug' => self::OWNER,
+				'ids'            => $requested,
+				'status'         => PlanStatus::ACTIVE,
+			)
+		);
+		$this->assertSame( array( $first_id, $second_id ), self::plan_ids( $active_plans ) );
+	}
+
+	/**
+	 * @testdox list matches nothing for an empty list filter.
+	 */
+	public function test_list_matches_nothing_for_an_empty_list_filter(): void {
+		$this->create();
+
+		$this->assertSame( array(), Plans::list( array( 'ids' => array() ) ) );
+		$this->assertSame( array(), Plans::list( array( 'status' => array() ) ) );
+		$this->assertSame( array(), Plans::list( array( 'extension_slug' => array() ) ) );
+	}
+
+	/**
+	 * @testdox list pages with limit and offset in id order.
+	 */
+	public function test_list_pages_with_limit_and_offset(): void {
+		$this->create();
+		$second_id = $this->create();
+		$third_id  = $this->create();
+
+		$plans = Plans::list(
+			array(
+				'limit'  => 2,
+				'offset' => 1,
+			)
+		);
+		$this->assertSame( array( $second_id, $third_id ), self::plan_ids( $plans ) );
+	}
+
+	/**
+	 * @testdox list ignores an unknown key with a notice.
+	 */
+	public function test_list_ignores_an_unknown_key_with_a_notice(): void {
+		$this->setExpectedIncorrectUsage( Plans::class . '::list' );
+		$plan_id = $this->create();
+
+		$this->assertSame( array( $plan_id ), self::plan_ids( Plans::list( array( 'orderby' => 'name' ) ) ) );
+	}
+
+	/**
+	 * @testdox list rejects an invalid value.
+	 * @dataProvider provide_invalid_list_args
+	 *
+	 * @param array<string, mixed> $args List args.
+	 */
+	public function test_list_rejects_an_invalid_value( array $args ): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		Plans::list( $args );
+	}
+
+	/**
+	 * @return array<string, array{0: array<string, mixed>}>
+	 */
+	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 ) ),
+		);
+	}
+}
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 74a89f0916e..ff41ae8a449 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
@@ -9,8 +9,12 @@ declare( strict_types=1 );

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

+use Automattic\WooCommerce\SubscriptionsEngine\Api\Plans;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\Rest\PlansController;
+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 EngineIntegrationTestCase;
 use RuntimeException;
 use WP_Error;
@@ -45,6 +49,7 @@ class PlansControllerTest extends EngineIntegrationTestCase {

 	public function tearDown(): void {
 		remove_all_actions( 'woocommerce_subscriptions_engine_validate_plan' );
+		StatusRegistry::reset();
 		wp_set_current_user( 0 );
 		parent::tearDown();
 	}
@@ -57,25 +62,26 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 		$this->assertSame( 401, $response->get_status() );
 	}

-	public function test_create_list_and_partial_patch_preserves_advanced_pricing_fields(): void {
+	public function test_patch_replaces_a_policy_wholesale_and_keeps_omitted_policies(): void {
 		wp_set_current_user( $this->admin_id );

+		$billing = array(
+			'period'         => 'month',
+			'interval'       => 1,
+			'max_cycles'     => 12,
+			'trial_duration' => array(
+				'length' => 7,
+				'unit'   => 'day',
+			),
+		);
+
 		$created = $this->request(
 			'POST',
 			self::BASE,
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
 				'name'           => 'Monthly',
-				'description'    => 'Ships every month',
-				'billing_policy' => array(
-					'period'         => 'month',
-					'interval'       => 1,
-					'max_cycles'     => 12,
-					'trial_duration' => array(
-						'length' => 7,
-						'unit'   => 'day',
-					),
-				),
+				'billing_policy' => $billing,
 				'pricing_policy' => array(
 					'policies'      => array(
 						array(
@@ -85,10 +91,8 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 					),
 					'one_time_fees' => array(
 						array(
-							'kind'      => 'setup',
-							'amount'    => 5,
-							'taxable'   => true,
-							'tax_class' => '',
+							'kind'   => 'setup',
+							'amount' => 5,
 						),
 					),
 				),
@@ -97,11 +101,8 @@ class PlansControllerTest extends EngineIntegrationTestCase {

 		$this->assertSame( 201, $created->get_status() );
 		$created_data = $this->response_data( $created );
-		$this->assertSame( 'global', $created_data['scope'] );
-		$this->assertSame( Plan::STATUS_ACTIVE, $created_data['status'] );
+		$this->assertSame( PlanStatus::ACTIVE, $created_data['status'] );
 		$this->assertSame( self::EXTENSION_SLUG, $created_data['extension_slug'] );
-		$this->assertArrayNotHasKey( 'group', $created_data );
-		$this->assertArrayNotHasKey( 'merchant_code', $created_data );

 		$id = $this->int_value( $created_data, 'id' );

@@ -111,59 +112,21 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
 				'name'           => 'Monthly plus',
-				'billing_policy' => array(
-					'period'   => 'week',
-					'interval' => 2,
-				),
 				'pricing_policy' => array(
-					'policies' => array(
-						array(
-							'type'            => 'fixed_amount',
-							'value'           => 2,
-							'duration_cycles' => 3,
-						),
-					),
+					'policies' => array( array( 'type' => 'bogo' ) ),
 				),
 			)
 		);

 		$this->assertSame( 200, $patched->get_status() );
-		$patched_data   = $this->response_data( $patched );
-		$billing_policy = $this->array_value( $patched_data, 'billing_policy' );
-		$pricing_policy = $this->array_value( $patched_data, 'pricing_policy' );
-		$policies       = $this->array_value( $pricing_policy, 'policies' );
-		$first_policy   = $this->array_value( $policies, 0 );
-		$one_time_fees  = $this->array_value( $pricing_policy, 'one_time_fees' );
-		$first_fee      = $this->array_value( $one_time_fees, 0 );
+		$patched_data = $this->response_data( $patched );
 		$this->assertSame( 'Monthly plus', $patched_data['name'] );
-		$this->assertSame( 'week', $billing_policy['period'] );
-		$this->assertSame( 2, $billing_policy['interval'] );
-		$this->assertSame( 12, $billing_policy['max_cycles'] );
-		$this->assertSame(
-			array(
-				'length' => 7,
-				'unit'   => 'day',
-			),
-			$billing_policy['trial_duration']
-		);
-		// Stored as sent: the engine coerces nothing inside the payload.
-		$this->assertSame(
-			array(
-				'type'            => 'fixed_amount',
-				'value'           => 2,
-				'duration_cycles' => 3,
-			),
-			$first_policy
-		);
-		$this->assertSame(
-			array(
-				'kind'      => 'setup',
-				'amount'    => 5,
-				'taxable'   => true,
-				'tax_class' => '',
-			),
-			$first_fee
-		);
+		$this->assertSame( array( 'policies' => array( array( 'type' => 'bogo' ) ) ), $patched_data['pricing_policy'], 'A present policy replaces the stored payload; nothing is merged.' );
+		$this->assertSame( $billing, $patched_data['billing_policy'], 'An omitted policy keeps its stored payload.' );
+
+		$fetched = $this->response_data( $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) ) );
+		$this->assertSame( array( 'policies' => array( array( 'type' => 'bogo' ) ) ), $fetched['pricing_policy'] );
+		$this->assertSame( $billing, $fetched['billing_policy'] );

 		$list = $this->request(
 			'GET',
@@ -179,6 +142,87 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 		$this->assertCount( 1, $this->response_data( $list ) );
 	}

+	public function test_billing_and_delivery_round_trip_opaquely(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$billing  = array(
+			'cadence' => 'every full moon',
+			'nested'  => array( 'anything' => array( 1, 2 ) ),
+		);
+		$delivery = array(
+			'anchor' => array( 'weekday' => 'tue' ),
+			'note'   => 'opaque',
+		);
+
+		$created = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug'  => self::EXTENSION_SLUG,
+				'name'            => 'Opaque',
+				'billing_policy'  => $billing,
+				'delivery_policy' => $delivery,
+			)
+		);
+
+		$this->assertSame( 201, $created->get_status(), 'The engine does not validate cadence.' );
+		$id = $this->int_value( $this->response_data( $created ), 'id' );
+
+		$fetched = $this->response_data( $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) ) );
+		$this->assertSame( $billing, $fetched['billing_policy'] );
+		$this->assertSame( $delivery, $fetched['delivery_policy'] );
+	}
+
+	public function test_create_without_billing_policy_stores_null(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$created = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 'No billing',
+			)
+		);
+
+		$this->assertSame( 201, $created->get_status() );
+		$data = $this->response_data( $created );
+		$this->assertNull( $data['billing_policy'] );
+		$this->assertNull( $data['pricing_policy'] );
+		$this->assertNull( $data['delivery_policy'] );
+	}
+
+	public function test_response_has_exactly_the_record_fields(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$id   = $this->create_plan( 'Fields' );
+		$data = $this->response_data( $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) ) );
+
+		$this->assertEqualsCanonicalizing(
+			array( 'id', 'extension_slug', 'status', 'name', 'billing_policy', 'pricing_policy', 'delivery_policy', 'date_created_gmt', 'date_updated_gmt' ),
+			array_keys( $data )
+		);
+		$this->assertIsString( $data['date_created_gmt'] );
+		$this->assertIsString( $data['date_updated_gmt'] );
+	}
+
+	public function test_an_empty_policy_object_is_returned_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'           => 'Empty pricing',
+				'pricing_policy' => array(),
+			)
+		);
+
+		$this->assertSame( 201, $created->get_status() );
+		$this->assertSame( '{}', wp_json_encode( $this->response_data( $created )['pricing_policy'] ) );
+	}
+
 	public function test_create_round_trips_the_pricing_payload_opaquely(): void {
 		wp_set_current_user( $this->admin_id );

@@ -218,72 +262,6 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 		$this->assertSame( $pricing_policy, $this->response_data( $fetched )['pricing_policy'] );
 	}

-	public function test_update_replaces_provided_top_level_keys_and_keeps_omitted_ones(): void {
-		wp_set_current_user( $this->admin_id );
-
-		$created = $this->request(
-			'POST',
-			self::BASE,
-			array(
-				'extension_slug' => self::EXTENSION_SLUG,
-				'name'           => 'Discounted monthly',
-				'billing_policy' => array(
-					'period'   => 'month',
-					'interval' => 1,
-				),
-				'pricing_policy' => array(
-					'policies'      => array(
-						array(
-							'type'  => 'percentage',
-							'value' => 10,
-						),
-					),
-					'one_time_fees' => array(
-						array(
-							'kind'   => 'setup',
-							'amount' => 5,
-						),
-					),
-					'custom_key'    => 'kept',
-				),
-			)
-		);
-		$this->assertSame( 201, $created->get_status() );
-		$id = $this->int_value( $this->response_data( $created ), 'id' );
-
-		$patched = $this->request(
-			'PATCH',
-			self::BASE . '/' . $id,
-			array(
-				'extension_slug' => self::EXTENSION_SLUG,
-				'pricing_policy' => array(
-					'policies' => array(
-						array( 'type' => 'bogo' ),
-					),
-				),
-			)
-		);
-		$this->assertSame( 200, $patched->get_status() );
-
-		$fetched = $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) );
-		$this->assertSame( 200, $fetched->get_status() );
-		$this->assertSame(
-			array(
-				'policies'      => array(
-					array( 'type' => 'bogo' ),
-				),
-				'one_time_fees' => array(
-					array(
-						'kind'   => 'setup',
-						'amount' => 5,
-					),
-				),
-				'custom_key'    => 'kept',
-			),
-			$this->response_data( $fetched )['pricing_policy']
-		);
-	}
-
 	public function test_arbitrary_payload_values_are_stored_as_given(): void {
 		wp_set_current_user( $this->admin_id );

@@ -319,7 +297,7 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 	 *
 	 * @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 controller rejects.
+	 * @param string $patch_code  PATCH error code: the route has no arg schema, so the facade rejects.
 	 */
 	public function test_non_object_pricing_policy_is_rejected( $invalid, string $create_code, string $patch_code ): void {
 		wp_set_current_user( $this->admin_id );
@@ -351,7 +329,12 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 			)
 		);
 		$this->assertSame( 400, $patched->get_status() );
-		$this->assertSame( $patch_code, $this->response_data( $patched )['code'] );
+		$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.' );
+		}

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

-	public function test_validate_action_receives_errors_a_plan_copy_and_slug(): void {
+	public function test_update_rejects_an_empty_name_with_a_message_naming_the_field(): void {
+		wp_set_current_user( $this->admin_id );
+		$id = $this->create_plan( 'Monthly' );
+
+		$patched = $this->request(
+			'PATCH',
+			self::BASE . '/' . $id,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => '',
+			)
+		);
+
+		$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'] );
+	}
+
+	public function test_validate_action_receives_errors_a_plan_view_and_slug(): void {
 		wp_set_current_user( $this->admin_id );

 		$calls = array();
@@ -381,7 +384,7 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 			static function ( $errors, $plan, $extension_slug ) use ( &$calls ): void {
 				self::assertInstanceOf( WP_Error::class, $errors );
 				self::assertFalse( $errors->has_errors() );
-				self::assertInstanceOf( Plan::class, $plan );
+				self::assertInstanceOf( PlanView::class, $plan );
 				$calls[] = array( $plan->get_id(), $plan->get_name(), $plan->get_pricing_policy(), $extension_slug );
 			},
 			10,
@@ -428,16 +431,12 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 		);
 		$this->assertSame( 200, $renamed->get_status() );

-		$merged_pricing = array(
-			'policies'      => array( array( 'type' => 'bogo' ) ),
-			'custom_key'    => 'kept',
-			'one_time_fees' => array( array( 'amount' => 5 ) ),
-		);
+		$replaced_pricing = array( 'one_time_fees' => array( array( 'amount' => 5 ) ) );

 		$this->assertSame(
 			array(
 				array(
-					null,
+					0,
 					'Validated',
 					array(
 						'policies'   => array( array( 'type' => 'bogo' ) ),
@@ -445,18 +444,18 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 					),
 					self::EXTENSION_SLUG,
 				),
-				array( $id, 'Validated again', $merged_pricing, self::EXTENSION_SLUG ),
-				array( $id, 'Renamed only', $merged_pricing, self::EXTENSION_SLUG ),
+				array( $id, 'Validated again', $replaced_pricing, self::EXTENSION_SLUG ),
+				array( $id, 'Renamed only', $replaced_pricing, self::EXTENSION_SLUG ),
 			),
 			$calls,
-			'Create passes a plan with a null id; updates pass the stored plan with the request merged in.'
+			'Create passes a view with id 0; updates pass the would-be state with the request applied.'
 		);
 	}

 	/**
 	 * @dataProvider provide_rejecting_errors
 	 *
-	 * @param array<string, mixed> $data            Error data the owner adds.
+	 * @param array<string, mixed> $data            Error data the extension adds.
 	 * @param int                  $expected_status Expected response status.
 	 */
 	public function test_validate_action_error_rejects_create_and_update( array $data, int $expected_status ): void {
@@ -466,7 +465,7 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 		add_action(
 			'woocommerce_subscriptions_engine_validate_plan',
 			static function ( WP_Error $errors ) use ( $data ): void {
-				$errors->add( 'owner_rejected', 'No.', $data );
+				$errors->add( 'extension_rejected', 'No.', $data );
 			}
 		);

@@ -484,7 +483,7 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 			)
 		);
 		$this->assertSame( $expected_status, $created->get_status() );
-		$this->assertSame( 'owner_rejected', $this->response_data( $created )['code'] );
+		$this->assertSame( 'extension_rejected', $this->response_data( $created )['code'] );

 		$patched = $this->request(
 			'PATCH',
@@ -496,7 +495,7 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 			)
 		);
 		$this->assertSame( $expected_status, $patched->get_status() );
-		$this->assertSame( 'owner_rejected', $this->response_data( $patched )['code'] );
+		$this->assertSame( 'extension_rejected', $this->response_data( $patched )['code'] );

 		remove_all_actions( 'woocommerce_subscriptions_engine_validate_plan' );

@@ -563,14 +562,15 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 		);
 	}

-	public function test_validate_action_cannot_change_the_stored_plan(): void {
+	public function test_validate_action_receives_a_read_only_view_not_the_entity(): void {
 		wp_set_current_user( $this->admin_id );

+		$views = array();
 		add_action(
 			'woocommerce_subscriptions_engine_validate_plan',
-			static function ( WP_Error $errors, Plan $plan ): void {
-				$plan->set_name( 'Changed by callback' );
-				$plan->set_pricing_policy( array( 'policies' => array( array( 'type' => 'changed' ) ) ) );
+			static function ( WP_Error $errors, $plan ) use ( &$views ): void {
+				unset( $errors );
+				$views[] = $plan;
 			},
 			10,
 			2
@@ -582,32 +582,16 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
 				'name'           => 'As sent',
-				'billing_policy' => array(
-					'period'   => 'month',
-					'interval' => 1,
-				),
 				'pricing_policy' => array( 'policies' => array( array( 'type' => 'raw' ) ) ),
 			)
 		);
 		$this->assertSame( 201, $created->get_status() );
 		$this->assertSame( 'As sent', $this->response_data( $created )['name'] );
-		$id = $this->int_value( $this->response_data( $created ), 'id' );
-
-		$patched = $this->request(
-			'PATCH',
-			self::BASE . '/' . $id,
-			array(
-				'extension_slug' => self::EXTENSION_SLUG,
-				'name'           => 'As sent again',
-			)
-		);
-		$this->assertSame( 200, $patched->get_status() );
-
-		remove_all_actions( 'woocommerce_subscriptions_engine_validate_plan' );

-		$fetched = $this->response_data( $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) ) );
-		$this->assertSame( 'As sent again', $fetched['name'] );
-		$this->assertSame( array( 'policies' => array( array( 'type' => 'raw' ) ) ), $fetched['pricing_policy'] );
+		$this->assertCount( 1, $views );
+		$this->assertNotInstanceOf( Plan::class, $views[0], 'Callbacks never receive the mutable Core entity.' );
+		$this->assertInstanceOf( PlanView::class, $views[0] );
+		$this->assertSame( 'As sent', $views[0]->get_name() );
 	}

 	public function test_throwing_validate_callback_fails_the_write_without_storing(): void {
@@ -659,30 +643,21 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 		$this->assertSame( '1', $list->get_headers()['X-WP-Total'] );
 	}

-	public function test_reorder_does_not_fire_the_validate_action(): void {
+	public function test_reorder_route_is_not_registered(): void {
 		wp_set_current_user( $this->admin_id );
-		$first  = $this->create_plan( 'First' );
-		$second = $this->create_plan( 'Second' );
+		$id = $this->create_plan( 'First' );

-		$calls = 0;
-		add_action(
-			'woocommerce_subscriptions_engine_validate_plan',
-			static function () use ( &$calls ): void {
-				++$calls;
-			}
-		);
+		$this->assertArrayNotHasKey( self::BASE . '/reorder', rest_get_server()->get_routes() );

-		$reordered = $this->request(
+		$response = $this->request(
 			'POST',
 			self::BASE . '/reorder',
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
-				'ids'            => array( $second, $first ),
+				'ids'            => array( $id ),
 			)
 		);
-
-		$this->assertSame( 200, $reordered->get_status() );
-		$this->assertSame( 0, $calls );
+		$this->assertSame( 404, $response->get_status() );
 	}

 	public function test_patch_with_null_pricing_policy_clears_the_payload(): void {
@@ -793,22 +768,55 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 		$this->assertSame( 'woocommerce-subscriptions-test', $second_data['extension_slug'] );
 	}

-	public function test_list_can_order_by_status(): void {
+	public function test_list_defaults_to_id_order_and_rejects_retired_orderby_values(): void {
 		wp_set_current_user( $this->admin_id );

-		$active_before = $this->create_plan( 'Active before' );
-		$archived      = $this->create_plan( 'Archived' );
-		$active_after  = $this->create_plan( 'Active after' );
+		$charlie = $this->create_plan( 'Charlie' );
+		$alpha   = $this->create_plan( 'Alpha' );
+
+		$list = $this->request( 'GET', self::BASE, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) );
+		$this->assertSame( array( $charlie, $alpha ), $this->response_ids( $list ) );
+
+		$by_name = $this->request(
+			'GET',
+			self::BASE,
+			array(),
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'orderby'        => 'name',
+			)
+		);
+		$this->assertSame( array( $alpha, $charlie ), $this->response_ids( $by_name ) );

-		$archived_response = $this->request(
+		foreach ( array( 'status', 'sort_order' ) as $orderby ) {
+			$rejected = $this->request(
+				'GET',
+				self::BASE,
+				array(),
+				array(
+					'extension_slug' => self::EXTENSION_SLUG,
+					'orderby'        => $orderby,
+				)
+			);
+			$this->assertSame( 400, $rejected->get_status(), "orderby={$orderby} must be rejected." );
+		}
+	}
+
+	public function test_status_filter_accepts_a_registered_extension_status_and_rejects_others(): void {
+		wp_set_current_user( $this->admin_id );
+		StatusRegistry::register( StatusRegistry::KIND_PLAN, 'seasonal' );
+
+		$this->create_plan( 'Active' );
+		$seasonal = $this->create_plan( 'Seasonal' );
+		$patched  = $this->request(
 			'PATCH',
-			self::BASE . '/' . $archived,
+			self::BASE . '/' . $seasonal,
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
-				'status'         => Plan::STATUS_ARCHIVED,
+				'status'         => 'seasonal',
 			)
 		);
-		$this->assertSame( 200, $archived_response->get_status() );
+		$this->assertSame( 'seasonal', $this->response_data( $patched )['status'] );

 		$list = $this->request(
 			'GET',
@@ -816,13 +824,32 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 			array(),
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
-				'orderby'        => 'status',
-				'order'          => 'desc',
+				'status'         => 'seasonal',
 			)
 		);
-
 		$this->assertSame( 200, $list->get_status() );
-		$this->assertSame( array( $archived, $active_before, $active_after ), $this->response_ids( $list ) );
+		$this->assertSame( array( $seasonal ), $this->response_ids( $list ) );
+
+		$unknown = $this->request(
+			'GET',
+			self::BASE,
+			array(),
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'status'         => 'never-registered',
+			)
+		);
+		$this->assertSame( 400, $unknown->get_status() );
+
+		$bad_patch = $this->request(
+			'PATCH',
+			self::BASE . '/' . $seasonal,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'status'         => 'never-registered',
+			)
+		);
+		$this->assertSame( 400, $bad_patch->get_status() );
 	}

 	public function test_single_plan_routes_reject_wildcard_and_list_extension_slugs(): void {
@@ -843,18 +870,38 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 					)
 				)->get_status()
 			);
-			$this->assertSame(
-				400,
-				$this->request(
-					'POST',
-					self::BASE . '/reorder',
-					array(
-						'extension_slug' => $extension_slug,
-						'ids'            => array( $id ),
-					)
-				)->get_status()
+		}
+	}
+
+	public function test_single_plan_routes_404_a_plan_of_another_extension_slug_or_an_unknown_id(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$foreign_id = $this->create_plan( 'Foreign', 'woocommerce-subscriptions-test' );
+		$unknown_id = $foreign_id + 1000;
+
+		foreach ( array( $foreign_id, $unknown_id ) as $id ) {
+			$get = $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) );
+			$this->assertSame( 404, $get->get_status() );
+			$this->assertSame( 'woocommerce_subscriptions_engine_plan_not_found', $this->response_data( $get )['code'] );
+
+			$patch = $this->request(
+				'PATCH',
+				self::BASE . '/' . $id,
+				array(
+					'extension_slug' => self::EXTENSION_SLUG,
+					'name'           => 'Hijacked',
+				)
 			);
+			$this->assertSame( 404, $patch->get_status() );
+			$this->assertSame( 'woocommerce_subscriptions_engine_plan_not_found', $this->response_data( $patch )['code'] );
 		}
+
+		$foreign = Plans::get( $foreign_id );
+		$this->assertNotNull( $foreign );
+		$this->assertSame( 'Foreign', $foreign->get_name(), 'A PATCH under another slug writes nothing.' );
+
+		$own = $this->request( 'GET', self::BASE . '/' . $foreign_id, array(), array( 'extension_slug' => 'woocommerce-subscriptions-test' ) );
+		$this->assertSame( 200, $own->get_status(), 'The plan resolves under its own slug.' );
 	}

 	public function test_create_rejects_wildcard_and_list_extension_slugs(): void {
@@ -878,6 +925,72 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 		}
 	}

+	public function test_create_validates_the_status_and_keeps_the_callback_out_of_the_schema(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$created = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 'Unregistered',
+				'status'         => 'never-registered',
+			)
+		);
+		$this->assertSame( 400, $created->get_status() );
+		$this->assertSame( 'rest_invalid_param', $this->response_data( $created )['code'] );
+
+		$schema = ( new PlansController() )->get_public_item_schema();
+		$this->assertIsArray( $schema['properties']['status'] );
+		$this->assertArrayNotHasKey( 'validate_callback', $schema['properties']['status'] );
+		$this->assertArrayNotHasKey( 'arg_options', $schema['properties']['status'] );
+	}
+
+	public function test_create_surfaces_a_failed_insert_as_a_logged_create_error(): void {
+		global $wpdb;
+
+		wp_set_current_user( $this->admin_id );
+
+		$break_plan_inserts = static function ( $query ) {
+			if ( is_string( $query ) && 0 === stripos( ltrim( $query ), 'INSERT' ) && false !== strpos( $query, 'wc_selling_plans' ) ) {
+				return 'INSERT INTO nonexistent_table_for_this_test (id) VALUES (1)';
+			}
+
+			return $query;
+		};
+		$errors             = array();
+		$capture            = static function ( $message, $level, $context ) use ( &$errors ) {
+			if ( 'error' === $level && is_string( $message ) && is_array( $context ) && 'woocommerce-subscriptions-engine' === ( $context['source'] ?? null ) ) {
+				$errors[] = $message;
+			}
+			return $message;
+		};
+		add_filter( 'query', $break_plan_inserts );
+		add_filter( 'woocommerce_logger_log_message', $capture, 10, 3 );
+		$suppressed = $wpdb->suppress_errors( true );
+
+		try {
+			$response = $this->request(
+				'POST',
+				self::BASE,
+				array(
+					'extension_slug' => self::EXTENSION_SLUG,
+					'name'           => 'Doomed insert',
+				)
+			);
+		} finally {
+			$wpdb->suppress_errors( $suppressed );
+			remove_filter( 'query', $break_plan_inserts );
+			remove_filter( 'woocommerce_logger_log_message', $capture, 10 );
+		}
+
+		$this->assertSame( 500, $response->get_status() );
+		$this->assertSame( 'woocommerce_subscriptions_engine_plan_create_failed', $this->response_data( $response )['code'] );
+		$this->assertStringNotContainsString( 'nonexistent_table_for_this_test', (string) wp_json_encode( $response->get_data() ), 'The database error never reaches the client.' );
+		$this->assertNotEmpty( $errors, 'A failed write is logged.' );
+		$this->assertStringContainsString( 'nonexistent_table_for_this_test', $errors[0], 'The log carries the database error.' );
+	}
+
 	public function test_update_surfaces_a_failed_write_as_an_error(): void {
 		global $wpdb;

@@ -912,69 +1025,33 @@ class PlansControllerTest extends EngineIntegrationTestCase {

 		$this->assertSame( 500, $response->get_status() );
 		$this->assertSame( 'woocommerce_subscriptions_engine_plan_update_failed', $this->response_data( $response )['code'] );
+		$this->assertStringNotContainsString( 'nonexistent_table_for_this_test', (string) wp_json_encode( $response->get_data() ), 'The database error never reaches the client.' );
 	}

-	public function test_archive_restore_and_reorder(): void {
+	public function test_archive_and_restore(): void {
 		wp_set_current_user( $this->admin_id );

-		$first  = $this->create_plan( 'First' );
-		$second = $this->create_plan( 'Second' );
+		$first = $this->create_plan( 'First' );

 		$archived = $this->request(
 			'PATCH',
 			self::BASE . '/' . $first,
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
-				'status'         => Plan::STATUS_ARCHIVED,
+				'status'         => PlanStatus::ARCHIVED,
 			)
 		);
-		$this->assertSame( Plan::STATUS_ARCHIVED, $this->response_data( $archived )['status'] );
+		$this->assertSame( PlanStatus::ARCHIVED, $this->response_data( $archived )['status'] );

 		$restored = $this->request(
 			'PATCH',
 			self::BASE . '/' . $first,
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
-				'status'         => Plan::STATUS_ACTIVE,
-			)
-		);
-		$this->assertSame( Plan::STATUS_ACTIVE, $this->response_data( $restored )['status'] );
-
-		$reordered = $this->request(
-			'POST',
-			self::BASE . '/reorder',
-			array(
-				'extension_slug' => self::EXTENSION_SLUG,
-				'ids'            => array( $second, $first ),
-			)
-		);
-		$this->assertSame( 200, $reordered->get_status() );
-
-		$list = $this->request( 'GET', self::BASE, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) );
-		$ids  = array();
-		foreach ( $this->response_data( $list ) as $row ) {
-			$this->assertIsArray( $row );
-			$ids[] = $this->int_value( $row, 'id' );
-		}
-		$this->assertSame( array( $second, $first ), $ids );
-	}
-
-	public function test_reorder_rejects_duplicate_ids(): void {
-		wp_set_current_user( $this->admin_id );
-
-		$first  = $this->create_plan( 'First' );
-		$second = $this->create_plan( 'Second' );
-
-		$reordered = $this->request(
-			'POST',
-			self::BASE . '/reorder',
-			array(
-				'extension_slug' => self::EXTENSION_SLUG,
-				'ids'            => array( $second, $first, $second ),
+				'status'         => PlanStatus::ACTIVE,
 			)
 		);
-
-		$this->assertSame( 400, $reordered->get_status() );
+		$this->assertSame( PlanStatus::ACTIVE, $this->response_data( $restored )['status'] );
 	}

 	public function test_delete_route_is_not_exposed(): void {
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SellingPlansTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SellingPlansTest.php
deleted file mode 100644
index da400c7c9a8..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SellingPlansTest.php
+++ /dev/null
@@ -1,125 +0,0 @@
-<?php
-/**
- * Integration tests for the SellingPlans facade.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api;
-
-use EngineIntegrationTestCase;
-use Automattic\WooCommerce\SubscriptionsEngine\Api\SellingPlans;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\SellingPlans
- */
-class SellingPlansTest extends EngineIntegrationTestCase {
-
-	private const SLUG = 'lite';
-
-	/**
-	 * Insert a plan.
-	 *
-	 * @param string               $name      Plan name.
-	 * @param array<string, mixed> $overrides Attribute overrides.
-	 */
-	private function make_plan( string $name, array $overrides = array() ): int {
-		return ( new PlanRepository() )->insert(
-			Plan::create(
-				array_merge(
-					array(
-						'name'           => $name,
-						'billing_policy' => BillingPolicy::from_array(
-							array(
-								'period'   => 'month',
-								'interval' => 1,
-							)
-						),
-						'extension_slug' => self::SLUG,
-					),
-					$overrides
-				)
-			)
-		);
-	}
-
-	/**
-	 * Map plans to their ids.
-	 *
-	 * @param array<int, Plan> $plans Plans to map.
-	 * @return array<int, int|null>
-	 */
-	private static function plan_ids( array $plans ): array {
-		return array_map(
-			static function ( Plan $plan ): ?int {
-				return $plan->get_id();
-			},
-			$plans
-		);
-	}
-
-	public function test_list_plans_excludes_archived_and_foreign_slug_plans(): void {
-		$second_id = $this->make_plan( 'Second', array( 'sort_order' => 2 ) );
-		$first_id  = $this->make_plan( 'First', array( 'sort_order' => 1 ) );
-		$this->make_plan( 'Archived', array( 'status' => Plan::STATUS_ARCHIVED ) );
-		$this->make_plan( 'Foreign', array( 'extension_slug' => 'other-extension' ) );
-
-		$plans = ( new SellingPlans( array( self::SLUG ) ) )->list_plans();
-
-		$this->assertSame( array( $first_id, $second_id ), self::plan_ids( $plans ) );
-	}
-
-	public function test_get_plans_returns_active_owned_plans_in_display_order(): void {
-		$second_id   = $this->make_plan( 'Second', array( 'sort_order' => 2 ) );
-		$first_id    = $this->make_plan( 'First', array( 'sort_order' => 1 ) );
-		$excluded_id = $this->make_plan( 'Excluded', array( 'sort_order' => 3 ) );
-		$archived_id = $this->make_plan( 'Archived', array( 'status' => Plan::STATUS_ARCHIVED ) );
-		$foreign_id  = $this->make_plan( 'Foreign', array( 'extension_slug' => 'other-extension' ) );
-
-		$catalog = new SellingPlans( array( self::SLUG ) );
-
-		$plans = $catalog->get_plans( array( $second_id, $first_id, $archived_id, $foreign_id, 999999 ) );
-
-		// Display order regardless of request order; archived, foreign, and unknown ids are absent.
-		$this->assertSame( array( $first_id, $second_id ), self::plan_ids( $plans ) );
-		$this->assertNotContains( $excluded_id, self::plan_ids( $plans ) );
-	}
-
-	public function test_get_plans_empty_or_invalid_ids_yield_an_empty_array(): void {
-		$plan_id = $this->make_plan( 'Plan' );
-
-		$catalog = new SellingPlans( array( self::SLUG ) );
-
-		// Non-int junk coverage lives in PlanRepositoryTest; the facade takes int ids.
-		$this->assertSame( array(), $catalog->get_plans( array() ) );
-		$this->assertSame( array(), $catalog->get_plans( array( $plan_id, 0 ) ) );
-	}
-
-	public function test_two_slug_instance_reads_across_both_slugs(): void {
-		$lite_id    = $this->make_plan( 'Lite plan', array( 'sort_order' => 1 ) );
-		$other_id   = $this->make_plan(
-			'Other plan',
-			array(
-				'sort_order'     => 2,
-				'extension_slug' => 'other-extension',
-			)
-		);
-		$foreign_id = $this->make_plan(
-			'Foreign',
-			array(
-				'sort_order'     => 3,
-				'extension_slug' => 'third-extension',
-			)
-		);
-
-		$catalog = new SellingPlans( array( self::SLUG, 'other-extension' ) );
-
-		$this->assertSame( array( $lite_id, $other_id ), self::plan_ids( $catalog->list_plans() ) );
-		$this->assertSame( array( $lite_id, $other_id ), self::plan_ids( $catalog->get_plans( array( $other_id, $lite_id, $foreign_id ) ) ) );
-	}
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
index 112b9b1d09a..8885475dacd 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
@@ -17,12 +17,9 @@ use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\Subscriptions
@@ -54,15 +51,7 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
 	 * @return Contract The persisted contract with cycle 1 billed.
 	 */
 	private function sign_up_contract( int $customer_id = 0 ): Contract {
-		$plan = Plan::create(
-			array(
-				'name'           => 'Monthly',
-				'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
-				'category'       => Plan::DEFAULT_CATEGORY,
-				'extension_slug' => 'engine-tests',
-			)
-		);
-		( new PlanRepository() )->insert( $plan );
+		$plan = $this->plan_view( $this->make_plan() );

 		$order = new WC_Order();
 		$order->set_currency( 'USD' );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
index ff963ed45d5..8efba72e1a6 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
@@ -11,9 +11,11 @@
 declare( strict_types=1 );

 use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Plans;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;

 /**
@@ -21,6 +23,11 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
  */
 abstract class EngineIntegrationTestCase extends WP_UnitTestCase {

+	/**
+	 * Owner slug of the plans {@see self::make_plan()} creates by default.
+	 */
+	protected const PLAN_OWNER = 'engine-tests';
+
 	/**
 	 * Gateway ids wired with an approving scheduled-payment handler, to unhook on teardown.
 	 *
@@ -89,6 +96,79 @@ abstract class EngineIntegrationTestCase extends WP_UnitTestCase {
 		$this->approved_gateways[] = $gateway;
 	}

+	/**
+	 * Create a plan through the plan facade: a monthly billing payload owned by
+	 * {@see self::PLAN_OWNER} unless overridden.
+	 *
+	 * @param array<string, mixed> $overrides `Plans::create()` args to replace.
+	 * @return int The plan id.
+	 */
+	protected function make_plan( array $overrides = array() ): int {
+		$plan = Plans::create(
+			array_merge(
+				array(
+					'extension_slug' => self::PLAN_OWNER,
+					'name'           => 'Monthly',
+					'billing_policy' => array(
+						'period'   => 'month',
+						'interval' => 1,
+					),
+				),
+				$overrides
+			)
+		);
+
+		return $plan->get_id();
+	}
+
+	/**
+	 * Run `$run` and return the context of every engine log entry at `$level` it wrote whose
+	 * context matches `$context_match` (for example a contract id). Matching on source, level and
+	 * context, not message text, keeps the assertions valid when a message is reworded.
+	 *
+	 * @param string               $level         Log level, e.g. `warning`.
+	 * @param array<string, mixed> $context_match Context keys and values every returned entry carries.
+	 * @param callable             $run           Code under test.
+	 * @return array<int, array<string, mixed>> Contexts of the matching entries, in log order.
+	 */
+	protected function capture_engine_log( string $level, array $context_match, callable $run ): array {
+		$entries = array();
+		$capture = static function ( $message, $entry_level, $context ) use ( &$entries, $level, $context_match ) {
+			if ( $level !== $entry_level || ! is_array( $context ) || 'woocommerce-subscriptions-engine' !== ( $context['source'] ?? null ) ) {
+				return $message;
+			}
+			foreach ( $context_match as $key => $value ) {
+				if ( ! array_key_exists( $key, $context ) || $value !== $context[ $key ] ) {
+					return $message;
+				}
+			}
+			$entries[] = $context;
+
+			return $message;
+		};
+		add_filter( 'woocommerce_logger_log_message', $capture, 10, 3 );
+
+		try {
+			$run();
+		} finally {
+			remove_filter( 'woocommerce_logger_log_message', $capture, 10 );
+		}
+
+		return $entries;
+	}
+
+	/**
+	 * Read a plan through the plan facade, asserting it exists.
+	 *
+	 * @param int $plan_id Plan id.
+	 */
+	protected function plan_view( int $plan_id ): PlanView {
+		$plan = Plans::get( $plan_id );
+		$this->assertInstanceOf( PlanView::class, $plan );
+
+		return $plan;
+	}
+
 	/**
 	 * Sign up a contract for a paid order on `$plan` through the contracts facade, the way an
 	 * extension maps its checkout: create a draft from explicit order fields and snapshots,
@@ -96,11 +176,11 @@ abstract class EngineIntegrationTestCase extends WP_UnitTestCase {
 	 * customer gets a new one.
 	 *
 	 * @param WC_Order             $order     Saved, paid order.
-	 * @param Plan                 $plan      Saved selling plan.
+	 * @param PlanView             $plan      Saved selling plan.
 	 * @param array<string, mixed> $overrides `Contracts::create()` fields to replace; `status` is the final status.
 	 * @return int The contract id.
 	 */
-	protected function sign_up_from_order( WC_Order $order, Plan $plan, array $overrides = array() ): int {
+	protected function sign_up_from_order( WC_Order $order, PlanView $plan, array $overrides = array() ): int {
 		if ( $order->get_customer_id() <= 0 ) {
 			$customer_id = self::factory()->user->create();
 			$this->assertIsInt( $customer_id );
@@ -143,7 +223,7 @@ abstract class EngineIntegrationTestCase extends WP_UnitTestCase {
 				'payment_method_title' => '' !== $order->get_payment_method_title() ? $order->get_payment_method_title() : null,
 				'payment_token_id'     => $token_id > 0 ? $token_id : null,
 				'start_gmt'            => $start,
-				'next_payment_gmt'     => $plan->get_billing_policy()->compute_first_renewal_from( $start ),
+				'next_payment_gmt'     => BillingPolicy::from_array( $plan->get_billing_policy() ?? array() )->compute_first_renewal_from( $start ),
 				'billing_total'        => (string) $order->get_total(),
 				'discount_total'       => (string) $order->get_total_discount(),
 				'shipping_total'       => (string) $order->get_shipping_total(),
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php
index e3eabcbedfb..a1fccbae5c2 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php
@@ -17,13 +17,10 @@ use DomainException;
 use EngineIntegrationTestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Reactivation;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;

 /**
@@ -59,26 +56,7 @@ class ReactivationTest extends EngineIntegrationTestCase {
 	 * Create a monthly plan and return its id.
 	 */
 	private function make_monthly_plan(): int {
-		return $this->make_plan( 'month' );
-	}
-
-	/**
-	 * Create a plan on the given cadence period and return its id.
-	 *
-	 * @param string $period Billing period slug: day/week/month/year.
-	 */
-	private function make_plan( string $period ): int {
-		$plan = Plan::create(
-			array(
-				'name'           => ucfirst( $period ) . 'ly',
-				'billing_policy' => new BillingPolicy( $period, 1, null, null, null ),
-				'category'       => Plan::DEFAULT_CATEGORY,
-				'extension_slug' => 'engine-tests',
-			)
-		);
-		( new PlanRepository() )->insert( $plan );
-
-		return (int) $plan->get_id();
+		return $this->make_plan();
 	}

 	/**
@@ -227,7 +205,17 @@ class ReactivationTest extends EngineIntegrationTestCase {
 	public function test_reactivate_floors_past_due_at_now_when_the_roll_cap_exhausts(): void {
 		// Daily cadence, held ~6.5 years past due: more rolls than the cap allows, so
 		// the date is floored at `$now` - never returned still in the past.
-		$id = $this->seed_on_hold( '2020-01-01 00:00:00', $this->make_plan( 'day' ) );
+		$id = $this->seed_on_hold(
+			'2020-01-01 00:00:00',
+			$this->make_plan(
+				array(
+					'billing_policy' => array(
+						'period'   => 'day',
+						'interval' => 1,
+					),
+				)
+			)
+		);

 		$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-07-06 00:00:00' ) );

@@ -243,6 +231,116 @@ class ReactivationTest extends EngineIntegrationTestCase {
 		$this->assertSame( '2026-04-15 09:30:00', $this->reload( $id )->get_next_payment_gmt() );
 	}

+	/**
+	 * @dataProvider provide_unusable_live_billing_payloads
+	 *
+	 * @param array<string, mixed>|null $billing The live plan's billing payload.
+	 */
+	public function test_reactivate_floors_past_due_at_now_when_the_live_billing_is_unusable( ?array $billing ): void {
+		$plan_id = $this->make_plan( array( 'billing_policy' => $billing ) );
+		$id      = $this->seed_on_hold( '2026-02-01 00:00:00', $plan_id );
+
+		$warnings = $this->capture_engine_log(
+			'warning',
+			array(
+				'contract_id' => $id,
+				'plan_id'     => $plan_id,
+			),
+			function () use ( $id ): void {
+				$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-04-15 09:30:00' ) );
+			}
+		);
+
+		$this->assertSame( '2026-04-15 09:30:00', $this->reload( $id )->get_next_payment_gmt() );
+		$this->assertNotEmpty( $warnings, 'A null or unusable live billing payload is logged with the contract and plan.' );
+	}
+
+	/**
+	 * @return array<string, array{0: array<string, mixed>|null}>
+	 */
+	public function provide_unusable_live_billing_payloads(): array {
+		return array(
+			'null payload'     => array( null ),
+			'missing interval' => array( array( 'period' => 'month' ) ),
+			'unknown period'   => array(
+				array(
+					'period'   => 'decade',
+					'interval' => 1,
+				),
+			),
+			'zero interval'    => array(
+				array(
+					'period'   => 'month',
+					'interval' => 0,
+				),
+			),
+		);
+	}
+
+	/**
+	 * A snapshot policy with no usable cadence falls through to the live plan (logged),
+	 * the same as renewal, instead of throwing out of the forward roll.
+	 *
+	 * @dataProvider provide_unusable_snapshot_billing_payloads
+	 *
+	 * @param array<string, mixed>      $snapshot_billing The snapshot's billing payload.
+	 * @param array<string, mixed>|null $live_billing     The live plan's billing payload.
+	 * @param string                    $expected_next    The expected next payment.
+	 */
+	public function test_reactivate_falls_back_to_the_live_plan_when_the_snapshot_billing_is_unusable( array $snapshot_billing, ?array $live_billing, string $expected_next ): void {
+		$id = $this->seed_on_hold( '2026-02-01 00:00:00', $this->make_plan( array( 'billing_policy' => $live_billing ) ) );
+
+		$contract = $this->reload( $id );
+		$contract->set_plan_snapshot(
+			PlanSnapshot::from_array(
+				array(
+					'selling_plan_id' => $contract->get_selling_plan_id(),
+					'billing_policy'  => $snapshot_billing,
+				)
+			)
+		);
+
+		$warnings = $this->capture_engine_log(
+			'warning',
+			array( 'contract_id' => $id ),
+			function () use ( $contract ): void {
+				$this->assertTrue( $this->sut->reactivate( $contract, $this->utc( '2026-04-15 09:30:00' ) ) );
+			}
+		);
+
+		$stored = $this->reload( $id );
+		$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
+		$this->assertSame( $expected_next, $stored->get_next_payment_gmt() );
+		$this->assertNotEmpty( $warnings, 'An unusable snapshot billing payload is logged.' );
+	}
+
+	/**
+	 * @return array<string, array{0: array<string, mixed>, 1: array<string, mixed>|null, 2: string}>
+	 */
+	public function provide_unusable_snapshot_billing_payloads(): array {
+		$monthly = array(
+			'period'   => 'month',
+			'interval' => 1,
+		);
+		$decade  = array(
+			'period'   => 'decade',
+			'interval' => 1,
+		);
+		$zero    = array(
+			'period'   => 'month',
+			'interval' => 0,
+		);
+		$rolled  = '2026-05-01 00:00:00';
+		$floored = '2026-04-15 09:30:00';
+
+		return array(
+			'unknown period, live monthly'     => array( $decade, $monthly, $rolled ),
+			'zero interval, live monthly'      => array( $zero, $monthly, $rolled ),
+			'unknown period, live unusable'    => array( $decade, $zero, $floored ),
+			'zero interval, live null payload' => array( $zero, null, $floored ),
+		);
+	}
+
 	public function test_reactivate_leaves_a_null_next_payment_null(): void {
 		$id = $this->seed_on_hold( null, $this->make_monthly_plan() );

diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php
index 6f66130bd5d..c0e16f372f5 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php
@@ -23,17 +23,14 @@ use EngineIntegrationTestCase;
 use WC_Order;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;

 /**
@@ -153,15 +150,7 @@ class OwnerScopedDueScanTest extends EngineIntegrationTestCase {
 	 * @param string $owner The plan's (and so the contract's) extension slug.
 	 */
 	private function sign_up( string $owner ): int {
-		$plan = Plan::create(
-			array(
-				'name'           => 'Monthly',
-				'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
-				'category'       => Plan::DEFAULT_CATEGORY,
-				'extension_slug' => $owner,
-			)
-		);
-		( new PlanRepository() )->insert( $plan );
+		$plan = $this->plan_view( $this->make_plan( array( 'extension_slug' => $owner ) ) );

 		$order = new WC_Order();
 		$order->set_currency( 'USD' );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php
index c39089d3004..726e17b883d 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php
@@ -18,14 +18,11 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 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\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher
@@ -65,23 +62,6 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
 		return new DateTimeImmutable( '2026-02-15 00:00:00', new DateTimeZone( 'UTC' ) );
 	}

-	/**
-	 * Persist a monthly plan and return the entity (the sign-up helper needs the plan).
-	 */
-	private function make_plan_object(): Plan {
-		$plan = Plan::create(
-			array(
-				'name'           => 'Monthly',
-				'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
-				'category'       => Plan::DEFAULT_CATEGORY,
-				'extension_slug' => 'engine-tests',
-			)
-		);
-		( new PlanRepository() )->insert( $plan );
-
-		return $plan;
-	}
-
 	/**
 	 * Sign up a contract through the contracts facade so its billing chain holds cycle 1 (billed),
 	 * with its next payment due at the given date.
@@ -91,7 +71,7 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
 	 * @return Contract The persisted contract with cycle 1 billed.
 	 */
 	private function sign_up_contract( string $gateway, string $next_payment_gmt ): Contract {
-		$plan = $this->make_plan_object();
+		$plan = $this->plan_view( $this->make_plan() );

 		$order = new WC_Order();
 		$order->set_currency( 'USD' );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php
index 2ac1e894e24..f9990ab4f58 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php
@@ -16,9 +16,10 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 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\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Plans;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
@@ -28,6 +29,7 @@ use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalIntent
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SnapshotStore;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine
@@ -105,27 +107,23 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		return $order instanceof WC_Order ? $order : null;
 	}

-	private function make_plan( ?int $max_cycles = null ): int {
-		return (int) $this->make_plan_object( $max_cycles )->get_id();
-	}
-
 	/**
-	 * Persist a monthly plan and return the entity (the sign-up helper needs the plan).
+	 * Create a monthly plan and return its view (the sign-up helper needs the plan).
 	 *
 	 * @param int|null $max_cycles Maximum billing cycles, or null for open-ended.
 	 */
-	private function make_plan_object( ?int $max_cycles = null ): Plan {
-		$plan = Plan::create(
-			array(
-				'name'           => 'Monthly',
-				'billing_policy' => new BillingPolicy( 'month', 1, null, $max_cycles, null ),
-				'category'       => Plan::DEFAULT_CATEGORY,
-				'extension_slug' => 'engine-tests',
+	private function make_plan_view( ?int $max_cycles = null ): PlanView {
+		return $this->plan_view(
+			$this->make_plan(
+				array(
+					'billing_policy' => array(
+						'period'     => 'month',
+						'interval'   => 1,
+						'max_cycles' => $max_cycles,
+					),
+				)
 			)
 		);
-		( new PlanRepository() )->insert( $plan );
-
-		return $plan;
 	}

 	/**
@@ -137,7 +135,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 	 * @return Contract The persisted contract with cycle 1 billed.
 	 */
 	private function sign_up_contract( string $gateway, ?int $max_cycles = null ): Contract {
-		$plan = $this->make_plan_object( $max_cycles );
+		$plan = $this->make_plan_view( $max_cycles );

 		$order = new WC_Order();
 		$order->set_currency( 'USD' );
@@ -447,7 +445,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$order->add_item( $line );
 		$order->save();

-		$contract_id = $this->sign_up_from_order( $order, $this->make_plan_object() );
+		$contract_id = $this->sign_up_from_order( $order, $this->make_plan_view() );

 		$renewal_order = $this->run_scheduled_renewal( $contract_id );
 		$this->assertInstanceOf( WC_Order::class, $renewal_order );
@@ -653,6 +651,175 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$this->assertNull( $reloaded->get_next_payment_gmt() );
 	}

+	/**
+	 * @testdox the scheduled scan parks a contract without a snapshot whose live plan billing payload is null, does not parse, or has no usable cadence.
+	 *
+	 * @dataProvider provide_unusable_live_billing_payloads
+	 *
+	 * @param array<string, mixed>|null $billing The live plan's billing payload.
+	 */
+	public function test_scheduled_renewal_parks_a_contract_whose_live_billing_is_unusable( ?array $billing ): void {
+		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
+
+		$plan_id     = $this->make_plan();
+		$order       = $this->make_origin_order();
+		$contract    = $this->make_contract( $plan_id, $order->get_id() );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$repo = new ContractRepository();
+		$repo->append_cycle(
+			Cycle::create(
+				array(
+					'contract_id'    => $contract_id,
+					'sequence_no'    => 1,
+					'count'          => 1,
+					'status'         => new CycleStatus( CycleStatus::BILLED ),
+					'starts_at_gmt'  => '2026-01-15 00:00:00',
+					'ends_at_gmt'    => '2026-02-15 00:00:00',
+					'expected_total' => '19.99',
+					'currency'       => 'USD',
+				)
+			)
+		);
+
+		$this->assertInstanceOf(
+			PlanView::class,
+			Plans::update(
+				$plan_id,
+				array(
+					'extension_slug' => self::PLAN_OWNER,
+					'billing_policy' => $billing,
+				)
+			)
+		);
+
+		$result   = null;
+		$warnings = $this->capture_engine_log(
+			'warning',
+			array(
+				'contract_id' => $contract_id,
+				'plan_id'     => $plan_id,
+			),
+			function () use ( &$result, $contract_id ): void {
+				$result = $this->run_scheduled_renewal( $contract_id );
+			}
+		);
+
+		$this->assertNull( $result );
+		$this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+		$reloaded = $repo->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $reloaded );
+		$this->assertNull( $reloaded->get_next_payment_gmt(), 'The contract is parked out of the due set.' );
+		$this->assertNotEmpty( $warnings, 'A null or unusable live billing payload is logged with the contract and plan.' );
+	}
+
+	/**
+	 * @return array<string, array{0: array<string, mixed>|null}>
+	 */
+	public function provide_unusable_live_billing_payloads(): array {
+		return array(
+			'null payload'     => array( null ),
+			'missing interval' => array( array( 'period' => 'month' ) ),
+			'unknown period'   => array(
+				array(
+					'period'   => 'decade',
+					'interval' => 1,
+				),
+			),
+			'zero interval'    => array(
+				array(
+					'period'   => 'month',
+					'interval' => 0,
+				),
+			),
+		);
+	}
+
+	/**
+	 * @testdox the scheduled scan falls back to the live plan when the snapshot billing has no usable cadence, and parks when neither is usable.
+	 *
+	 * @dataProvider provide_unusable_snapshot_billing_payloads
+	 *
+	 * @param array<string, mixed>      $snapshot_billing The snapshot's billing payload.
+	 * @param array<string, mixed>|null $live_billing     The live plan's billing payload.
+	 * @param string|null               $expected_next    The next payment after the run; null when parked.
+	 */
+	public function test_scheduled_renewal_reads_an_unusable_snapshot_billing_through_the_live_plan( array $snapshot_billing, ?array $live_billing, ?string $expected_next ): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+
+		$plan  = $this->make_plan_view();
+		$order = new WC_Order();
+		$order->set_currency( 'USD' );
+		$order->set_payment_method( self::GATEWAY_APPROVING );
+		$order->set_total( '19.99' );
+		$order->set_date_paid( '2026-01-15 00:00:00' );
+		$order->save();
+
+		$contract_id = $this->sign_up_from_order( $order, $plan );
+		$this->seed_plan_snapshot(
+			$contract_id,
+			array(
+				'selling_plan_id' => $plan->get_id(),
+				'name'            => $plan->get_name(),
+				'billing_policy'  => $snapshot_billing,
+			)
+		);
+		$this->assertInstanceOf(
+			PlanView::class,
+			Plans::update(
+				$plan->get_id(),
+				array(
+					'extension_slug' => self::PLAN_OWNER,
+					'billing_policy' => $live_billing,
+				)
+			)
+		);
+
+		$result   = null;
+		$warnings = $this->capture_engine_log(
+			'warning',
+			array( 'contract_id' => $contract_id ),
+			function () use ( &$result, $contract_id ): void {
+				$result = $this->run_scheduled_renewal( $contract_id );
+			}
+		);
+
+		$this->assertNotEmpty( $warnings, 'An unusable snapshot billing payload is logged.' );
+		$this->assertSame( $expected_next, $this->reload_contract( $contract_id )->get_next_payment_gmt() );
+		if ( null === $expected_next ) {
+			$this->assertNull( $result );
+			$this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ), 'A parked contract bills nothing.' );
+		} else {
+			$this->assertInstanceOf( WC_Order::class, $result, 'The renewal bills on the live cadence.' );
+		}
+	}
+
+	/**
+	 * @return array<string, array{0: array<string, mixed>, 1: array<string, mixed>|null, 2: string|null}>
+	 */
+	public function provide_unusable_snapshot_billing_payloads(): array {
+		$monthly = array(
+			'period'   => 'month',
+			'interval' => 1,
+		);
+		$decade  = array(
+			'period'   => 'decade',
+			'interval' => 1,
+		);
+		$zero    = array(
+			'period'   => 'month',
+			'interval' => 0,
+		);
+
+		return array(
+			'unknown period, live monthly'  => array( $decade, $monthly, '2026-03-15 00:00:00' ),
+			'zero interval, live monthly'   => array( $zero, $monthly, '2026-03-15 00:00:00' ),
+			'unknown period, live unusable' => array( $decade, $zero, null ),
+			'zero interval, live null'      => array( $zero, null, null ),
+		);
+	}
+
 	/**
 	 * @testdox the scheduled scan resumes a stalled renewal whose order was saved but never charged.
 	 *
@@ -851,6 +1018,21 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'status' => $status ), array( 'id' => $contract_id ) );
 	}

+	/**
+	 * Store a plan snapshot row and point the contract at it.
+	 *
+	 * @param int                  $contract_id Stored contract id.
+	 * @param array<string, mixed> $payload     Plan snapshot payload.
+	 */
+	private function seed_plan_snapshot( int $contract_id, array $payload ): void {
+		$contract = $this->reload_contract( $contract_id );
+		$snapshot = PlanSnapshot::from_array( $payload );
+		$contract->set_plan_snapshot_id(
+			( new SnapshotStore() )->insert( $contract_id, SnapshotStore::TYPE_PLAN, $snapshot->get_selling_plan_id(), $snapshot->to_payload(), $snapshot->get_schema_version() )
+		);
+		( new ContractRepository() )->update_fields( $contract, array( 'plan_snapshot_id' ) );
+	}
+
 	/**
 	 * Reload a contract, asserting it still exists.
 	 *
@@ -1802,16 +1984,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 	 * @return Contract The persisted contract with cycle 1 billed.
 	 */
 	private function sign_up_contract_with_line_item( string $gateway, ?array $pricing_policy, $quantity = 2 ): Contract {
-		$plan = Plan::create(
-			array(
-				'name'           => 'Monthly',
-				'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
-				'pricing_policy' => $pricing_policy,
-				'category'       => Plan::DEFAULT_CATEGORY,
-				'extension_slug' => 'engine-tests',
-			)
-		);
-		( new PlanRepository() )->insert( $plan );
+		$plan = $this->plan_view( $this->make_plan( array( 'pricing_policy' => $pricing_policy ) ) );

 		$product = new \WC_Product_Simple();
 		$product->set_name( 'Monthly Filters' );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanMetaTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanMetaTest.php
new file mode 100644
index 00000000000..deb113d6ae2
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanMetaTest.php
@@ -0,0 +1,345 @@
+<?php
+/**
+ * Integration tests for the multi-value plan meta reads and writes on
+ * PlanRepository (WordPress post-meta semantics).
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Storage;
+
+use EngineIntegrationTestCase;
+use InvalidArgumentException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository
+ */
+class PlanMetaTest extends EngineIntegrationTestCase {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var PlanRepository
+	 */
+	private $sut;
+
+	/**
+	 * A stored plan id.
+	 *
+	 * @var int
+	 */
+	private $id;
+
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new PlanRepository();
+		$this->id  = $this->sut->insert(
+			Plan::create(
+				array(
+					'name'           => 'Meta plan',
+					'extension_slug' => 'acme-subs',
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox add_meta keeps every value under one key in order.
+	 */
+	public function test_add_meta_keeps_every_value_under_one_key_in_order(): void {
+		$first  = $this->sut->add_meta( $this->id, 'note', 'one' );
+		$second = $this->sut->add_meta( $this->id, 'note', 'two' );
+
+		$this->assertIsInt( $first );
+		$this->assertIsInt( $second );
+		$this->assertGreaterThan( $first, $second );
+		$this->assertSame( array( 'one', 'two' ), $this->sut->get_meta( $this->id, 'note' ) );
+		$this->assertSame( 'one', $this->sut->get_meta( $this->id, 'note', true ) );
+	}
+
+	/**
+	 * @testdox unique add on an existing key adds nothing.
+	 */
+	public function test_unique_add_on_an_existing_key_adds_nothing(): void {
+		$this->sut->add_meta( $this->id, 'note', 'one' );
+
+		$this->assertNull( $this->sut->add_meta( $this->id, 'note', 'two', true ) );
+		$this->assertSame( array( 'one' ), $this->sut->get_meta( $this->id, 'note' ) );
+	}
+
+	/**
+	 * @testdox update_meta adds the key when absent.
+	 */
+	public function test_update_meta_adds_when_absent(): void {
+		$this->assertTrue( $this->sut->update_meta( $this->id, 'note', 'one' ) );
+		$this->assertSame( array( 'one' ), $this->sut->get_meta( $this->id, 'note' ) );
+	}
+
+	/**
+	 * @testdox update_meta rewrites every row for the key.
+	 */
+	public function test_update_meta_rewrites_every_row_for_the_key(): void {
+		$this->sut->add_meta( $this->id, 'note', 'one' );
+		$this->sut->add_meta( $this->id, 'note', 'two' );
+
+		$this->assertTrue( $this->sut->update_meta( $this->id, 'note', 'three' ) );
+		$this->assertSame( array( 'three', 'three' ), $this->sut->get_meta( $this->id, 'note' ) );
+	}
+
+	/**
+	 * @testdox update_meta with a previous value rewrites only matching rows.
+	 */
+	public function test_update_meta_with_a_previous_value_rewrites_only_matching_rows(): void {
+		$this->sut->add_meta( $this->id, 'note', 'one' );
+		$this->sut->add_meta( $this->id, 'note', 'two' );
+
+		$this->assertTrue( $this->sut->update_meta( $this->id, 'note', 'three', 'two' ) );
+		$this->assertSame( array( 'one', 'three' ), $this->sut->get_meta( $this->id, 'note' ) );
+		$this->assertFalse( $this->sut->update_meta( $this->id, 'note', 'four', 'missing' ) );
+	}
+
+	/**
+	 * @testdox update_meta to the same value reports no change.
+	 */
+	public function test_update_meta_to_the_same_value_reports_no_change(): void {
+		$this->sut->add_meta( $this->id, 'note', 'one' );
+
+		$this->assertFalse( $this->sut->update_meta( $this->id, 'note', 'one' ) );
+		$this->assertSame( array( 'one' ), $this->sut->get_meta( $this->id, 'note' ) );
+	}
+
+	/**
+	 * @testdox delete_meta with a value removes only that row.
+	 */
+	public function test_delete_meta_with_a_value_removes_only_that_row(): void {
+		$this->sut->add_meta( $this->id, 'note', 'one' );
+		$this->sut->add_meta( $this->id, 'note', 'two' );
+
+		$this->assertTrue( $this->sut->delete_meta( $this->id, 'note', 'one' ) );
+		$this->assertSame( array( 'two' ), $this->sut->get_meta( $this->id, 'note' ) );
+	}
+
+	/**
+	 * @testdox delete_meta without a value removes every row for the key.
+	 */
+	public function test_delete_meta_without_a_value_removes_every_row_for_the_key(): void {
+		$this->sut->add_meta( $this->id, 'note', 'one' );
+		$this->sut->add_meta( $this->id, 'note', 'two' );
+		$this->sut->add_meta( $this->id, 'other', 'kept' );
+
+		$this->assertTrue( $this->sut->delete_meta( $this->id, 'note' ) );
+		$this->assertSame( array(), $this->sut->get_meta( $this->id, 'note' ) );
+		$this->assertSame( array( 'kept' ), $this->sut->get_meta( $this->id, 'other' ) );
+		$this->assertFalse( $this->sut->delete_meta( $this->id, 'note' ) );
+	}
+
+	/**
+	 * @testdox get_meta without a key groups every key.
+	 */
+	public function test_get_meta_without_a_key_groups_every_key(): void {
+		$this->sut->add_meta( $this->id, 'note', 'one' );
+		$this->sut->add_meta( $this->id, 'flag', 'yes' );
+		$this->sut->add_meta( $this->id, 'note', 'two' );
+
+		$this->assertSame(
+			array(
+				'note' => array( 'one', 'two' ),
+				'flag' => array( 'yes' ),
+			),
+			$this->sut->get_meta( $this->id )
+		);
+	}
+
+	/**
+	 * @testdox arrays round-trip through serialization.
+	 */
+	public function test_arrays_round_trip_through_serialization(): void {
+		$value = array(
+			'a' => 1,
+			'b' => array( 'c' ),
+		);
+
+		$this->sut->add_meta( $this->id, 'payload', $value );
+
+		$this->assertSame( $value, $this->sut->get_meta( $this->id, 'payload', true ) );
+		$this->assertTrue( $this->sut->delete_meta( $this->id, 'payload', $value ) );
+	}
+
+	/**
+	 * @testdox a missing key reads as empty.
+	 */
+	public function test_missing_key_reads_as_empty(): void {
+		$this->assertSame( '', $this->sut->get_meta( $this->id, 'missing', true ) );
+		$this->assertSame( array(), $this->sut->get_meta( $this->id, 'missing' ) );
+		$this->assertSame( array(), $this->sut->get_meta( $this->id ) );
+	}
+
+	/**
+	 * @testdox meta is scoped to its plan.
+	 */
+	public function test_meta_is_scoped_to_its_plan(): void {
+		$plan  = Plan::create(
+			array(
+				'name'           => 'Other',
+				'extension_slug' => 'acme-subs',
+			)
+		);
+		$other = $this->sut->insert( $plan );
+		$this->sut->add_meta( $other, 'note', 'theirs' );
+
+		$this->assertSame( array(), $this->sut->get_meta( $this->id, 'note' ) );
+	}
+
+	/**
+	 * @testdox meta survives a plan update.
+	 */
+	public function test_meta_survives_a_plan_update(): void {
+		$this->sut->add_meta( $this->id, 'note', 'kept' );
+
+		$plan = $this->sut->find( $this->id );
+		$this->assertInstanceOf( Plan::class, $plan );
+		$plan->set_name( 'Renamed' );
+		$this->sut->update_fields( $plan, array( 'name' ) );
+
+		$this->assertSame( array( 'kept' ), $this->sut->get_meta( $this->id, 'note' ) );
+	}
+
+	/**
+	 * @testdox delete removes the plan's meta.
+	 */
+	public function test_delete_removes_the_plan_meta(): void {
+		$this->sut->add_meta( $this->id, 'note', 'gone' );
+
+		$this->assertTrue( $this->sut->delete( $this->id ) );
+		$this->assertSame( array(), $this->sut->get_meta( $this->id ) );
+	}
+
+	/**
+	 * @testdox delete throws when the meta rows fail to delete, so a caller transaction can roll back.
+	 */
+	public function test_delete_throws_when_the_meta_delete_fails(): void {
+		$this->assert_write_throws_on_a_failed_query(
+			'DELETE FROM `' . SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLAN_META ) . '`',
+			'Failed to delete meta rows',
+			function (): void {
+				$this->sut->delete( $this->id );
+			}
+		);
+	}
+
+	/**
+	 * @testdox delete throws when the plan row fails to delete, and keeps the plan and its meta.
+	 */
+	public function test_delete_throws_when_the_plan_row_delete_fails(): void {
+		$this->sut->add_meta( $this->id, 'note', 'kept' );
+
+		$this->assert_write_throws_on_a_failed_query(
+			'DELETE FROM `' . SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS ) . '`',
+			'Failed to delete plan ' . $this->id,
+			function (): void {
+				$this->sut->delete( $this->id );
+			}
+		);
+
+		$this->assertNotNull( $this->sut->find( $this->id ) );
+		$this->assertSame( array( 'kept' ), $this->sut->get_meta( $this->id, 'note' ) );
+	}
+
+	/**
+	 * @testdox a failed meta write throws.
+	 * @dataProvider provide_failing_meta_writes
+	 *
+	 * @param string            $statement Start of the failing SQL statement.
+	 * @param string            $message   Expected message fragment.
+	 * @param string            $method    Repository method.
+	 * @param array<int, mixed> $args      Arguments after the plan id.
+	 */
+	public function test_a_failed_meta_write_throws( string $statement, string $message, string $method, array $args ): void {
+		$this->sut->add_meta( $this->id, 'existing', 'one' );
+
+		$this->assert_write_throws_on_a_failed_query(
+			$statement . ' `' . SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLAN_META ) . '`',
+			$message,
+			function () use ( $method, $args ): void {
+				$this->sut->{$method}( $this->id, ...$args );
+			}
+		);
+	}
+
+	/**
+	 * @return array<string, array{0: string, 1: string, 2: string, 3: array<int, mixed>}>
+	 */
+	public function provide_failing_meta_writes(): array {
+		return array(
+			'add'    => array( 'INSERT INTO', 'Failed to add plan meta', 'add_meta', array( 'note', 'x' ) ),
+			'update' => array( 'UPDATE', 'Failed to update plan meta', 'update_meta', array( 'existing', 'two' ) ),
+			'delete' => array( 'DELETE FROM', 'Failed to delete plan meta', 'delete_meta', array( 'existing' ) ),
+		);
+	}
+
+	/**
+	 * Break the queries starting with `$statement` and assert `$write` throws.
+	 *
+	 * @param string   $statement Start of the SQL statement to break.
+	 * @param string   $message   Expected message fragment.
+	 * @param callable $write     Write to run.
+	 */
+	private function assert_write_throws_on_a_failed_query( string $statement, string $message, callable $write ): void {
+		global $wpdb;
+
+		$break = static function ( string $query ) use ( $statement ): string {
+			return 0 === strpos( $query, $statement ) ? 'SELECT broken syntax (' : $query;
+		};
+		add_filter( 'query', $break );
+		$suppressed = $wpdb->suppress_errors( true );
+
+		try {
+			$write();
+			$this->fail( 'Expected the write to throw.' );
+		} catch ( \RuntimeException $e ) {
+			$this->assertStringContainsString( $message, $e->getMessage() );
+		} finally {
+			$wpdb->suppress_errors( $suppressed );
+			remove_filter( 'query', $break );
+		}
+	}
+
+	/**
+	 * @testdox a delete under another extension slug keeps the plan and its meta.
+	 */
+	public function test_a_delete_under_another_extension_slug_keeps_the_plan_and_its_meta(): void {
+		$this->sut->add_meta( $this->id, 'note', 'kept' );
+
+		$this->assertFalse( $this->sut->delete( $this->id, 'another-extension' ) );
+		$this->assertNotNull( $this->sut->find( $this->id ) );
+		$this->assertSame( array( 'kept' ), $this->sut->get_meta( $this->id, 'note' ) );
+	}
+
+	/**
+	 * @testdox an empty key is rejected on writes.
+	 * @dataProvider provide_write_methods
+	 *
+	 * @param string $method Write method name.
+	 */
+	public function test_an_empty_key_is_rejected_on_writes( string $method ): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		$this->sut->{$method}( $this->id, '', 'value' );
+	}
+
+	/**
+	 * @return array<string, array{0: string}>
+	 */
+	public function provide_write_methods(): array {
+		return array(
+			'add'    => array( 'add_meta' ),
+			'update' => array( 'update_meta' ),
+			'delete' => array( 'delete_meta' ),
+		);
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanRepositoryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanRepositoryTest.php
index 36aed54bb14..68c1f649d60 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanRepositoryTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanRepositoryTest.php
@@ -11,7 +11,7 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integrati

 use EngineIntegrationTestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;

 /**
@@ -19,27 +19,53 @@ use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepositor
  */
 class PlanRepositoryTest extends EngineIntegrationTestCase {

-	private function make_plan( PlanRepository $repo, string $name, string $extension_slug, int $sort_order = 0 ): int {
+	/**
+	 * Insert a plan with a monthly billing payload.
+	 *
+	 * @param PlanRepository       $repo           Repository.
+	 * @param string               $name           Plan name.
+	 * @param string|null          $extension_slug Owner slug.
+	 * @param array<string, mixed> $args           Extra Plan::create() args.
+	 */
+	private function insert_plan( PlanRepository $repo, string $name, ?string $extension_slug = 'lite', array $args = array() ): int {
 		return $repo->insert(
 			Plan::create(
-				array(
-					'name'           => $name,
-					'billing_policy' => BillingPolicy::from_array(
-						array(
+				array_merge(
+					array(
+						'name'           => $name,
+						'billing_policy' => array(
 							'period'   => 'month',
 							'interval' => 1,
-						)
+						),
+						'extension_slug' => $extension_slug,
 					),
-					'extension_slug' => $extension_slug,
-					'sort_order'     => $sort_order,
+					$args
 				)
 			)
 		);
 	}

-	public function test_plan_round_trips_with_policies_and_extension_slug(): void {
-		$repo           = new PlanRepository();
-		$pricing_policy = array(
+	/**
+	 * Ids of a plan list.
+	 *
+	 * @param array<int, Plan> $plans Plans.
+	 * @return array<int, int|null>
+	 */
+	private static function ids( array $plans ): array {
+		return array_map( static fn ( Plan $plan ): ?int => $plan->get_id(), $plans );
+	}
+
+	/**
+	 * @testdox a plan round-trips all three opaque policies.
+	 */
+	public function test_plan_round_trips_all_three_opaque_policies(): void {
+		$repo     = new PlanRepository();
+		$billing  = array(
+			'period'         => 'fortnight',
+			'interval'       => 1,
+			'trial_duration' => array( 'unit' => 'day' ),
+		);
+		$pricing  = array(
 			'policies'   => array(
 				array(
 					'type'  => 'percentage',
@@ -48,22 +74,19 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
 			),
 			'custom_key' => array( 'nested' => '1.50' ),
 		);
+		$delivery = array(
+			'anchor' => array( 'day' => 3 ),
+			'note'   => 'opaque',
+		);

 		$plan = Plan::create(
 			array(
-				'name'           => 'Monthly',
-				'description'    => 'A monthly plan',
-				'billing_policy' => BillingPolicy::from_array(
-					array(
-						'period'     => 'month',
-						'interval'   => 1,
-						'max_cycles' => 12,
-					)
-				),
-				'pricing_policy' => $pricing_policy,
-				'status'         => Plan::STATUS_ARCHIVED,
-				'sort_order'     => 4,
-				'extension_slug' => 'lite',
+				'name'            => 'Monthly',
+				'billing_policy'  => $billing,
+				'pricing_policy'  => $pricing,
+				'delivery_policy' => $delivery,
+				'status'          => PlanStatus::ARCHIVED,
+				'extension_slug'  => 'lite',
 			)
 		);

@@ -75,238 +98,282 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {

 		$this->assertInstanceOf( Plan::class, $fetched );
 		$this->assertSame( 'Monthly', $fetched->get_name() );
-		$this->assertSame( 'A monthly plan', $fetched->get_description() );
 		$this->assertSame( 'lite', $fetched->get_extension_slug() );
-		$this->assertSame( Plan::STATUS_ARCHIVED, $fetched->get_status() );
-		$this->assertSame( 4, $fetched->get_sort_order() );
-		$this->assertSame( 'month', $fetched->get_billing_policy()->get_period() );
-		$this->assertSame( 12, $fetched->get_billing_policy()->get_max_cycles() );
-		$this->assertSame( $pricing_policy, $fetched->get_pricing_policy() );
+		$this->assertSame( PlanStatus::ARCHIVED, $fetched->get_status() );
+		$this->assertSame( $billing, $fetched->get_billing_policy() );
+		$this->assertSame( $pricing, $fetched->get_pricing_policy() );
+		$this->assertSame( $delivery, $fetched->get_delivery_policy() );
+		$this->assertNotNull( $fetched->get_date_created_gmt() );
+		$this->assertNotNull( $fetched->get_date_updated_gmt() );
 	}

-	public function test_plan_without_optional_policies_round_trips(): void {
+	/**
+	 * @testdox a plan without policies round-trips with null billing.
+	 */
+	public function test_plan_without_policies_round_trips_with_null_billing(): void {
 		$repo = new PlanRepository();

-		$id = $repo->insert(
-			Plan::create(
-				array(
-					'name'           => 'Bare',
-					'billing_policy' => BillingPolicy::from_array(
-						array(
-							'period'   => 'week',
-							'interval' => 2,
-						)
-					),
-				)
+		$plan = Plan::create(
+			array(
+				'name'           => 'Bare',
+				'extension_slug' => 'lite',
 			)
 		);
+		$id   = $repo->insert( $plan );

 		$fetched = $repo->find( $id );

 		$this->assertInstanceOf( Plan::class, $fetched );
+		$this->assertNull( $fetched->get_billing_policy() );
 		$this->assertNull( $fetched->get_pricing_policy() );
 		$this->assertNull( $fetched->get_delivery_policy() );
-		$this->assertNull( $fetched->get_extension_slug() );
 	}

-	public function test_merchant_code_round_trips_through_insert_and_find(): void {
+	/**
+	 * @testdox update_fields persists the name, status and policies and bumps only the update time.
+	 */
+	public function test_update_fields_persists_name_status_and_policies_and_bumps_only_the_updated_date(): void {
+		global $wpdb;
+
 		$repo = new PlanRepository();
+		$id   = $this->insert_plan( $repo, 'Before' );

-		$id = $repo->insert(
-			Plan::create(
-				array(
-					'name'           => 'Coded',
-					'billing_policy' => BillingPolicy::from_array(
-						array(
-							'period'   => 'month',
-							'interval' => 1,
-						)
-					),
-					'merchant_code'  => 'coffee-club',
-				)
-			)
-		);
+		$table = $wpdb->prefix . 'wc_selling_plans';
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$wpdb->query( $wpdb->prepare( "UPDATE {$table} SET date_created_gmt = %s, date_updated_gmt = %s WHERE id = %d", '2020-01-01 00:00:00', '2020-01-01 00:00:00', $id ) );

-		$fetched = $repo->find( $id );
+		$plan = $repo->find( $id );
+		$this->assertInstanceOf( Plan::class, $plan );

-		$this->assertInstanceOf( Plan::class, $fetched );
-		$this->assertSame( 'coffee-club', $fetched->get_merchant_code() );
-	}
+		$plan->set_name( 'After' );
+		$plan->set_status( PlanStatus::ARCHIVED );
+		$plan->set_billing_policy( array( 'period' => 'week' ) );
+		$plan->set_pricing_policy( array( 'policies' => array() ) );
+		$plan->set_delivery_policy( array( 'x' => 1 ) );
+		$this->assertTrue( $repo->update_fields( $plan, array( 'name', 'status', 'billing_policy', 'pricing_policy', 'delivery_policy' ) ) );
+		$this->assertNotSame( '2020-01-01 00:00:00', $plan->get_date_updated_gmt(), 'The update time is stamped back onto the entity.' );

-	public function test_duplicate_merchant_code_insert_throws_within_one_extension(): void {
-		$repo = new PlanRepository();
+		$updated = $repo->find( $id );
+		$this->assertInstanceOf( Plan::class, $updated );
+		$this->assertSame( 'After', $updated->get_name() );
+		$this->assertSame( PlanStatus::ARCHIVED, $updated->get_status() );
+		$this->assertSame( array( 'period' => 'week' ), $updated->get_billing_policy() );
+		$this->assertSame( array( 'policies' => array() ), $updated->get_pricing_policy() );
+		$this->assertSame( array( 'x' => 1 ), $updated->get_delivery_policy() );
+		$this->assertSame( '2020-01-01 00:00:00', $updated->get_date_created_gmt() );
+		$this->assertNotSame( '2020-01-01 00:00:00', $updated->get_date_updated_gmt() );
+
+		$updated->set_billing_policy( null );
+		$repo->update_fields( $updated, array( 'billing_policy' ) );
+		$cleared = $repo->find( $id );
+		$this->assertInstanceOf( Plan::class, $cleared );
+		$this->assertNull( $cleared->get_billing_policy() );
+	}

-		$make = static function ( string $extension_slug ): Plan {
-			return Plan::create(
-				array(
-					'name'           => 'Duplicate code',
-					'billing_policy' => BillingPolicy::from_array(
-						array(
-							'period'   => 'month',
-							'interval' => 1,
-						)
-					),
-					'merchant_code'  => 'dupe-code',
-					'extension_slug' => $extension_slug,
-				)
-			);
-		};
+	/**
+	 * @testdox update_fields on a deleted plan returns false.
+	 */
+	public function test_update_fields_returns_false_for_a_deleted_plan(): void {
+		$repo  = new PlanRepository();
+		$stale = $repo->find( $this->insert_plan( $repo, 'Gone' ) );
+		$this->assertInstanceOf( Plan::class, $stale );
+		$this->assertTrue( $repo->delete( (int) $stale->get_id() ) );

-		$repo->insert( $make( 'lite' ) );
+		$stale->set_name( 'Renamed' );

-		$this->expectException( \RuntimeException::class );
-		$repo->insert( $make( 'lite' ) );
+		$this->assertFalse( $repo->update_fields( $stale, array( 'name' ) ) );
 	}

-	public function test_same_merchant_code_coexists_across_extensions(): void {
+	/**
+	 * @testdox update_fields writing identical values (no changed rows) returns true.
+	 */
+	public function test_update_fields_with_identical_values_returns_true(): void {
 		$repo = new PlanRepository();
+		$plan = $repo->find( $this->insert_plan( $repo, 'Same' ) );
+		$this->assertInstanceOf( Plan::class, $plan );

-		$make = static function ( string $extension_slug ): Plan {
-			return Plan::create(
-				array(
-					'name'           => 'Shared code',
-					'billing_policy' => BillingPolicy::from_array(
-						array(
-							'period'   => 'month',
-							'interval' => 1,
-						)
-					),
-					'merchant_code'  => 'monthly-box',
-					'extension_slug' => $extension_slug,
-				)
-			);
-		};
+		// The first write may bump the update time; the second, within the same second, changes no row.
+		$this->assertTrue( $repo->update_fields( $plan, array( 'name' ) ) );
+		$this->assertTrue( $repo->update_fields( $plan, array( 'name' ) ) );
+	}

-		$first_id  = $repo->insert( $make( 'lite' ) );
-		$second_id = $repo->insert( $make( 'other-extension' ) );
+	/**
+	 * @testdox find scoped to an extension slug reads only that extension's plan.
+	 */
+	public function test_find_scoped_to_an_extension_slug_reads_only_that_extensions_plan(): void {
+		$repo = new PlanRepository();
+		$id   = $this->insert_plan( $repo, 'Owned' );

-		$this->assertGreaterThan( 0, $first_id );
-		$this->assertGreaterThan( $first_id, $second_id );
+		$this->assertInstanceOf( Plan::class, $repo->find( $id, 'lite' ) );
+		$this->assertNull( $repo->find( $id, 'other-extension' ), 'A plan of another extension reads as missing.' );
+		$this->assertInstanceOf( Plan::class, $repo->find( $id ), 'An unscoped read finds a plan of any extension.' );
 	}

-	public function test_plans_without_merchant_code_coexist(): void {
+	/**
+	 * @testdox update_fields writes nothing and returns false when the row belongs to another extension.
+	 */
+	public function test_update_fields_writes_nothing_to_a_row_of_another_extension(): void {
+		global $wpdb;
+
 		$repo = new PlanRepository();
+		$id   = $this->insert_plan( $repo, 'Original' );
+		$plan = $repo->find( $id );
+		$this->assertInstanceOf( Plan::class, $plan );

-		$first_id  = $this->make_plan( $repo, 'First uncoded', 'lite' );
-		$second_id = $this->make_plan( $repo, 'Second uncoded', 'lite' );
+		// The row moves to another extension after the entity was read.
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$wpdb->update( $wpdb->prefix . 'wc_selling_plans', array( 'extension_slug' => 'other-extension' ), array( 'id' => $id ) );
+		$before = $repo->find( $id );
+		$this->assertInstanceOf( Plan::class, $before );

-		$this->assertGreaterThan( 0, $first_id );
-		$this->assertGreaterThan( $first_id, $second_id );
+		// Identical values: the row exists by id, but not for this extension, so it is not "unchanged".
+		$this->assertFalse( $repo->update_fields( $plan, array( 'name' ) ) );

-		$first = $repo->find( $first_id );
-		$this->assertInstanceOf( Plan::class, $first );
-		$this->assertNull( $first->get_merchant_code() );
-	}
+		$plan->set_name( 'Renamed' );
+		$this->assertFalse( $repo->update_fields( $plan, array( 'name' ) ) );

-	public function test_update_persists_changes(): void {
-		$repo = new PlanRepository();
+		$after = $repo->find( $id );
+		$this->assertInstanceOf( Plan::class, $after );
+		$this->assertSame( $before->to_storage(), $after->to_storage() );
+	}

+	/**
+	 * @testdox update_fields without an id throws.
+	 */
+	public function test_update_fields_without_an_id_throws(): void {
 		$plan = Plan::create(
 			array(
-				'name'           => 'Before',
-				'billing_policy' => BillingPolicy::from_array(
-					array(
-						'period'   => 'month',
-						'interval' => 1,
-					)
-				),
+				'name'           => 'Unsaved',
+				'extension_slug' => 'lite',
 			)
 		);
-		$id   = $repo->insert( $plan );

-		$plan->set_name( 'After' );
-		$plan->set_status( Plan::STATUS_ARCHIVED );
-		$plan->set_sort_order( 8 );
-		$this->assertTrue( $repo->update( $plan ) );
+		$this->expectException( \RuntimeException::class );

-		$updated = $repo->find( $id );
-		$this->assertInstanceOf( Plan::class, $updated );
-		$this->assertSame( 'After', $updated->get_name() );
-		$this->assertSame( Plan::STATUS_ARCHIVED, $updated->get_status() );
-		$this->assertSame( 8, $updated->get_sort_order() );
+		( new PlanRepository() )->update_fields( $plan, array( 'name' ) );
 	}

-	public function test_query_count_and_reorder_use_plan_lifecycle_fields(): void {
+	/**
+	 * @testdox update_fields writes only the named columns.
+	 */
+	public function test_update_fields_writes_only_the_named_columns(): void {
 		$repo = new PlanRepository();
+		$id   = $this->insert_plan( $repo, 'Original' );

-		$first    = Plan::create(
-			array(
-				'name'           => 'Alpha monthly',
-				'billing_policy' => BillingPolicy::from_array(
-					array(
-						'period'   => 'month',
-						'interval' => 1,
-					)
-				),
-				'status'         => Plan::STATUS_ACTIVE,
-				'sort_order'     => 1,
-				'extension_slug' => 'lite',
-			)
-		);
-		$second   = Plan::create(
-			array(
-				'name'           => 'Beta weekly',
-				'billing_policy' => BillingPolicy::from_array(
-					array(
-						'period'   => 'week',
-						'interval' => 1,
-					)
-				),
-				'status'         => Plan::STATUS_ACTIVE,
-				'sort_order'     => 2,
-				'extension_slug' => 'lite',
-			)
-		);
-		$archived = Plan::create(
-			array(
-				'name'           => 'Archived yearly',
-				'billing_policy' => BillingPolicy::from_array(
-					array(
-						'period'   => 'year',
-						'interval' => 1,
-					)
-				),
-				'status'         => Plan::STATUS_ARCHIVED,
-				'sort_order'     => 3,
-				'extension_slug' => 'lite',
-			)
-		);
+		$stale = $repo->find( $id );
+		$this->assertInstanceOf( Plan::class, $stale );
+
+		$other = $repo->find( $id );
+		$this->assertInstanceOf( Plan::class, $other );
+		$other->set_name( 'Renamed elsewhere' );
+		$repo->update_fields( $other, array( 'name' ) );
+
+		$stale->set_status( PlanStatus::ARCHIVED );
+		$repo->update_fields( $stale, array( 'status' ) );

-		$first_id    = $repo->insert( $first );
-		$second_id   = $repo->insert( $second );
-		$archived_id = $repo->insert( $archived );
+		$stored = $repo->find( $id );
+		$this->assertInstanceOf( Plan::class, $stored );
+		$this->assertSame( 'Renamed elsewhere', $stored->get_name() );
+		$this->assertSame( PlanStatus::ARCHIVED, $stored->get_status() );
+	}
+
+	/**
+	 * @testdox update_fields refuses a field that is not writable.
+	 * @testWith ["extension_slug"]
+	 *           ["sort_order"]
+	 *
+	 * @param string $field Field that is not a writable column.
+	 */
+	public function test_update_fields_refuses_a_field_that_is_not_writable( string $field ): void {
+		$repo = new PlanRepository();
+		$plan = $repo->find( $this->insert_plan( $repo, 'Guarded' ) );
+		$this->assertInstanceOf( Plan::class, $plan );
+
+		$this->expectException( \InvalidArgumentException::class );
+
+		$repo->update_fields( $plan, array( $field ) );
+	}
+
+	/**
+	 * @testdox query and count filter by status and search.
+	 */
+	public function test_query_and_count_filter_by_status_and_search(): void {
+		$repo = new PlanRepository();
+
+		$this->insert_plan( $repo, 'Alpha monthly' );
+		$second_id = $this->insert_plan( $repo, 'Beta weekly' );
+		$this->insert_plan( $repo, 'Archived yearly', 'lite', array( 'status' => PlanStatus::ARCHIVED ) );

 		$active = $repo->query(
 			array(
-				'status' => Plan::STATUS_ACTIVE,
+				'status' => PlanStatus::ACTIVE,
 				'search' => 'weekly',
 			)
 		);

-		$this->assertCount( 1, $active );
-		$this->assertSame( $second_id, $active[0]->get_id() );
-		$this->assertSame( 1, $repo->count( array( 'status' => Plan::STATUS_ARCHIVED ) ) );
+		$this->assertSame( array( $second_id ), self::ids( $active ) );
+		$this->assertSame( 2, $repo->count( array( 'status' => PlanStatus::ACTIVE ) ) );
+		$this->assertSame( 1, $repo->count( array( 'status' => PlanStatus::ARCHIVED ) ) );
+	}

-		$this->assertTrue(
-			$repo->reorder(
-				'lite',
-				array(
-					$first_id    => 9,
-					$second_id   => 1,
-					$archived_id => 2,
+	/**
+	 * @testdox query accepts a status list.
+	 */
+	public function test_query_status_accepts_a_list(): void {
+		$repo = new PlanRepository();
+
+		$active_id   = $this->insert_plan( $repo, 'Active' );
+		$archived_id = $this->insert_plan( $repo, 'Archived', 'lite', array( 'status' => PlanStatus::ARCHIVED ) );
+
+		$args = array( 'status' => array( PlanStatus::ACTIVE, PlanStatus::ARCHIVED ) );
+
+		$this->assertSame( array( $active_id, $archived_id ), self::ids( $repo->query( $args ) ) );
+		$this->assertSame( 2, $repo->count( $args ) );
+		$this->assertSame( array( $archived_id ), self::ids( $repo->query( array( 'status' => array( PlanStatus::ARCHIVED ) ) ) ) );
+	}
+
+	/**
+	 * @testdox query matches nothing for an empty or invalid status list.
+	 */
+	public function test_query_empty_or_invalid_status_list_matches_nothing(): void {
+		$repo = new PlanRepository();
+		$this->insert_plan( $repo, 'Active' );
+
+		$this->assertCount( 0, $repo->query( array( 'status' => array() ) ) );
+		$this->assertSame( 0, $repo->count( array( 'status' => array() ) ) );
+		$this->assertCount( 0, $repo->query( array( 'status' => array( PlanStatus::ACTIVE, 5 ) ) ) );
+		$this->assertCount( 0, $repo->query( array( 'status' => '' ) ) );
+		$this->assertCount( 1, $repo->query( array( 'status' => null ) ) );
+	}
+
+	/**
+	 * @testdox query defaults to id order and sorts by name.
+	 */
+	public function test_query_defaults_to_id_order_and_sorts_by_name(): void {
+		$repo = new PlanRepository();
+
+		$charlie = $this->insert_plan( $repo, 'Charlie' );
+		$alpha   = $this->insert_plan( $repo, 'Alpha' );
+		$bravo   = $this->insert_plan( $repo, 'Bravo' );
+
+		$this->assertSame( array( $charlie, $alpha, $bravo ), self::ids( $repo->query() ) );
+		$this->assertSame( array( $alpha, $bravo, $charlie ), self::ids( $repo->query( array( 'orderby' => 'name' ) ) ) );
+		$this->assertSame(
+			array( $charlie, $bravo, $alpha ),
+			self::ids(
+				$repo->query(
+					array(
+						'orderby' => 'name',
+						'order'   => 'desc',
+					)
 				)
 			)
 		);
-
-		$ordered = $repo->query(
-			array(
-				'orderby' => 'sort_order',
-				'order'   => 'asc',
-				'limit'   => 3,
-			)
+		$this->assertSame(
+			array( $bravo, $alpha, $charlie ),
+			self::ids( $repo->query( array( 'order' => 'desc' ) ) )
 		);
-
-		$this->assertSame( array( $second_id, $archived_id, $first_id ), array_map( static fn ( Plan $plan ): ?int => $plan->get_id(), $ordered ) );
+		$this->assertSame( array( $charlie, $alpha, $bravo ), self::ids( $repo->query( array( 'orderby' => 'status' ) ) ), 'An unknown orderby falls back to id.' );
 	}

 	/**
@@ -332,12 +399,12 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
 	public function test_query_search_terms_starting_with_prepare_specifiers( string $search ): void {
 		$repo = new PlanRepository();

-		$this->make_plan( $repo, 'Unrelated prepare regression plan', 'lite' );
-		$expected_id = $this->make_plan( $repo, $search . ' plan', 'lite' );
+		$this->insert_plan( $repo, 'Unrelated prepare regression plan', 'lite' );
+		$expected_id = $this->insert_plan( $repo, $search . ' plan', 'lite' );

 		$query_args = array(
 			'extension_slugs' => array( 'lite' ),
-			'status'          => Plan::STATUS_ACTIVE,
+			'status'          => PlanStatus::ACTIVE,
 			'search'          => $search,
 			'orderby'         => 'id',
 			'order'           => 'asc',
@@ -355,9 +422,8 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
 	public function test_invalid_extension_scopes_do_not_return_unscoped_results(): void {
 		$repo = new PlanRepository();

-		$id = $this->make_plan( $repo, 'Scoped', 'lite' );
+		$this->insert_plan( $repo, 'Scoped', 'lite' );

-		$this->assertInstanceOf( Plan::class, $repo->find( $id, 'any' ) );
 		// Test with extension_slugs array.
 		$this->assertCount( 1, $repo->query( array( 'extension_slugs' => array( 'any' ) ) ) );
 		$this->assertSame( 1, $repo->count( array( 'extension_slugs' => array( 'any' ) ) ) );
@@ -365,8 +431,6 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
 		$this->assertCount( 1, $repo->query( array( 'extension_slugs' => null ) ) );
 		$this->assertSame( 1, $repo->count( array( 'extension_slugs' => null ) ) );

-		$this->assertNull( $repo->find( $id, '' ) );
-		$this->assertNull( $repo->find( $id, 'bad slug' ) );
 		$this->assertCount( 0, $repo->query( array( 'extension_slugs' => array() ) ) );
 		$this->assertSame( 0, $repo->count( array( 'extension_slugs' => array() ) ) );
 		$this->assertCount( 0, $repo->query( array( 'extension_slugs' => array( '' ) ) ) );
@@ -378,8 +442,8 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
 	public function test_query_extension_slugs_filters_by_single_and_multiple_slugs(): void {
 		$repo = new PlanRepository();

-		$lite_id  = $this->make_plan( $repo, 'Lite plan', 'lite', 1 );
-		$other_id = $this->make_plan( $repo, 'Other plan', 'other-extension', 2 );
+		$lite_id  = $this->insert_plan( $repo, 'Lite plan', 'lite' );
+		$other_id = $this->insert_plan( $repo, 'Other plan', 'other-extension' );

 		$single = $repo->query( array( 'extension_slugs' => array( 'lite' ) ) );
 		$this->assertSame( array( $lite_id ), array_map( static fn ( Plan $plan ): ?int => $plan->get_id(), $single ) );
@@ -392,57 +456,19 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
 	public function test_query_singular_extension_slug_arg_is_unknown_and_ignored(): void {
 		$repo = new PlanRepository();

-		$plan_id = $this->make_plan( $repo, 'Scoped', 'lite' );
+		$plan_id = $this->insert_plan( $repo, 'Scoped', 'lite' );

 		$plans = $repo->query( array( 'extension_slug' => 'other-extension' ) );
 		$this->assertSame( array( $plan_id ), array_map( static fn ( Plan $plan ): ?int => $plan->get_id(), $plans ) );
 		$this->assertSame( 1, $repo->count( array( 'extension_slug' => '' ) ) );
 	}

-	public function test_reorder_fails_before_updates_when_an_id_is_missing_or_outside_extension(): void {
-		$repo = new PlanRepository();
-
-		$first_id = $this->make_plan( $repo, 'First', 'lite', 1 );
-		$other_id = $this->make_plan( $repo, 'Other', 'other-extension', 2 );
-
-		$this->assertFalse(
-			$repo->reorder(
-				'lite',
-				array(
-					$first_id => 9,
-					999999    => 1,
-				)
-			)
-		);
-
-		$first = $repo->find( $first_id, 'lite' );
-		$this->assertInstanceOf( Plan::class, $first );
-		$this->assertSame( 1, $first->get_sort_order() );
-
-		$this->assertFalse(
-			$repo->reorder(
-				'lite',
-				array(
-					$first_id => 9,
-					$other_id => 1,
-				)
-			)
-		);
-
-		$first = $repo->find( $first_id, 'lite' );
-		$other = $repo->find( $other_id, 'other-extension' );
-		$this->assertInstanceOf( Plan::class, $first );
-		$this->assertInstanceOf( Plan::class, $other );
-		$this->assertSame( 1, $first->get_sort_order() );
-		$this->assertSame( 2, $other->get_sort_order() );
-	}
-
 	public function test_query_ids_returns_only_those_plans(): void {
 		$repo = new PlanRepository();

-		$first_plan_id  = $this->make_plan( $repo, 'First', 'lite', 1 );
-		$second_plan_id = $this->make_plan( $repo, 'Second', 'lite', 2 );
-		$this->make_plan( $repo, 'Third', 'lite', 3 );
+		$first_plan_id  = $this->insert_plan( $repo, 'First', 'lite' );
+		$second_plan_id = $this->insert_plan( $repo, 'Second', 'lite' );
+		$this->insert_plan( $repo, 'Third', 'lite' );

 		$plans = $repo->query( array( 'ids' => array( $first_plan_id, $second_plan_id ) ) );

@@ -453,17 +479,17 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
 	public function test_query_ids_composes_with_status_and_extension_slugs(): void {
 		$repo = new PlanRepository();

-		$active_id  = $this->make_plan( $repo, 'Active lite', 'lite', 1 );
-		$foreign_id = $this->make_plan( $repo, 'Other extension', 'other-extension', 2 );
+		$active_id  = $this->insert_plan( $repo, 'Active lite', 'lite' );
+		$foreign_id = $this->insert_plan( $repo, 'Other extension', 'other-extension' );

-		$archived = $repo->find( $this->make_plan( $repo, 'Archived lite', 'lite', 3 ) );
+		$archived = $repo->find( $this->insert_plan( $repo, 'Archived lite', 'lite' ) );
 		$this->assertInstanceOf( Plan::class, $archived );
-		$archived->set_status( Plan::STATUS_ARCHIVED );
-		$this->assertTrue( $repo->update( $archived ) );
+		$archived->set_status( PlanStatus::ARCHIVED );
+		$repo->update_fields( $archived, array( 'status' ) );

 		$plans = $repo->query(
 			array(
-				'status'          => Plan::STATUS_ACTIVE,
+				'status'          => PlanStatus::ACTIVE,
 				'extension_slugs' => array( 'lite' ),
 				'ids'             => array( $active_id, $foreign_id, (int) $archived->get_id() ),
 			)
@@ -475,7 +501,7 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {

 	public function test_query_empty_or_invalid_ids_match_nothing(): void {
 		$repo    = new PlanRepository();
-		$plan_id = $this->make_plan( $repo, 'Plan', 'lite' );
+		$plan_id = $this->insert_plan( $repo, 'Plan', 'lite' );

 		$this->assertCount( 0, $repo->query( array( 'ids' => array() ) ) );
 		$this->assertSame( 0, $repo->count( array( 'ids' => array() ) ) );
@@ -487,8 +513,8 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
 	public function test_query_null_ids_behaves_as_arg_absent(): void {
 		$repo = new PlanRepository();

-		$first_plan_id  = $this->make_plan( $repo, 'First', 'lite', 1 );
-		$second_plan_id = $this->make_plan( $repo, 'Second', 'lite', 2 );
+		$first_plan_id  = $this->insert_plan( $repo, 'First', 'lite' );
+		$second_plan_id = $this->insert_plan( $repo, 'Second', 'lite' );

 		$plans = $repo->query( array( 'ids' => null ) );

@@ -499,19 +525,7 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
 	public function test_delete_removes_the_row(): void {
 		$repo = new PlanRepository();

-		$id = $repo->insert(
-			Plan::create(
-				array(
-					'name'           => 'Doomed',
-					'billing_policy' => BillingPolicy::from_array(
-						array(
-							'period'   => 'month',
-							'interval' => 1,
-						)
-					),
-				)
-			)
-		);
+		$id = $this->insert_plan( $repo, 'Doomed' );

 		$this->assertTrue( $repo->delete( $id ) );
 		$this->assertNull( $repo->find( $id ) );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php
index bb9251991f9..2ac69c8ffd2 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php
@@ -26,6 +26,7 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
 	public function table_provider(): array {
 		return array(
 			array( SchemaInstaller::TABLE_PLANS ),
+			array( SchemaInstaller::TABLE_PLAN_META ),
 			array( SchemaInstaller::TABLE_CONTRACTS ),
 			array( SchemaInstaller::TABLE_CONTRACT_ITEMS ),
 			array( SchemaInstaller::TABLE_CONTRACT_ADDRESSES ),
@@ -101,18 +102,56 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
 		$this->assertSame( 'extension_slug', $column );
 	}

-	public function test_plans_table_has_status_and_sort_order_columns(): void {
+	/**
+	 * @testdox The plans table keeps status, drops the retired columns, and has a nullable billing_policy.
+	 */
+	public function test_plans_table_columns_after_the_records_rework(): void {
 		global $wpdb;

 		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );

+		$this->assertTrue( $this->has_column( $table, 'status' ), 'Expected plans.status.' );
+
+		foreach ( array( 'description', 'category', 'sort_order', 'merchant_code', 'inventory_policy' ) as $column ) {
+			$this->assertFalse( $this->has_column( $table, $column ), "Did not expect plans.{$column}." );
+		}
+
 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-		$status = $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'status' ) );
-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-		$sort_order = $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'sort_order' ) );
+		$row = $wpdb->get_row( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'billing_policy' ), ARRAY_A );
+
+		$this->assertIsArray( $row, 'Expected a plans.billing_policy column.' );
+		$this->assertSame( 'YES', $row['Null'] ?? null, 'Expected plans.billing_policy to be NULLable.' );
+	}
+
+	/**
+	 * @testdox The plans table is indexed by (extension_slug, status) and carries no retired indexes.
+	 */
+	public function test_plans_table_indexes(): void {
+		$table   = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
+		$indexes = $this->index_names( $table );

-		$this->assertSame( 'status', $status );
-		$this->assertSame( 'sort_order', $sort_order );
+		$this->assertContains( 'extension_status', $indexes );
+		$this->assertSame( array( 'extension_slug', 'status' ), $this->index_columns( $table, 'extension_status' ) );
+
+		foreach ( array( 'category', 'status_sort', 'extension_merchant_code' ) as $index ) {
+			$this->assertNotContains( $index, $indexes, "Did not expect the plans {$index} index." );
+		}
+	}
+
+	/**
+	 * @testdox Plan meta carries the HPOS-style key/value indexes.
+	 */
+	public function test_plan_meta_has_hpos_style_indexes(): void {
+		$table   = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLAN_META );
+		$indexes = $this->index_names( $table );
+
+		$this->assertContains( 'meta_key_value', $indexes );
+		$this->assertContains( 'plan_meta_key_value', $indexes );
+		$this->assertSame( array( 'meta_key', 'meta_value' ), $this->index_columns( $table, 'meta_key_value' ) );
+		$this->assertSame(
+			array( 'plan_id', 'meta_key', 'meta_value' ),
+			$this->index_columns( $table, 'plan_meta_key_value' )
+		);
 	}

 	public function test_contracts_table_has_extension_slug_column(): void {
@@ -251,10 +290,10 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox The schema version is 2.5.0 (nullable contract identity columns, HPOS-style meta indexes).
+	 * @testdox The schema version is 2.6.0 (plans as records, plan meta table).
 	 */
-	public function test_schema_version_is_2_5_0(): void {
-		$this->assertSame( '2.5.0', SchemaInstaller::get_version() );
+	public function test_schema_version_is_2_6_0(): void {
+		$this->assertSame( '2.6.0', SchemaInstaller::get_version() );
 	}

 	/**
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
index a3e210e1cdf..9671edfa1bc 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php
@@ -48,6 +48,14 @@ class ArgumentValidatorTest extends EngineIntegrationTestCase {
 			'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 ),
 		);
 	}

@@ -70,13 +78,22 @@ class ArgumentValidatorTest extends EngineIntegrationTestCase {
 	 */
 	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.' ),
+			'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.' ),
 		);
 	}

diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/PlanViewTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/PlanViewTest.php
new file mode 100644
index 00000000000..01faea756bd
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/PlanViewTest.php
@@ -0,0 +1,114 @@
+<?php
+/**
+ * Unit tests for the PlanView DTO.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Api\View;
+
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView
+ */
+class PlanViewTest extends TestCase {
+
+	public function test_every_getter_maps_from_a_stored_plan(): void {
+		$billing  = array(
+			'period'   => 'month',
+			'interval' => 1,
+		);
+		$pricing  = array( 'policies' => array( array( 'type' => 'percentage' ) ) );
+		$delivery = array( 'anchor' => 3 );
+
+		$view = PlanView::from_plan(
+			Plan::from_storage(
+				array(
+					'id'               => 7,
+					'name'             => 'Monthly',
+					'status'           => 'archived',
+					'extension_slug'   => 'acme-subs',
+					'billing_policy'   => $billing,
+					'pricing_policy'   => $pricing,
+					'delivery_policy'  => $delivery,
+					'date_created_gmt' => '2026-01-01 00:00:00',
+					'date_updated_gmt' => '2026-01-02 00:00:00',
+				)
+			)
+		);
+
+		$this->assertSame( 7, $view->get_id() );
+		$this->assertSame( 'acme-subs', $view->get_extension_slug() );
+		$this->assertSame( 'archived', $view->get_status() );
+		$this->assertSame( 'Monthly', $view->get_name() );
+		$this->assertSame( $billing, $view->get_billing_policy() );
+		$this->assertSame( $pricing, $view->get_pricing_policy() );
+		$this->assertSame( $delivery, $view->get_delivery_policy() );
+		$this->assertSame( '2026-01-01 00:00:00', $view->get_date_created_gmt() );
+		$this->assertSame( '2026-01-02 00:00:00', $view->get_date_updated_gmt() );
+	}
+
+	public function test_an_unsaved_plan_has_id_zero_and_no_dates(): void {
+		$plan = Plan::create(
+			array(
+				'name'           => 'Draft',
+				'extension_slug' => 'my-ext',
+			)
+		);
+		$view = PlanView::from_plan( $plan );
+
+		$this->assertSame( 0, $view->get_id() );
+		$this->assertSame( 'my-ext', $view->get_extension_slug() );
+		$this->assertNull( $view->get_date_created_gmt() );
+		$this->assertNull( $view->get_date_updated_gmt() );
+	}
+
+	public function test_null_policies_stay_null(): void {
+		$plan = Plan::create(
+			array(
+				'name'           => 'Bare',
+				'extension_slug' => 'my-ext',
+			)
+		);
+		$view = PlanView::from_plan( $plan );
+
+		$this->assertNull( $view->get_billing_policy() );
+		$this->assertNull( $view->get_pricing_policy() );
+		$this->assertNull( $view->get_delivery_policy() );
+	}
+
+	public function test_the_view_is_final_with_no_public_state_and_only_getters(): void {
+		$reflection = new \ReflectionClass( PlanView::class );
+
+		$this->assertTrue( $reflection->isFinal(), 'No subclass can add write access to the view.' );
+		$this->assertSame( array(), $reflection->getProperties( \ReflectionProperty::IS_PUBLIC ), 'The view has no writable state.' );
+		foreach ( $reflection->getMethods( \ReflectionMethod::IS_PUBLIC ) as $method ) {
+			if ( $method->isStatic() || $method->isConstructor() ) {
+				continue;
+			}
+			$this->assertStringStartsWith( 'get_', $method->getName(), 'The view exposes getters only, no mutators.' );
+		}
+	}
+
+	public function test_the_view_does_not_follow_later_entity_changes(): void {
+		$plan = Plan::create(
+			array(
+				'name'           => 'Before',
+				'extension_slug' => 'my-ext',
+				'pricing_policy' => array( 'a' => 1 ),
+			)
+		);
+		$view = PlanView::from_plan( $plan );
+
+		$plan->set_name( 'After' );
+		$plan->set_pricing_policy( array( 'b' => 2 ) );
+
+		$this->assertSame( 'Before', $view->get_name() );
+		$this->assertSame( array( 'a' => 1 ), $view->get_pricing_policy() );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanStatusTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanStatusTest.php
new file mode 100644
index 00000000000..654c9c26dd5
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanStatusTest.php
@@ -0,0 +1,62 @@
+<?php
+/**
+ * Unit tests for the registry-backed PlanStatus helpers.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
+
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanStatus
+ */
+class PlanStatusTest extends TestCase {
+
+	protected function tearDown(): void {
+		StatusRegistry::reset();
+		parent::tearDown();
+	}
+
+	public function test_defaults_lists_the_two_engine_slugs(): void {
+		$this->assertSame( array( 'active', 'archived' ), PlanStatus::get_defaults() );
+	}
+
+	public function test_all_equals_the_defaults_with_nothing_registered(): void {
+		$this->assertSame( PlanStatus::get_defaults(), PlanStatus::get_all() );
+	}
+
+	public function test_defaults_are_registered(): void {
+		$this->assertTrue( PlanStatus::is_registered( PlanStatus::ACTIVE ) );
+		$this->assertTrue( PlanStatus::is_registered( PlanStatus::ARCHIVED ) );
+		$this->assertFalse( PlanStatus::is_registered( 'nonsense' ) );
+	}
+
+	public function test_an_extension_registered_status_is_listed_and_registered(): void {
+		StatusRegistry::register( StatusRegistry::KIND_PLAN, 'seasonal' );
+
+		$all = PlanStatus::get_all();
+
+		$this->assertSame( 'seasonal', end( $all ) );
+		$this->assertTrue( PlanStatus::is_registered( 'seasonal' ) );
+	}
+
+	public function test_a_contract_registration_does_not_make_a_plan_status_registered(): void {
+		StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+
+		$this->assertFalse( PlanStatus::is_registered( 'paused-by-merchant' ) );
+	}
+
+	public function test_is_valid_checks_the_slug_format_only(): void {
+		$this->assertTrue( PlanStatus::is_valid( PlanStatus::ARCHIVED ) );
+		$this->assertTrue( PlanStatus::is_valid( 'legacy-x' ), 'Well-formed but unregistered.' );
+		$this->assertFalse( PlanStatus::is_registered( 'legacy-x' ) );
+		$this->assertFalse( PlanStatus::is_valid( 'Archived' ) );
+		$this->assertFalse( PlanStatus::is_valid( str_repeat( 'a', 21 ) ) );
+	}
+}
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 83657b9af0b..a66398c4ef4 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,244 +9,418 @@ declare( strict_types=1 );

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

-use InvalidArgumentException;
+use DomainException;
 use PHPUnit\Framework\TestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;

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

-	private function billing(): BillingPolicy {
-		return BillingPolicy::from_array(
-			array(
-				'period'   => 'month',
-				'interval' => 1,
-			)
-		);
+	protected function tearDown(): void {
+		StatusRegistry::reset();
+		parent::tearDown();
 	}

-	public function test_create_defaults_category_and_extension_slug(): void {
+	/**
+	 * @testdox create applies the defaults.
+	 */
+	public function test_create_defaults(): void {
 		$plan = Plan::create(
 			array(
 				'name'           => 'Monthly box',
-				'billing_policy' => $this->billing(),
+				'extension_slug' => 'my-ext',
 			)
 		);

 		$this->assertNull( $plan->get_id() );
-		$this->assertSame( Plan::DEFAULT_CATEGORY, $plan->get_category() );
-		$this->assertSame( Plan::STATUS_ACTIVE, $plan->get_status() );
-		$this->assertSame( 0, $plan->get_sort_order() );
-		$this->assertNull( $plan->get_merchant_code() );
-		$this->assertNull( $plan->get_extension_slug() );
+		$this->assertSame( 'Monthly box', $plan->get_name() );
+		$this->assertSame( PlanStatus::ACTIVE, $plan->get_status() );
+		$this->assertSame( 'my-ext', $plan->get_extension_slug() );
+		$this->assertNull( $plan->get_billing_policy() );
+		$this->assertNull( $plan->get_pricing_policy() );
+		$this->assertNull( $plan->get_delivery_policy() );
+		$this->assertNull( $plan->get_date_created_gmt() );
+		$this->assertNull( $plan->get_date_updated_gmt() );
 	}

-	public function test_merchant_code_round_trips_through_create_and_storage(): void {
+	/**
+	 * @return array<string, array{0: string}>
+	 */
+	public function provide_policy_fields(): array {
+		return array(
+			'billing'  => array( 'billing_policy' ),
+			'pricing'  => array( 'pricing_policy' ),
+			'delivery' => array( 'delivery_policy' ),
+		);
+	}
+
+	/**
+	 * @testdox a policy round-trips opaquely through storage.
+	 * @dataProvider provide_policy_fields
+	 *
+	 * @param string $field Policy field.
+	 */
+	public function test_a_policy_round_trips_opaquely_through_storage( string $field ): void {
+		$payload = array(
+			'period'   => 'fortnight',
+			'anything' => array(
+				'nested' => array( 1, 'two', array( 'three' => true ) ),
+				'empty'  => array(),
+			),
+			'flag'     => null,
+		);
+
 		$plan = Plan::create(
 			array(
-				'name'           => 'Coded',
-				'billing_policy' => $this->billing(),
-				'merchant_code'  => 'monthly-box',
+				'name'           => 'Opaque',
+				'extension_slug' => 'my-ext',
+				$field           => $payload,
 			)
 		);

-		$this->assertSame( 'monthly-box', $plan->get_merchant_code() );
-		$this->assertSame( 'monthly-box', $plan->to_storage()['merchant_code'] );
-
-		$hydrated = Plan::from_storage( $plan->to_storage() );
+		$storage = $plan->to_storage();
+		$this->assertSame( $payload, $storage[ $field ] );

-		$this->assertSame( 'monthly-box', $hydrated->get_merchant_code() );
+		$hydrated = Plan::from_storage( $storage );
+		$this->assertSame( $payload, $this->policy( $hydrated, $field ) );
+		$this->assertSame( 'my-ext', $hydrated->get_extension_slug() );
 	}

-	public function test_absent_merchant_code_is_null_in_storage(): void {
-		$plan = Plan::create(
+	/**
+	 * @testdox an empty object policy is kept as an empty array.
+	 * @dataProvider provide_policy_fields
+	 *
+	 * @param string $field Policy field.
+	 */
+	public function test_an_empty_object_policy_is_kept_as_an_empty_array( string $field ): void {
+		$plan = Plan::from_storage(
 			array(
-				'name'           => 'Uncoded',
-				'billing_policy' => $this->billing(),
+				'name' => 'Empty',
+				$field => array(),
 			)
 		);

-		$this->assertNull( $plan->to_storage()['merchant_code'] );
-		$this->assertNull( Plan::from_storage( $plan->to_storage() )->get_merchant_code() );
+		$this->assertSame( array(), $this->policy( $plan, $field ) );
 	}

-	public function test_status_and_sort_order_are_mutable(): void {
+	/**
+	 * @testdox a null policy stays null.
+	 * @dataProvider provide_policy_fields
+	 *
+	 * @param string $field Policy field.
+	 */
+	public function test_a_null_policy_stays_null( string $field ): void {
 		$plan = Plan::create(
 			array(
-				'name'           => 'Ordered',
-				'billing_policy' => $this->billing(),
-				'sort_order'     => 3,
+				'extension_slug' => 'my-ext',
+				'name'           => 'Null',
+				$field           => null,
 			)
 		);

-		$plan->set_status( Plan::STATUS_ARCHIVED );
-		$plan->set_sort_order( 7 );
+		$this->assertNull( $plan->to_storage()[ $field ] );
+		$this->assertNull( $this->policy( Plan::from_storage( $plan->to_storage() ), $field ) );
+	}

-		$this->assertSame( Plan::STATUS_ARCHIVED, $plan->get_status() );
-		$this->assertSame( 7, $plan->get_sort_order() );
+	/**
+	 * @return array<string, array{0: string, 1: mixed}>
+	 */
+	public function provide_bad_policy_values(): array {
+		$cases = array();
+		foreach ( array( 'billing_policy', 'pricing_policy', 'delivery_policy' ) as $field ) {
+			$cases[ "{$field} list" ]   = array( $field, array( 'a', 'b' ) );
+			$cases[ "{$field} scalar" ] = array( $field, 'monthly' );
+			$cases[ "{$field} int" ]    = array( $field, 5 );
+		}
+		return $cases;
 	}

-	public function test_invalid_status_is_rejected(): void {
-		$this->expectException( InvalidArgumentException::class );
+	/**
+	 * @testdox create rejects a non-object policy.
+	 * @dataProvider provide_bad_policy_values
+	 *
+	 * @param string $field Policy field.
+	 * @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(
-				'name'           => 'Bad status',
-				'billing_policy' => $this->billing(),
-				'status'         => 'deleted',
+				'extension_slug' => 'my-ext',
+				'name'           => 'Bad',
+				$field           => $value,
 			)
 		);
 	}

-	public function test_to_storage_exposes_extension_slug_and_decoded_policies(): void {
+	/**
+	 * @testdox create accepts a policy object keyed by numeric ids.
+	 * @dataProvider provide_policy_fields
+	 *
+	 * @param string $field Policy field.
+	 */
+	public function test_create_accepts_a_policy_object_keyed_by_numeric_ids( string $field ): void {
+		$payload = json_decode( '{"123": {"price": "9.00"}, "456": {"price": "12.00"}}', true );
+
 		$plan = Plan::create(
 			array(
-				'name'           => 'Owned',
-				'billing_policy' => $this->billing(),
-				'status'         => Plan::STATUS_ARCHIVED,
-				'sort_order'     => 9,
-				'extension_slug' => 'lite',
-				'pricing_policy' => array( 'policies' => array() ),
+				'extension_slug' => 'my-ext',
+				'name'           => 'Per variation',
+				$field           => $payload,
 			)
 		);

-		$storage = $plan->to_storage();
+		$this->assertSame( $payload, $this->policy( $plan, $field ) );
+	}

-		$this->assertSame( 'lite', $storage['extension_slug'] );
-		$this->assertSame( Plan::STATUS_ARCHIVED, $storage['status'] );
-		$this->assertSame( 9, $storage['sort_order'] );
-		$this->assertIsArray( $storage['billing_policy'] );
-		$this->assertSame( array( 'policies' => array() ), $storage['pricing_policy'] );
+	/**
+	 * @testdox from_storage hydrates a stored list policy as it is and nulls a non-array one, without validating.
+	 */
+	public function test_from_storage_does_not_validate(): void {
+		$plan = Plan::from_storage(
+			array(
+				'name'            => '',
+				'status'          => 'retired-by-ext',
+				'billing_policy'  => array( 'a', 'b' ),
+				'pricing_policy'  => 'monthly',
+				'delivery_policy' => 5,
+			)
+		);
+
+		$this->assertSame( '', $plan->get_name() );
+		$this->assertNull( $plan->get_extension_slug() );
+		$this->assertSame( array( 'a', 'b' ), $plan->get_billing_policy() );
+		$this->assertNull( $plan->get_pricing_policy() );
+		$this->assertNull( $plan->get_delivery_policy() );
 	}

 	/**
-	 * An arbitrary extension payload, including vocabulary the engine does not know.
+	 * @testdox create requires a non-empty name and an extension slug.
+	 * @dataProvider provide_missing_required_args
 	 *
-	 * @return array<string, mixed>
+	 * @param array<string, mixed> $args    Create args.
+	 * @param string               $message Expected message.
+	 */
+	public function test_create_requires_a_name_and_an_extension_slug( array $args, string $message ): void {
+		$this->expectException( DomainException::class );
+		$this->expectExceptionMessage( $message );
+
+		Plan::create( $args );
+	}
+
+	/**
+	 * @return array<string, array{0: array<string, mixed>, 1: string}>
 	 */
-	private function arbitrary_payload(): array {
+	public function provide_missing_required_args(): array {
 		return array(
-			'policies'      => array(
+			'no name'           => array( array( 'extension_slug' => 'my-ext' ), 'Plan: name is required and must be a non-empty string.' ),
+			'blank name'        => array(
 				array(
-					'type'  => 'tiered',
-					'value' => -5,
-					'tiers' => array( array( 'min' => 1 ), array( 'min' => 10 ) ),
+					'extension_slug' => 'my-ext',
+					'name'           => '   ',
 				),
-				array( 'type' => 'bogo' ),
+				'Plan: name is required and must be a non-empty string.',
 			),
-			'one_time_fees' => array(),
-			'custom_key'    => array( 'nested' => array( 'deep' => '1.50' ) ),
+			'no extension slug' => array( array( 'name' => 'Box' ), 'Plan: extension_slug is required and must be a non-empty string.' ),
 		);
 	}

-	public function test_pricing_payload_round_trips_opaquely_through_storage(): void {
-		$plan = Plan::create(
+	/**
+	 * @testdox set_name rejects an empty name.
+	 */
+	public function test_set_name_rejects_an_empty_name(): void {
+		$plan = self::create_plan( 'Named' );
+
+		$this->expectException( DomainException::class );
+
+		$plan->set_name( '' );
+	}
+
+	/**
+	 * @testdox a setter rejects a list policy.
+	 * @dataProvider provide_policy_fields
+	 *
+	 * @param string $field Policy field.
+	 */
+	public function test_a_setter_rejects_a_list_policy( string $field ): void {
+		$plan   = self::create_plan( 'Bad' );
+		$setter = 'set_' . $field;
+
+		$this->expectException( DomainException::class );
+
+		$plan->{$setter}( array( 'a', 'b' ) );
+	}
+
+	/**
+	 * @testdox a setter replaces the whole payload.
+	 * @dataProvider provide_policy_fields
+	 *
+	 * @param string $field Policy field.
+	 */
+	public function test_a_setter_replaces_the_whole_payload( string $field ): void {
+		$plan   = Plan::create(
 			array(
-				'name'           => 'Opaque',
-				'billing_policy' => $this->billing(),
-				'pricing_policy' => $this->arbitrary_payload(),
+				'extension_slug' => 'my-ext',
+				'name'           => 'Replace',
+				$field           => array(
+					'a' => 1,
+					'b' => 2,
+				),
 			)
 		);
+		$setter = 'set_' . $field;

-		$this->assertSame( $this->arbitrary_payload(), $plan->get_pricing_policy() );
-		$this->assertSame( $this->arbitrary_payload(), $plan->to_storage()['pricing_policy'] );
-
-		$hydrated = Plan::from_storage( $plan->to_storage() );
+		$plan->{$setter}( array( 'c' => 3 ) );
+		$this->assertSame( array( 'c' => 3 ), $this->policy( $plan, $field ) );

-		$this->assertSame( $this->arbitrary_payload(), $hydrated->get_pricing_policy() );
+		$plan->{$setter}( null );
+		$this->assertNull( $this->policy( $plan, $field ) );
 	}

-	public function test_set_pricing_policy_round_trips_and_clears(): void {
-		$plan = Plan::create(
+	/**
+	 * @testdox create rejects an unregistered status.
+	 */
+	public function test_create_rejects_an_unregistered_status(): void {
+		$this->expectException( DomainException::class );
+
+		Plan::create(
 			array(
-				'name'           => 'Mutating',
-				'billing_policy' => $this->billing(),
+				'extension_slug' => 'my-ext',
+				'name'           => 'Unknown',
+				'status'         => 'seasonal',
 			)
 		);
+	}

-		$plan->set_pricing_policy( $this->arbitrary_payload() );
-		$this->assertSame( $this->arbitrary_payload(), $plan->get_pricing_policy() );
+	/**
+	 * @testdox set_status rejects an unregistered status.
+	 */
+	public function test_set_status_rejects_an_unregistered_status(): void {
+		$plan = self::create_plan( 'Unknown' );

-		$plan->set_pricing_policy( null );
-		$this->assertNull( $plan->get_pricing_policy() );
+		$this->expectException( DomainException::class );
+
+		$plan->set_status( 'seasonal' );
 	}

-	public function test_absent_pricing_payload_stays_null(): void {
+	/**
+	 * @testdox a registered extension status is accepted.
+	 */
+	public function test_a_registered_extension_status_is_accepted(): void {
+		StatusRegistry::register( StatusRegistry::KIND_PLAN, 'seasonal' );
+
 		$plan = Plan::create(
 			array(
-				'name'           => 'Plain',
-				'billing_policy' => $this->billing(),
+				'extension_slug' => 'my-ext',
+				'name'           => 'Seasonal',
+				'status'         => 'seasonal',
 			)
 		);
+		$this->assertSame( 'seasonal', $plan->get_status() );

-		$this->assertNull( $plan->get_pricing_policy() );
-		$this->assertNull( $plan->to_storage()['pricing_policy'] );
-		$this->assertNull( Plan::from_storage( $plan->to_storage() )->get_pricing_policy() );
+		$plan->set_status( PlanStatus::ARCHIVED );
+		$this->assertSame( PlanStatus::ARCHIVED, $plan->get_status() );
+		$this->assertSame( PlanStatus::ARCHIVED, $plan->to_storage()['status'] );
 	}

-	public function test_empty_pricing_payload_is_accepted(): void {
-		$plan = Plan::create(
+	/**
+	 * @testdox from_storage hydrates an unregistered stored status.
+	 */
+	public function test_from_storage_hydrates_an_unregistered_stored_status(): void {
+		$plan = Plan::from_storage(
 			array(
-				'name'           => 'Empty',
-				'billing_policy' => $this->billing(),
-				'pricing_policy' => array(),
+				'id'     => 5,
+				'name'   => 'Legacy',
+				'status' => 'retired-by-ext',
 			)
 		);

-		$this->assertSame( array(), $plan->get_pricing_policy() );
-		$this->assertSame( array(), Plan::from_storage( $plan->to_storage() )->get_pricing_policy() );
+		$this->assertSame( 'retired-by-ext', $plan->get_status() );
+
+		$plan->set_status( 'retired-by-ext' );
+		$this->assertSame( 'retired-by-ext', $plan->to_storage()['status'] );
 	}

 	/**
-	 * @return array<string, array{0: mixed}>
+	 * @testdox from_storage hydrates the id and dates.
 	 */
-	public function non_object_payloads(): array {
-		return array(
-			'list'   => array( array( array( 'type' => 'x' ) ) ),
-			'string' => array( 'percentage' ),
-			'int'    => array( 10 ),
+	public function test_from_storage_hydrates_the_id_and_dates(): void {
+		$plan = Plan::from_storage(
+			array(
+				'id'               => '12',
+				'name'             => 'Stored',
+				'status'           => 'archived',
+				'date_created_gmt' => '2026-01-02 03:04:05',
+				'date_updated_gmt' => '2026-02-03 04:05:06',
+			)
 		);
+
+		$this->assertSame( 12, $plan->get_id() );
+		$this->assertSame( PlanStatus::ARCHIVED, $plan->get_status() );
+		$this->assertSame( '2026-01-02 03:04:05', $plan->get_date_created_gmt() );
+		$this->assertSame( '2026-02-03 04:05:06', $plan->get_date_updated_gmt() );
 	}

 	/**
-	 * @dataProvider non_object_payloads
-	 *
-	 * @param mixed $payload Non-object payload.
+	 * @testdox to_storage has exactly the record columns.
 	 */
-	public function test_create_rejects_a_non_object_pricing_payload( $payload ): void {
-		$this->expectException( InvalidArgumentException::class );
-		$this->expectExceptionMessage( 'pricing_policy must be an object' );
+	public function test_to_storage_has_exactly_the_record_columns(): void {
+		$plan = self::create_plan( 'Columns' );

-		Plan::create(
-			array(
-				'name'           => 'Bad',
-				'billing_policy' => $this->billing(),
-				'pricing_policy' => $payload,
-			)
+		$this->assertEqualsCanonicalizing(
+			array( 'name', 'status', 'extension_slug', 'billing_policy', 'pricing_policy', 'delivery_policy' ),
+			array_keys( $plan->to_storage() )
 		);
 	}

 	/**
-	 * @dataProvider non_object_payloads
-	 *
-	 * @param mixed $payload Non-object payload.
+	 * @testdox name and id are mutable.
 	 */
-	public function test_from_storage_rejects_a_non_object_pricing_payload( $payload ): void {
-		$this->expectException( InvalidArgumentException::class );
-		$this->expectExceptionMessage( 'pricing_policy must be an object' );
+	public function test_name_and_id_are_mutable(): void {
+		$plan = self::create_plan( 'Before' );

-		Plan::from_storage(
+		$plan->set_name( 'After' );
+		$plan->set_id( 9 );
+
+		$this->assertSame( 'After', $plan->get_name() );
+		$this->assertSame( 9, $plan->get_id() );
+	}
+
+	/**
+	 * A new plan owned by `my-ext`.
+	 *
+	 * @param string $name Plan name.
+	 */
+	private static function create_plan( string $name ): Plan {
+		return Plan::create(
 			array(
-				'name'           => 'Corrupted',
-				'billing_policy' => array(
-					'period'   => 'month',
-					'interval' => 1,
-				),
-				'pricing_policy' => $payload,
+				'name'           => $name,
+				'extension_slug' => 'my-ext',
 			)
 		);
 	}
+
+	/**
+	 * Read a policy by field name.
+	 *
+	 * @param Plan   $plan  Plan.
+	 * @param string $field Policy field.
+	 * @return array<string, mixed>|null
+	 */
+	private function policy( Plan $plan, string $field ): ?array {
+		switch ( $field ) {
+			case 'billing_policy':
+				return $plan->get_billing_policy();
+			case 'pricing_policy':
+				return $plan->get_pricing_policy();
+			default:
+				return $plan->get_delivery_policy();
+		}
+	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/StatusRegistryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/StatusRegistryTest.php
index b17d1c8e9e0..dee6faa96c7 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/StatusRegistryTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/StatusRegistryTest.php
@@ -13,6 +13,7 @@ use InvalidArgumentException;
 use PHPUnit\Framework\TestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;

 /**
@@ -34,6 +35,24 @@ class StatusRegistryTest extends TestCase {
 			array( 'pending', 'processing', 'billed', 'failed', 'cancelled' ),
 			StatusRegistry::get_all( StatusRegistry::KIND_CYCLE )
 		);
+		$this->assertSame(
+			array( 'active', 'archived' ),
+			StatusRegistry::get_all( StatusRegistry::KIND_PLAN )
+		);
+	}
+
+	public function test_plan_registrations_are_independent_of_contract_and_cycle(): void {
+		StatusRegistry::register( StatusRegistry::KIND_PLAN, 'seasonal' );
+		StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+
+		$this->assertTrue( StatusRegistry::is_registered( StatusRegistry::KIND_PLAN, 'seasonal' ) );
+		$this->assertFalse( StatusRegistry::is_registered( StatusRegistry::KIND_CONTRACT, 'seasonal' ) );
+		$this->assertFalse( StatusRegistry::is_registered( StatusRegistry::KIND_CYCLE, 'seasonal' ) );
+		$this->assertFalse( StatusRegistry::is_registered( StatusRegistry::KIND_PLAN, 'paused-by-merchant' ) );
+		$this->assertSame(
+			array_merge( PlanStatus::get_defaults(), array( 'seasonal' ) ),
+			StatusRegistry::get_all( StatusRegistry::KIND_PLAN )
+		);
 	}

 	public function test_registered_statuses_append_after_the_defaults_in_registration_order(): void {
@@ -138,29 +157,32 @@ class StatusRegistryTest extends TestCase {
 	public function test_register_rejects_an_unknown_kind(): void {
 		$this->expectException( InvalidArgumentException::class );

-		StatusRegistry::register( 'plan', 'draft' );
+		StatusRegistry::register( 'product', 'draft' );
 	}

 	public function test_all_rejects_an_unknown_kind(): void {
 		$this->expectException( InvalidArgumentException::class );

-		StatusRegistry::get_all( 'plan' );
+		StatusRegistry::get_all( 'product' );
 	}

 	public function test_is_registered_rejects_an_unknown_kind(): void {
 		$this->expectException( InvalidArgumentException::class );

-		StatusRegistry::is_registered( 'plan', 'draft' );
+		StatusRegistry::is_registered( 'product', 'draft' );
 	}

 	public function test_reset_clears_registrations_but_keeps_the_defaults(): void {
 		StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
 		StatusRegistry::register( StatusRegistry::KIND_CYCLE, 'disputed' );
+		StatusRegistry::register( StatusRegistry::KIND_PLAN, 'seasonal' );

 		StatusRegistry::reset();

 		$this->assertSame( ContractStatus::get_defaults(), StatusRegistry::get_all( StatusRegistry::KIND_CONTRACT ) );
 		$this->assertSame( CycleStatus::get_defaults(), StatusRegistry::get_all( StatusRegistry::KIND_CYCLE ) );
+		$this->assertSame( PlanStatus::get_defaults(), StatusRegistry::get_all( StatusRegistry::KIND_PLAN ) );
 		$this->assertFalse( StatusRegistry::is_registered( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' ) );
+		$this->assertFalse( StatusRegistry::is_registered( StatusRegistry::KIND_PLAN, 'seasonal' ) );
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/CoercionTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/CoercionTest.php
index aefca28998b..e94c41df699 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/CoercionTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/CoercionTest.php
@@ -40,6 +40,15 @@ class CoercionTest extends TestCase {
 		$this->assertSame( array(), Coercion::coerce_string_keyed( array() ) );
 	}

+	public function test_coerce_nullable_string_keyed_keeps_an_array_and_nulls_anything_else(): void {
+		// PHP stores an integer-like string key as an int, so a list stays a list.
+		$this->assertSame( array( 'a' ), Coercion::coerce_nullable_string_keyed( array( 'a' ) ) );
+		$this->assertSame( array( 'period' => 'month' ), Coercion::coerce_nullable_string_keyed( array( 'period' => 'month' ) ) );
+		$this->assertSame( array(), Coercion::coerce_nullable_string_keyed( array() ) );
+		$this->assertNull( Coercion::coerce_nullable_string_keyed( null ) );
+		$this->assertNull( Coercion::coerce_nullable_string_keyed( 'monthly' ) );
+	}
+
 	/**
 	 * @dataProvider provide_non_arrays
 	 *
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 f087a5a534e..2ca3627a253 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
@@ -20,6 +20,43 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
  */
 class BillingPolicyTest extends TestCase {

+	/**
+	 * @testdox from_array refuses an unusable cadence.
+	 * @testWith ["decade", 1, "BillingPolicy: invalid period \"decade\"."]
+	 *           ["month", 0, "BillingPolicy: interval must be positive, got 0."]
+	 *           ["day", -1, "BillingPolicy: interval must be positive, got -1."]
+	 *
+	 * @param string $period   Period.
+	 * @param int    $interval Interval.
+	 * @param string $message  Expected exception message.
+	 */
+	public function test_from_array_refuses_an_unusable_cadence( string $period, int $interval, string $message ): void {
+		$data = array(
+			'period'   => $period,
+			'interval' => $interval,
+		);
+
+		$this->expectException( DomainException::class );
+		$this->expectExceptionMessage( $message );
+
+		BillingPolicy::from_array( $data );
+	}
+
+	/**
+	 * @testdox from_array accepts a usable cadence.
+	 */
+	public function test_from_array_accepts_a_usable_cadence(): void {
+		$policy = BillingPolicy::from_array(
+			array(
+				'period'   => 'week',
+				'interval' => 2,
+			)
+		);
+
+		$this->assertSame( 'week', $policy->get_period() );
+		$this->assertSame( 2, $policy->get_interval() );
+	}
+
 	public function test_round_trips_through_array(): void {
 		$data = array(
 			'period'         => 'month',
@@ -104,28 +141,22 @@ class BillingPolicyTest extends TestCase {
 		);
 	}

-	public function test_invalid_period_throws(): void {
-		$policy = BillingPolicy::from_array(
-			array(
-				'period'   => 'fortnight',
-				'interval' => 1,
-			)
-		);
-
+	/**
+	 * @testdox construction refuses an unknown period.
+	 */
+	public function test_construction_refuses_an_unknown_period(): void {
 		$this->expectException( DomainException::class );
-		$policy->compute_next_renewal_from( new DateTimeImmutable( '2026-01-01', new DateTimeZone( 'UTC' ) ) );
-	}

-	public function test_non_positive_interval_throws(): void {
-		$policy = BillingPolicy::from_array(
-			array(
-				'period'   => 'month',
-				'interval' => 0,
-			)
-		);
+		new BillingPolicy( 'fortnight', 1, null, null, null );
+	}

+	/**
+	 * @testdox construction refuses a non-positive interval.
+	 */
+	public function test_construction_refuses_a_non_positive_interval(): void {
 		$this->expectException( DomainException::class );
-		$policy->compute_next_renewal_from( new DateTimeImmutable( '2026-01-01', new DateTimeZone( 'UTC' ) ) );
+
+		new BillingPolicy( 'month', 0, null, null, null );
 	}

 	public function test_non_array_trial_duration_throws(): void {
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 231d29b2d59..3f727a58230 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
@@ -97,9 +97,9 @@ class PlanSnapshotTest extends TestCase {
 	}

 	/**
-	 * @testdox get_billing_policy reconstructs the frozen cadence from the payload.
+	 * @testdox read_billing_policy reconstructs the frozen cadence from the payload.
 	 */
-	public function test_get_billing_policy_reconstructs_the_frozen_cadence(): void {
+	public function test_read_billing_policy_reconstructs_the_frozen_cadence(): void {
 		$snapshot = PlanSnapshot::from_array(
 			array(
 				'selling_plan_id' => 7,
@@ -114,7 +114,7 @@ class PlanSnapshotTest extends TestCase {
 			)
 		);

-		$policy = $snapshot->get_billing_policy();
+		$policy = $snapshot->read_billing_policy();

 		$this->assertInstanceOf( BillingPolicy::class, $policy );
 		$this->assertSame( 'month', $policy->get_period() );
@@ -123,27 +123,51 @@ class PlanSnapshotTest extends TestCase {
 	}

 	/**
-	 * @testdox get_billing_policy is null when the payload carries no billing policy.
+	 * @testdox read_billing_policy throws for a structurally-invalid stored policy.
 	 */
-	public function test_get_billing_policy_is_null_when_absent(): void {
-		$snapshot = PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ) );
+	public function test_read_billing_policy_throws_for_an_unreadable_policy(): void {
+		// `interval` missing: BillingPolicy::from_array() refuses it.
+		$snapshot = PlanSnapshot::from_array(
+			array(
+				'billing_policy' => array( 'period' => 'month' ),
+			)
+		);

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

 	/**
-	 * @testdox get_billing_policy degrades to null for a structurally-invalid stored policy.
+	 * @testdox read_billing_policy throws for a policy with no usable cadence ($label).
+	 *
+	 * @testWith ["unknown period", "decade", 1]
+	 *           ["zero interval", "month", 0]
+	 *           ["negative interval", "week", -2]
+	 *
+	 * @param string $label    Case label.
+	 * @param string $period   Stored period.
+	 * @param int    $interval Stored interval.
 	 */
-	public function test_get_billing_policy_is_null_for_an_unreadable_policy(): void {
-		// `interval` missing: BillingPolicy::from_array() would throw; the accessor swallows
-		// it and degrades to "no cadence" rather than fataling the read.
+	public function test_a_policy_without_a_usable_cadence_is_not_read( string $label, string $period, int $interval ): void {
+		unset( $label );
 		$snapshot = PlanSnapshot::from_array(
 			array(
-				'billing_policy' => array( 'period' => 'month' ),
+				'billing_policy' => array(
+					'period'   => $period,
+					'interval' => $interval,
+				),
 			)
 		);

-		$this->assertNull( $snapshot->get_billing_policy() );
+		$this->expectException( DomainException::class );
+		$snapshot->read_billing_policy();
+	}
+
+	/**
+	 * @testdox read_billing_policy is null when the payload carries no billing policy.
+	 */
+	public function test_read_billing_policy_is_null_when_absent(): void {
+		$this->assertNull( PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ) )->read_billing_policy() );
 	}

 	/**