Commit c4bec409775 for woocommerce

commit c4bec4097756db74378c44a0f7ac93be8a16087a
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date:   Tue Oct 6 11:31:29 2026 +0200

    Subscriptions engine: registered statuses and an owner-scoped due scan (#69350)

    Register subscription statuses and scope the engine due scan to owners

    - Add a WordPress-free status registry: engine default contract and cycle statuses plus validated extension registrations; status writes accept only registered slugs, unknown stored statuses round-trip unchanged
    - Retire the contract and cycle transition tables; hold, cancel and reactivate enforce the same preconditions explicitly, so REST responses are unchanged
    - Have hold and cancellation clear the next-due moment themselves, with hold storing a verified anchor that reactivation resumes from
    - Scope the renewal due scan to contracts whose owner is a registered consumer, backed by a new due_owner index (schema 2.4.0)
    - Simplify CycleStatus to a validating public constructor and split is_registered() from the slug-format is_valid()

diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-primitives-foundation b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-primitives-foundation
new file mode 100644
index 00000000000..536bd564358
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-primitives-foundation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Subscriptions engine package is not released yet; registered statuses and the owner-scoped due scan need no changelog entry.
+
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php
index 8baad69696a..07b30b5cea4 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php
@@ -21,6 +21,9 @@
  * fields and must not assume the set is closed. A generic resource read API is a
  * planned follow-up alongside the read-model views, when a consumer needs it.
  *
+ * Interim: moves out of the engine with the lifecycle flows (hold / reactivate /
+ * cancel and their routes).
+ *
  * Every route requires a logged-in user, enforced through the shared
  * {@see RESTPermissions} floor (core's cookie auth has already verified the REST nonce
  * `wp_rest` by then). Per-route, ownership is enforced with the asymmetric not-found
@@ -274,7 +277,7 @@ final class ContractsController extends WP_REST_Controller {
 	 * Run a lifecycle action behind the ownership guard, then return the domain
 	 * summary with the resulting status.
 	 *
-	 * A `DomainException` (an illegal transition for the contract's current state) maps to
+	 * A `DomainException` (an action whose preconditions the contract's current state does not meet) maps to
 	 * a 409 Conflict; any other failure maps to a 500. The ownership guard keeps the
 	 * asymmetric 404 for not-owned / unknown.
 	 *
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
index 87598d82570..aca53862b66 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
@@ -1,7 +1,8 @@
 <?php
 /**
  * Contract - the stable identity of a subscription and the live source of truth
- * for its current state. Enforces lifecycle transitions through {@see ContractStatus}.
+ * for its current state. Status writes must name a registered status ({@see ContractStatus});
+ * the entity enforces no transition rules between them.
  *
  * Being the live source of truth (mutable), it holds the live schedule
  * (`next_payment_gmt`), the latest snapshot references (`plan_snapshot_id` /
@@ -288,7 +289,7 @@ final class Contract {

 		$contract = new self( $args );

-		if ( ! ContractStatus::is_valid( $contract->status ) ) {
+		if ( ! ContractStatus::is_registered( $contract->status ) ) {
 			throw new DomainException( sprintf( 'Contract: invalid status "%s".', $contract->status ) );
 		}

@@ -355,17 +356,23 @@ final class Contract {
 	}

 	/**
-	 * Transition the contract to a new status.
+	 * Set the contract status.
 	 *
-	 * @param string $status Target status.
-	 * @throws DomainException If the transition is not allowed by ContractStatus.
+	 * Any registered status may follow any other: the engine enforces no
+	 * transition table (flows own their preconditions). Setting the current
+	 * status is a no-op, so a hydrated unregistered status survives it.
+	 *
+	 * @param string $status Target status; must be registered.
+	 * @throws DomainException If `$status` is not a registered contract status.
 	 */
 	public function set_status( string $status ): void {
 		if ( $status === $this->status ) {
 			return;
 		}

-		ContractStatus::assert_transition_allowed( $this->status, $status );
+		if ( ! ContractStatus::is_registered( $status ) ) {
+			throw new DomainException( sprintf( 'Contract: status "%s" is not registered.', $status ) );
+		}

 		$this->status = $status;
 	}
@@ -658,6 +665,24 @@ final class Contract {
 		return $this->meta;
 	}

+	/**
+	 * Set or remove one meta entry.
+	 *
+	 * Meta is opaque key/value data; the repository's existing child sync
+	 * persists the map on save.
+	 *
+	 * @param string      $key   Meta key.
+	 * @param string|null $value Meta value, or null to remove the key.
+	 */
+	public function set_meta( string $key, ?string $value ): void {
+		if ( null === $value ) {
+			unset( $this->meta[ $key ] );
+			return;
+		}
+
+		$this->meta[ $key ] = $value;
+	}
+
 	/**
 	 * Serialize the contract row (excluding generated id/timestamps).
 	 *
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/ContractStatus.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/ContractStatus.php
index d4e10fd4db7..1bb71a5430c 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/ContractStatus.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/ContractStatus.php
@@ -1,9 +1,13 @@
 <?php
 /**
- * ContractStatus - the contract lifecycle state machine.
+ * ContractStatus - the engine's default contract status slugs plus read helpers
+ * over the {@see StatusRegistry}.
  *
- * Owns the set of valid statuses and the allowed transitions between them.
- * Status transitions are validated here and applied by the {@see Contract} entity.
+ * Contract status is opaque engine data. The constants name the engine defaults:
+ * 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 Contract} entity refuses to write a status that is not registered.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
  */
@@ -12,8 +16,6 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;

-use DomainException;
-
 defined( 'ABSPATH' ) || exit;

 /**
@@ -28,11 +30,11 @@ final class ContractStatus {
 	public const EXPIRED              = 'expired';

 	/**
-	 * All known statuses.
+	 * The engine's default contract statuses (the registry seed).
 	 *
 	 * @return array<int, string>
 	 */
-	public static function all(): array {
+	public static function get_defaults(): array {
 		return array(
 			self::ACTIVE,
 			self::ON_HOLD,
@@ -43,87 +45,32 @@ final class ContractStatus {
 	}

 	/**
-	 * Whether `$status` is a known status.
+	 * Every registered contract status: the engine defaults, then extension
+	 * registrations.
 	 *
-	 * @param string $status Status to check.
+	 * @return array<int, string>
 	 */
-	public static function is_valid( string $status ): bool {
-		return in_array( $status, self::all(), true );
+	public static function get_all(): array {
+		return StatusRegistry::get_all( StatusRegistry::KIND_CONTRACT );
 	}

 	/**
-	 * Whether `$status` is terminal (no transitions out).
+	 * Whether `$status` is a registered contract status (an engine default or an extension
+	 * registration). Write paths accept only registered statuses.
 	 *
 	 * @param string $status Status to check.
 	 */
-	public static function is_terminal( string $status ): bool {
-		return self::is_valid( $status ) && array() === self::transitions()[ $status ];
-	}
-
-	/**
-	 * Whether a contract may move from `$from` to `$to`.
-	 *
-	 * Unknown source or target statuses are reported as not allowed, so a row
-	 * that has drifted into an unrecognized state cannot be transitioned out of
-	 * a value we do not know how to reason about. Same-status calls
-	 * (`active` -> `active`) report false here; {@see Contract::set_status()}
-	 * short-circuits no-ops before consulting this table so they do not surface
-	 * as exceptions to callers.
-	 *
-	 * @param string $from Current status.
-	 * @param string $to   Target status.
-	 */
-	public static function is_transition_allowed( string $from, string $to ): bool {
-		if ( ! self::is_valid( $from ) || ! self::is_valid( $to ) ) {
-			return false;
-		}
-
-		return in_array( $to, self::transitions()[ $from ], true );
+	public static function is_registered( string $status ): bool {
+		return StatusRegistry::is_registered( StatusRegistry::KIND_CONTRACT, $status );
 	}

 	/**
-	 * Whether a contract may move from `$from` to `$to`.
+	 * 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.
 	 *
-	 * Alias of {@see self::is_transition_allowed()}.
-	 *
-	 * @param string $from Current status.
-	 * @param string $to   Target status.
-	 */
-	public static function can_transition( string $from, string $to ): bool {
-		return self::is_transition_allowed( $from, $to );
-	}
-
-	/**
-	 * Throw if `$from` -> `$to` is not an allowed transition.
-	 *
-	 * The canonical enforcement entry point: every status change flows through
-	 * here before the new status is applied, which makes "no nonsense states" a
-	 * structural guarantee rather than a code-review aspiration.
-	 *
-	 * @param string $from Current status.
-	 * @param string $to   Target status.
-	 * @throws DomainException When the transition is rejected by {@see self::is_transition_allowed()}.
-	 */
-	public static function assert_transition_allowed( string $from, string $to ): void {
-		if ( ! self::is_transition_allowed( $from, $to ) ) {
-			throw new DomainException(
-				sprintf( 'ContractStatus: illegal status transition from "%s" to "%s".', $from, $to )
-			);
-		}
-	}
-
-	/**
-	 * Allowed transitions: current status => list of reachable statuses.
-	 *
-	 * @return array<string, array<int, string>>
+	 * @param string $status Status to check.
 	 */
-	private static function transitions(): array {
-		return array(
-			self::ACTIVE               => array( self::ON_HOLD, self::PENDING_CANCELLATION, self::CANCELLED, self::EXPIRED ),
-			self::ON_HOLD              => array( self::ACTIVE, self::PENDING_CANCELLATION, self::CANCELLED ),
-			self::PENDING_CANCELLATION => array( self::ACTIVE, self::CANCELLED ),
-			self::CANCELLED            => array(),
-			self::EXPIRED              => array(),
-		);
+	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/Cycle.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
index 6abe8d370b9..b43a0ef51ce 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
@@ -228,6 +228,7 @@ final class Cycle {

 		self::assert_valid_kind( $cycle->kind );
 		self::assert_valid_sequence_no( $cycle->sequence_no );
+		self::assert_registered_status( $cycle->status );

 		return $cycle;
 	}
@@ -235,8 +236,13 @@ final class Cycle {
 	/**
 	 * Hydrate from a stored row.
 	 *
+	 * A well-formed stored status is kept verbatim, registered or not (registration is
+	 * checked only where a status is written), so a value written by a since-deactivated
+	 * extension round-trips unchanged.
+	 *
 	 * @param array<string, mixed> $row Cycle row.
-	 * @throws DomainException If the stored status, kind, or sequence_no is invalid.
+	 * @throws DomainException If the stored kind or sequence_no is invalid, or the stored
+	 *                         status is not a well-formed status slug.
 	 */
 	public static function from_storage( array $row ): self {
 		$kind = ScalarCoercion::coerce_string( $row['kind'] ?? null, self::KIND_BILLING );
@@ -327,17 +333,24 @@ final class Cycle {
 	}

 	/**
-	 * Transition the cycle to a new status.
+	 * Set the cycle status.
+	 *
+	 * Any status may follow any other: the engine enforces no transition table. A
+	 * changed status must be registered (an unregistered value, such as one built
+	 * through {@see new CycleStatus()}, is rejected); setting the current status
+	 * is a no-op, so a hydrated unregistered status can be saved unchanged.
 	 *
 	 * @param CycleStatus $status Target status.
-	 * @throws DomainException If the transition is not allowed by CycleStatus.
+	 * @throws DomainException If the status changes to an unregistered value.
 	 */
 	public function set_status( CycleStatus $status ): void {
 		if ( $this->status->equals( $status ) ) {
 			return;
 		}

-		$this->status = $this->status->transition_to( $status );
+		self::assert_registered_status( $status );
+
+		$this->status = $status;
 	}

 	/**
@@ -591,14 +604,31 @@ final class Cycle {
 		}
 	}

+	/**
+	 * Reject a status value that is not registered. Write paths only
+	 * ({@see self::create()}, {@see self::set_status()}); storage hydration keeps an
+	 * unregistered stored value verbatim.
+	 *
+	 * @param CycleStatus $status Status to check.
+	 * @throws DomainException If the status is not registered.
+	 */
+	private static function assert_registered_status( CycleStatus $status ): void {
+		if ( ! CycleStatus::is_registered( $status->get_value() ) ) {
+			throw new DomainException(
+				sprintf( 'Cycle: status "%s" is not registered.', $status->get_value() )
+			);
+		}
+	}
+
 	/**
 	 * Resolve a status input into a typed {@see CycleStatus}. A `CycleStatus` passes
-	 * through; null defaults to `pending`; a string is validated via
-	 * {@see CycleStatus::from()}.
+	 * through; null defaults to `pending`; a string is wrapped (slug format checked).
+	 * Registration is checked by {@see self::create()} and {@see self::set_status()},
+	 * not here, so storage hydration keeps an unregistered stored value.
 	 *
 	 * @param mixed $status Raw status value (a CycleStatus, null, or a status string).
 	 * @return CycleStatus
-	 * @throws DomainException If a status string is not a known status.
+	 * @throws DomainException If a status string is not a well-formed status slug.
 	 */
 	private static function coerce_status( $status ): CycleStatus {
 		if ( $status instanceof CycleStatus ) {
@@ -606,10 +636,10 @@ final class Cycle {
 		}

 		if ( null === $status ) {
-			return CycleStatus::pending();
+			return new CycleStatus( CycleStatus::PENDING );
 		}

-		return CycleStatus::from( ScalarCoercion::coerce_string( $status ) );
+		return new CycleStatus( ScalarCoercion::coerce_string( $status ) );
 	}

 	/**
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
index 7a29582015f..bb0852845e3 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
@@ -1,17 +1,13 @@
 <?php
 /**
- * CycleStatus - the cycle lifecycle state machine, as an immutable value object.
- * Owns the valid statuses and allowed transitions so an invalid state cannot be
- * represented. Mirrors {@see ContractStatus}.
+ * CycleStatus - a cycle status as an immutable value object, plus read helpers
+ * over the {@see StatusRegistry}. Mirrors {@see ContractStatus}.
  *
- * Lifecycle: a cycle is born `pending`; a charge submitted to a gateway that has not
- * yet returned a terminal outcome (an async method awaiting confirmation) is
- * `processing`; it settles to `billed` (terminal) or `failed`. A `failed` cycle can be
- * retried back to `pending` (an admin-triggered re-attempt), and any non-settled cycle
- * can be `cancelled` (terminal). The state is shared with the shipping chain, so
- * `processing` names "submitted, awaiting a terminal outcome" without payment-specific wording.
- * Instance methods serve the entity; the static string helpers operate on raw strings at
- * the storage boundary.
+ * Cycle status is opaque engine data. The constants name the engine defaults:
+ * the defaults are shared slugs and carry no engine meaning; the engine enforces
+ * no transitions. Extensions may register more through
+ * {@see StatusRegistry::register()}. The slugs are shared with the shipping chain,
+ * so `processing` avoids payment-specific wording.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
  */
@@ -27,8 +23,11 @@ defined( 'ABSPATH' ) || exit;
 /**
  * CycleStatus value object.
  *
- * Immutable. Construct via a named factory ({@see self::pending()} etc.) or
- * {@see self::from()}.
+ * Immutable: `new CycleStatus( CycleStatus::PENDING )`. The constructor checks the slug
+ * format only, so a stored status a since-deactivated extension wrote still loads;
+ * whether a status is registered is checked where a cycle status is written
+ * ({@see Cycle::create()}, {@see Cycle::set_status()}, the repository's status
+ * compare-and-set).
  */
 final class CycleStatus {

@@ -46,63 +45,19 @@ final class CycleStatus {
 	private $value;

 	/**
-	 * Use a named factory ({@see self::pending()} etc.) or {@see self::from()}.
+	 * Wrap a status slug.
 	 *
-	 * @param string $value A known status string.
+	 * @param string $value Status slug.
+	 * @throws DomainException If `$value` is not a well-formed status slug.
 	 */
-	private function __construct( string $value ) {
-		$this->value = $value;
-	}
-
-	/**
-	 * Build a status value from a known status string.
-	 *
-	 * @param string $value Status string.
-	 * @throws DomainException If `$value` is not a known status.
-	 */
-	public static function from( string $value ): self {
+	public function __construct( string $value ) {
 		if ( ! self::is_valid( $value ) ) {
 			throw new DomainException(
-				sprintf( 'CycleStatus: "%s" is not a known status.', $value )
+				sprintf( 'CycleStatus: "%s" is not a valid status slug.', $value )
 			);
 		}

-		return new self( $value );
-	}
-
-	/**
-	 * The `pending` status (charge in flight; values locked at creation).
-	 */
-	public static function pending(): self {
-		return new self( self::PENDING );
-	}
-
-	/**
-	 * The `processing` status (charge submitted, awaiting a terminal outcome; non-terminal).
-	 */
-	public static function processing(): self {
-		return new self( self::PROCESSING );
-	}
-
-	/**
-	 * The `billed` status (settled after a successful charge; terminal).
-	 */
-	public static function billed(): self {
-		return new self( self::BILLED );
-	}
-
-	/**
-	 * The `failed` status (charge declined; non-terminal).
-	 */
-	public static function failed(): self {
-		return new self( self::FAILED );
-	}
-
-	/**
-	 * The `cancelled` status (closed; terminal).
-	 */
-	public static function cancelled(): self {
-		return new self( self::CANCELLED );
+		$this->value = $value;
 	}

 	/**
@@ -122,32 +77,11 @@ final class CycleStatus {
 	}

 	/**
-	 * Whether this status may move to `$target`.
-	 *
-	 * @param CycleStatus $target Target status.
-	 */
-	public function can_transition_to( CycleStatus $target ): bool {
-		return self::is_transition_allowed( $this->value, $target->value );
-	}
-
-	/**
-	 * Move to `$target`, returning the new status value.
-	 *
-	 * @param CycleStatus $target Target status.
-	 * @throws DomainException If the transition is not allowed.
-	 */
-	public function transition_to( CycleStatus $target ): self {
-		self::assert_transition_allowed( $this->value, $target->value );
-
-		return $target;
-	}
-
-	/**
-	 * All known statuses, in lifecycle order.
+	 * The engine's default cycle statuses (the registry seed).
 	 *
 	 * @return array<int, string>
 	 */
-	public static function all(): array {
+	public static function get_defaults(): array {
 		return array(
 			self::PENDING,
 			self::PROCESSING,
@@ -158,79 +92,45 @@ final class CycleStatus {
 	}

 	/**
-	 * Whether `$status` is a known status.
+	 * Every registered cycle status: the engine defaults, then extension
+	 * registrations.
 	 *
-	 * @param string $status Status to check.
+	 * @return array<int, string>
 	 */
-	public static function is_valid( string $status ): bool {
-		return in_array( $status, self::all(), true );
+	public static function get_all(): array {
+		return StatusRegistry::get_all( StatusRegistry::KIND_CYCLE );
 	}

 	/**
-	 * Whether `$status` is terminal (no transitions out).
+	 * Whether `$status` is a registered cycle status (an engine default or an extension
+	 * registration). Write paths accept only registered statuses.
 	 *
 	 * @param string $status Status to check.
 	 */
-	public static function is_terminal( string $status ): bool {
-		return self::is_valid( $status ) && array() === self::transitions()[ $status ];
+	public static function is_registered( string $status ): bool {
+		return StatusRegistry::is_registered( StatusRegistry::KIND_CYCLE, $status );
 	}

 	/**
-	 * Whether a cycle may move from `$from` to `$to`. Unknown statuses report false.
-	 * Same-status calls also report false; {@see Cycle::set_status()} short-circuits
-	 * no-ops before consulting this table.
+	 * 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 $from Current status.
-	 * @param string $to   Target status.
-	 */
-	public static function is_transition_allowed( string $from, string $to ): bool {
-		if ( ! self::is_valid( $from ) || ! self::is_valid( $to ) ) {
-			return false;
-		}
-
-		return in_array( $to, self::transitions()[ $from ], true );
-	}
-
-	/**
-	 * Whether a cycle may move from `$from` to `$to`.
-	 *
-	 * Alias of {@see self::is_transition_allowed()}.
-	 *
-	 * @param string $from Current status.
-	 * @param string $to   Target status.
+	 * @param string $status Status to check.
 	 */
-	public static function can_transition( string $from, string $to ): bool {
-		return self::is_transition_allowed( $from, $to );
+	public static function is_valid( string $status ): bool {
+		return StatusRegistry::is_valid_slug( $status );
 	}

 	/**
-	 * Throw if `$from` -> `$to` is not an allowed transition. The canonical
-	 * enforcement entry point every status change flows through.
+	 * Whether `$status` is `billed` or `cancelled`.
 	 *
-	 * @param string $from Current status.
-	 * @param string $to   Target status.
-	 * @throws DomainException When the transition is rejected by {@see self::is_transition_allowed()}.
-	 */
-	public static function assert_transition_allowed( string $from, string $to ): void {
-		if ( ! self::is_transition_allowed( $from, $to ) ) {
-			throw new DomainException(
-				sprintf( 'CycleStatus: illegal status transition from "%s" to "%s".', $from, $to )
-			);
-		}
-	}
-
-	/**
-	 * Allowed transitions: current status => list of reachable statuses.
+	 * Interim: moves out of the engine with the renewal flow (only the repository's
+	 * snapshot skip in `hydrate_cycle()` reads this). Unknown and
+	 * extension-registered statuses report false.
 	 *
-	 * @return array<string, array<int, string>>
+	 * @param string $status Status to check.
 	 */
-	private static function transitions(): array {
-		return array(
-			self::PENDING    => array( self::PROCESSING, self::BILLED, self::FAILED, self::CANCELLED ),
-			self::PROCESSING => array( self::BILLED, self::FAILED, self::CANCELLED ),
-			self::BILLED     => array(),
-			self::FAILED     => array( self::PENDING, self::CANCELLED ),
-			self::CANCELLED  => array(),
-		);
+	public static function is_terminal( string $status ): bool {
+		return in_array( $status, array( self::BILLED, self::CANCELLED ), true );
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/StatusRegistry.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/StatusRegistry.php
new file mode 100644
index 00000000000..6ef258dac51
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/StatusRegistry.php
@@ -0,0 +1,155 @@
+<?php
+/**
+ * StatusRegistry - the set of registered contract and cycle 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
+ * 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
+ * written by a since-deactivated extension) still hydrate and round-trip
+ * unchanged; the registry never gates reads.
+ *
+ * Core zone: WordPress-free by design. No WP/Woo symbols, no time functions.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
+
+use InvalidArgumentException;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Static registry of status slugs per kind.
+ */
+final class StatusRegistry {
+
+	/**
+	 * Contract status kind.
+	 */
+	public const KIND_CONTRACT = 'contract';
+
+	/**
+	 * Cycle status kind.
+	 */
+	public const KIND_CYCLE = 'cycle';
+
+	/**
+	 * Longest accepted slug (the status columns are `varchar(20)`).
+	 */
+	private const MAX_LENGTH = 20;
+
+	/**
+	 * Slug format: lowercase alphanumeric words joined by single hyphens. Anchored with `\z`
+	 * (not `$`, which also matches before a trailing newline).
+	 */
+	private const SLUG_PATTERN = '/^[a-z0-9]+(?:-[a-z0-9]+)*\z/';
+
+	/**
+	 * Extension registrations, keyed by kind => list of slugs in registration
+	 * order. The engine defaults are intrinsic and never stored here.
+	 *
+	 * Static (not instance state) because the public registration API is itself
+	 * static - every consumer reaches the registry by class name.
+	 *
+	 * @var array<string, array<int, string>>
+	 */
+	private static $registered = array();
+
+	/**
+	 * Register an extension status slug for `$kind`.
+	 *
+	 * 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 $slug Status slug; must satisfy {@see self::is_valid_slug()}.
+	 * @throws InvalidArgumentException When the kind is unknown or the slug is malformed.
+	 */
+	public static function register( string $kind, string $slug ): void {
+		self::assert_known_kind( $kind );
+
+		if ( ! self::is_valid_slug( $slug ) ) {
+			throw new InvalidArgumentException(
+				sprintf(
+					'StatusRegistry: "%s" is not a valid status slug (lowercase letters, digits and single hyphens, at most %d characters).',
+					$slug,
+					self::MAX_LENGTH
+				)
+			);
+		}
+
+		if ( in_array( $slug, self::get_all( $kind ), true ) ) {
+			return;
+		}
+
+		self::$registered[ $kind ][] = $slug;
+	}
+
+	/**
+	 * 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 $slug Status slug.
+	 * @throws InvalidArgumentException When the kind is unknown.
+	 */
+	public static function is_registered( string $kind, string $slug ): bool {
+		return in_array( $slug, self::get_all( $kind ), true );
+	}
+
+	/**
+	 * 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}.
+	 * @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();
+
+		return array_merge( $defaults, self::$registered[ $kind ] ?? array() );
+	}
+
+	/**
+	 * Whether `$slug` satisfies the status slug format: lowercase letters and
+	 * digits in words joined by single hyphens, at most 20 characters.
+	 *
+	 * @param string $slug Candidate slug.
+	 */
+	public static function is_valid_slug( string $slug ): bool {
+		return strlen( $slug ) <= self::MAX_LENGTH && 1 === preg_match( self::SLUG_PATTERN, $slug );
+	}
+
+	/**
+	 * Clear every extension registration. The engine defaults remain.
+	 *
+	 * @internal Public only so test setUp/tearDown can isolate per-test state.
+	 *           Not part of the consumer API.
+	 */
+	public static function reset(): void {
+		self::$registered = array();
+	}
+
+	/**
+	 * Throw unless `$kind` is a known status kind.
+	 *
+	 * @param string $kind Kind to check.
+	 * @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 ) {
+			throw new InvalidArgumentException(
+				sprintf( 'StatusRegistry: unknown status kind "%s".', $kind )
+			);
+		}
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Renewal/RenewalCalculator.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Renewal/RenewalCalculator.php
index 2c66e7ca55b..d45da773209 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Renewal/RenewalCalculator.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Renewal/RenewalCalculator.php
@@ -111,7 +111,7 @@ final class RenewalCalculator {
 				'contract_id'       => $values['contract_id'] ?? null,
 				'sequence_no'       => $values['sequence_no'] ?? null,
 				'count'             => $values['count'] ?? null,
-				'status'            => CycleStatus::pending(),
+				'status'            => new CycleStatus( CycleStatus::PENDING ),
 				'starts_at_gmt'     => $start->format( 'Y-m-d H:i:s' ),
 				'ends_at_gmt'       => $end->format( 'Y-m-d H:i:s' ),
 				'expected_total'    => $values['expected_total'] ?? null,
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
index 6e03a723b5c..d819f4ed291 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
@@ -152,7 +152,7 @@ final class ContractFactory {
 				'contract_id'    => 0,
 				'sequence_no'    => 1,
 				'count'          => 1,
-				'status'         => CycleStatus::billed(),
+				'status'         => new CycleStatus( CycleStatus::BILLED ),
 				'order_id'       => $order->get_id(),
 				'extension_slug' => $plan->get_extension_slug(),
 				'starts_at_gmt'  => $starts_at,
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
index 0f227678842..6e01b577de1 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
@@ -7,8 +7,14 @@
  * contract down NOW (transition to cancelled, close any charge caught mid-flight,
  * announce it), while {@see self::cancel_at_period_end()} winds it down gracefully
  * (transition to pending-cancellation, stamp the end date, keep serving until the
- * period lapses). Lives under `Integration\Contracts` so contract lifecycle stays
- * separate from the renewal money-path.
+ * period lapses). Both modes disarm the contract's next-due moment themselves: the batch
+ * due scan keys on `next_payment_gmt` and a registered owner, so the flow stops renewals by
+ * clearing its own due moment rather than relying on status. Their preconditions are
+ * the flow's own, not rules of the status primitive. Lives under `Integration\Contracts`
+ * so contract lifecycle stays separate from the renewal money-path.
+ *
+ * Interim: moves out of the engine with the lifecycle flows (hold / reactivate /
+ * cancel and their routes).
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts
  */
@@ -57,14 +63,16 @@ final class Cancellation {
 	}

 	/**
-	 * Cancel `$contract`: transition to cancelled and close any mid-charge cycle.
+	 * Cancel `$contract`: move it to cancelled, disarm its next-due moment, and close any
+	 * mid-charge cycle.
 	 *
-	 * Status moves through the Core state machine ({@see Contract::set_status()}), which raises
-	 * a `DomainException` on an illegal transition. When the chain's most-recent cycle is still
-	 * `pending` (a charge caught mid-flight) it is transitioned `cancelled` so a stale claim is
-	 * not left open; a settled cycle is untouched. The due scan only selects active contracts,
-	 * so a cancelled contract simply stops being picked up - there is no per-contract schedule
-	 * to clear.
+	 * Only an active, on-hold or pending-cancellation contract can be cancelled; cancelling an
+	 * already cancelled contract is an idempotent no-op that still succeeds and fires the
+	 * action. Any other status - including one that is not registered - raises a
+	 * `DomainException`. The next-payment date and any hold anchor are cleared so the due scan
+	 * never selects the contract again. When the chain's most-recent cycle is still `pending`
+	 * (a charge caught mid-flight) it is transitioned `cancelled` so a stale claim is not left
+	 * open; a settled cycle is untouched.
 	 *
 	 * @param Contract $contract Contract to cancel. Must have an id.
 	 * @return bool True when the contract was cancelled and persisted.
@@ -77,8 +85,17 @@ final class Cancellation {
 			throw new RuntimeException( 'Cancellation::cancel(): cannot cancel a contract that has no id.' );
 		}

-		$previous = $contract->get_status();
-		$contract->set_status( ContractStatus::CANCELLED );
+		$previous   = $contract->get_status();
+		$cancelable = array( ContractStatus::ACTIVE, ContractStatus::ON_HOLD, ContractStatus::PENDING_CANCELLATION, ContractStatus::CANCELLED );
+		if ( ! in_array( $previous, $cancelable, true ) ) {
+			throw new \DomainException( 'Cancellation::cancel(): only an active, on-hold or pending-cancellation contract can be cancelled.' );
+		}
+
+		if ( ContractStatus::CANCELLED !== $previous ) {
+			$contract->set_status( ContractStatus::CANCELLED );
+			$contract->set_next_payment_gmt( null );
+			$contract->set_meta( Hold::ANCHOR_META_KEY, null );
+		}

 		// Compare-and-set on the status read above: a concurrent transition (another
 		// request, the renewal engine's settle) makes this write miss loudly rather
@@ -90,8 +107,8 @@ final class Cancellation {
 		// Close a charge caught mid-flight: a still-pending head cycle is cancelled so no stale
 		// claim is left open. A settled (billed/failed/cancelled) cycle is left as is.
 		$current = $this->contracts->find_chain_head( $id );
-		if ( null !== $current && $current->get_status()->equals( CycleStatus::pending() ) ) {
-			$current->set_status( CycleStatus::cancelled() );
+		if ( null !== $current && $current->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ) ) {
+			$current->set_status( new CycleStatus( CycleStatus::CANCELLED ) );
 			$this->contracts->update_cycle( $current );
 		}

@@ -106,26 +123,24 @@ final class Cancellation {
 	}

 	/**
-	 * Wind `$contract` down at the end of the current period: transition to
-	 * pending-cancellation and stamp the end date.
-	 *
-	 * Status moves through the Core state machine ({@see Contract::set_status()}), which
-	 * raises a `DomainException` on an illegal transition. The contract keeps serving
-	 * until the current period ends, so the next-payment moment is recorded as the
-	 * contract `end_gmt` (when not already set) for a first-class "cancels on" date, and
-	 * the next-payment date is deliberately LEFT in place so the contract lapses at the
-	 * date rather than being torn down now.
+	 * Wind `$contract` down at the end of the current period: move it to
+	 * pending-cancellation, stamp the end date, and disarm its next-due moment.
 	 *
-	 * The due scan already refuses to charge a non-active contract ({@see RenewalEngine::process()}
-	 * skips it with no order), so no renewal fires while it winds down.
+	 * Only an active or on-hold contract can be wound down; winding down an already
+	 * pending-cancellation contract is an idempotent no-op that still succeeds and fires the
+	 * action. Any other status - including one that is not registered - raises a
+	 * `DomainException`. The contract keeps serving until the current period ends, so the
+	 * next-due moment (the next-payment date, or for a held contract the hold anchor) is
+	 * recorded as the contract `end_gmt` when not already set, for a first-class "cancels on"
+	 * date. The next-payment date and any hold anchor are then cleared, so no renewal fires
+	 * while the contract winds down.
 	 *
-	 * TODO: terminating a PENDING_CANCELLATION contract (ACTIVE has lapsed) when its date
-	 * arrives - moving it to CANCELLED/EXPIRED at period end - is a follow-up slice. The
-	 * current dispatcher only skips a non-active contract; it does not yet transition it
-	 * terminal at the date, so a wound-down contract stays PENDING_CANCELLATION until a
-	 * later terminate-at-date pass lands. No charge occurs in the meantime.
+	 * TODO: terminating a PENDING_CANCELLATION contract when its `end_gmt` arrives - moving
+	 * it to CANCELLED/EXPIRED at period end - is a follow-up slice. The contract now has no
+	 * next-due moment, so it stays PENDING_CANCELLATION (and is never charged) until a later
+	 * terminate-at-date pass ends it at its `end_gmt`.
 	 *
-	 * @param Contract $contract Contract to wind down. Must have an id, and be ACTIVE.
+	 * @param Contract $contract Contract to wind down. Must have an id, and be ACTIVE or ON_HOLD.
 	 * @return bool True when the contract was wound down and persisted.
 	 * @throws RuntimeException If the contract has no id.
 	 * @throws \DomainException If the contract cannot be wound down from its current state, or its state changed concurrently.
@@ -137,13 +152,25 @@ final class Cancellation {
 		}

 		$previous = $contract->get_status();
-		$contract->set_status( ContractStatus::PENDING_CANCELLATION );
+		if ( ! in_array( $previous, array( ContractStatus::ACTIVE, ContractStatus::ON_HOLD, ContractStatus::PENDING_CANCELLATION ), true ) ) {
+			throw new \DomainException( 'Cancellation::cancel_at_period_end(): only an active or on-hold contract can be cancelled at period end.' );
+		}

-		// The end of the current period is the next-payment moment: the contract is
-		// honoured up to (not through) it. Record it as the contract end when not already
-		// set, so reads have a first-class "cancels on" date.
-		if ( null === $contract->get_end_gmt() && null !== $contract->get_next_payment_gmt() ) {
-			$contract->set_end_gmt( $contract->get_next_payment_gmt() );
+		if ( ContractStatus::PENDING_CANCELLATION !== $previous ) {
+			$contract->set_status( ContractStatus::PENDING_CANCELLATION );
+
+			// The end of the current period is the next-due moment: the contract is honoured
+			// up to (not through) it. A held contract's moment lives in the hold anchor.
+			$period_end = $contract->get_next_payment_gmt();
+			if ( null === $period_end && ContractStatus::ON_HOLD === $previous ) {
+				$period_end = Hold::read_anchor( $contract );
+			}
+			if ( null === $contract->get_end_gmt() && null !== $period_end ) {
+				$contract->set_end_gmt( $period_end );
+			}
+
+			$contract->set_meta( Hold::ANCHOR_META_KEY, null );
+			$contract->set_next_payment_gmt( null );
 		}

 		// Compare-and-set on the status read above: a concurrent transition makes this
@@ -152,8 +179,6 @@ final class Cancellation {
 			throw new \DomainException( 'Cancellation::cancel_at_period_end(): the contract state changed concurrently; nothing was written.' );
 		}

-		// Intentionally leave the next-payment date in place: the contract lapses at the date (see the TODO above).
-
 		/**
 		 * Fires after a contract is set to wind down at the end of the current period.
 		 *
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php
index f9f83dd87ba..6a9533929c2 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php
@@ -3,12 +3,16 @@
  * Hold - put an active subscription contract on hold (suspend billing).
  *
  * A focused contract-management operation (deliberately not a catch-all manager),
- * mirroring {@see Cancellation}: transition the contract ACTIVE -> ON_HOLD through the
- * Core state machine and announce it. No charge fires while held because the batch due
- * scan only bills active contracts, so there is no per-contract schedule to clear. The
- * contract keeps its `next_payment_gmt` so the held duration is recoverable on
- * {@see Reactivation}. Lives under `Integration\Contracts` so contract lifecycle stays
- * separate from the renewal money-path.
+ * mirroring {@see Cancellation}: move the contract ACTIVE -> ON_HOLD, disarm its
+ * next-due moment, and announce it. The batch due scan keys on `next_payment_gmt` and a
+ * registered owner (its active-status predicate is a renewal-flow condition, see
+ * {@see ContractRepository::find_due()}), so the flow disarms its own due moment rather
+ * than relying on status to stop billing. The cleared moment is kept in contract meta
+ * ({@see self::ANCHOR_META_KEY}) so {@see Reactivation} can recompute the schedule
+ * forward from it. Its preconditions are its own, not a rule of the status primitive.
+ *
+ * Interim: moves out of the engine with the lifecycle flows (hold / reactivate /
+ * cancel and their routes).
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts
  */
@@ -17,6 +21,8 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts;

+use DateTimeImmutable;
+use DateTimeZone;
 use RuntimeException;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
@@ -34,6 +40,15 @@ final class Hold {
 	 */
 	public const CONTRACT_HELD_ACTION = 'woocommerce_subscriptions_engine_contract_held';

+	/**
+	 * Contract meta key holding the next-due moment cleared by a hold - the moment
+	 * {@see Reactivation} recomputes forward from.
+	 *
+	 * Interim: moves out of the engine with the lifecycle flows (hold / reactivate /
+	 * cancel and their routes).
+	 */
+	public const ANCHOR_META_KEY = '_hold_next_payment_gmt';
+
 	/**
 	 * Contract repository.
 	 *
@@ -51,17 +66,15 @@ final class Hold {
 	}

 	/**
-	 * Hold `$contract`: transition it to on-hold.
+	 * Hold `$contract`: move it to on-hold and disarm its next-due moment.
 	 *
-	 * Status moves through the Core state machine ({@see Contract::set_status()}), which
-	 * raises a `DomainException` on an illegal transition (e.g. holding a terminal
-	 * contract). The current cycle is immutable and is NOT touched; only the live
-	 * contract status moves. No charge fires while held because the batch due scan only
-	 * bills active contracts - there is no per-contract schedule to clear. The
-	 * `next_payment_gmt` is preserved so {@see Reactivation} can recompute the schedule
-	 * forward.
+	 * Only an active contract can be held; holding an already on-hold contract is an
+	 * idempotent no-op that still succeeds and fires the action (nothing is rewritten,
+	 * so the stored anchor survives). Any other status - including one that is not
+	 * registered - raises a `DomainException`. The current cycle is immutable and is
+	 * NOT touched.
 	 *
-	 * @param Contract $contract Contract to hold. Must have an id, and be ACTIVE.
+	 * @param Contract $contract Contract to hold. Must have an id, and be ACTIVE (or already ON_HOLD).
 	 * @return bool True when the contract was held and persisted.
 	 * @throws RuntimeException If the contract has no id.
 	 * @throws \DomainException If the contract cannot be held from its current state, or its state changed concurrently.
@@ -73,11 +86,21 @@ final class Hold {
 		}

 		$previous = $contract->get_status();
-		$contract->set_status( ContractStatus::ON_HOLD );
+		if ( ContractStatus::ACTIVE !== $previous && ContractStatus::ON_HOLD !== $previous ) {
+			throw new \DomainException( 'Hold::hold(): only an active contract can be held.' );
+		}
+
+		if ( ContractStatus::ACTIVE === $previous ) {
+			$this->persist_anchor( $contract );
+
+			$contract->set_status( ContractStatus::ON_HOLD );
+			$contract->set_next_payment_gmt( null );
+		}

 		// Compare-and-set on the status read above: a concurrent transition (another
 		// request, the renewal engine) makes this write miss loudly rather than be
-		// clobbered.
+		// clobbered. The anchor is already stored, so a reader that sees the contract
+		// on hold always finds it.
 		if ( ! $this->contracts->update_if_status( $contract, $previous ) ) {
 			throw new \DomainException( 'Hold::hold(): the contract state changed concurrently; nothing was written.' );
 		}
@@ -91,4 +114,65 @@ final class Hold {

 		return true;
 	}
+
+	/**
+	 * Store the next-due moment as the hold anchor while the contract is still active,
+	 * before the hold disarms it.
+	 *
+	 * The repository writes the row and its meta as separate statements (no
+	 * transaction), so the anchor is written and read back first: if it did not
+	 * persist, nothing has been disarmed yet and the hold aborts. A contract with no
+	 * next-due moment stores no anchor (null removes the key). An anchor left behind by
+	 * a hold that then loses its compare-and-set is harmless: the next hold overwrites
+	 * it and cancellation clears it.
+	 *
+	 * @param Contract $contract Active contract about to be held. Must have an id.
+	 * @throws \DomainException If the contract stopped being active concurrently.
+	 * @throws RuntimeException If the anchor could not be stored.
+	 */
+	private function persist_anchor( Contract $contract ): void {
+		$next_payment_gmt = $contract->get_next_payment_gmt();
+		$contract->set_meta( self::ANCHOR_META_KEY, $next_payment_gmt );
+
+		if ( ! $this->contracts->update_if_status( $contract, ContractStatus::ACTIVE ) ) {
+			throw new \DomainException( 'Hold::hold(): the contract state changed concurrently; nothing was written.' );
+		}
+
+		$stored = $this->contracts->find( (int) $contract->get_id() );
+		$anchor = null === $stored ? null : ( $stored->get_meta()[ self::ANCHOR_META_KEY ] ?? null );
+		if ( $anchor !== $next_payment_gmt ) {
+			throw new RuntimeException( 'Hold::hold(): the hold anchor could not be stored; the contract was not held.' );
+		}
+	}
+
+	/**
+	 * The hold anchor stored on `$contract`, or null when there is none.
+	 *
+	 * The one reader of {@see self::ANCHOR_META_KEY}: a value that is not a well-formed
+	 * GMT datetime (`Y-m-d H:i:s`) counts as absent and is logged, since a flow resuming
+	 * or ending from it would otherwise act on garbage.
+	 *
+	 * @param Contract $contract Contract to read.
+	 */
+	public static function read_anchor( Contract $contract ): ?string {
+		$anchor = $contract->get_meta()[ self::ANCHOR_META_KEY ] ?? '';
+		if ( '' === $anchor ) {
+			return null;
+		}
+
+		$parsed = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $anchor, new DateTimeZone( 'UTC' ) );
+		if ( false !== $parsed && $parsed->format( 'Y-m-d H:i:s' ) === $anchor ) {
+			return $anchor;
+		}
+
+		wc_get_logger()->warning(
+			sprintf( 'Hold: contract %d has a malformed hold anchor; it is ignored.', (int) $contract->get_id() ),
+			array(
+				'source'      => 'woocommerce-subscriptions-engine',
+				'contract_id' => $contract->get_id(),
+			)
+		);
+
+		return null;
+	}
 }
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 8ff46ddcd41..e7629263066 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
@@ -3,11 +3,15 @@
  * Reactivation - resume a held subscription contract (resume billing).
  *
  * A focused contract-management operation (deliberately not a catch-all manager),
- * mirroring {@see Cancellation}: transition the contract ON_HOLD -> ACTIVE through the
- * Core state machine, recompute the next-payment date forward, and announce it. Setting
- * the contract active with a forward next-payment date is the re-arm: the batch due scan
- * picks it up at the date. Lives under `Integration\Contracts` so contract lifecycle
- * stays separate from the renewal money-path.
+ * mirroring {@see Cancellation}: move the contract ON_HOLD -> ACTIVE, re-arm its
+ * next-due moment forward from the hold anchor, and announce it. Writing the forward
+ * next-payment date is the re-arm (the batch due scan, keyed on `next_payment_gmt` and a
+ * registered owner, picks the contract up at that date), not the status change alone.
+ * Lives under `Integration\Contracts` so contract lifecycle stays separate from the
+ * renewal money-path. Its preconditions are its own, not a rule of the status primitive.
+ *
+ * Interim: moves out of the engine with the lifecycle flows (hold / reactivate /
+ * cancel and their routes).
  *
  * `$now` is read at this integration boundary (or injected for tests) and the cadence
  * math is delegated to the clock-free {@see RenewalCalculator}, so the engine keeps a
@@ -81,16 +85,18 @@ final class Reactivation {
 	}

 	/**
-	 * Reactivate `$contract`: transition to active, recompute the next-payment date
-	 * forward, and persist.
+	 * Reactivate `$contract`: move it to active, re-arm the next-payment date forward,
+	 * and persist.
 	 *
-	 * Status moves through the Core state machine ({@see Contract::set_status()}), which
-	 * raises a `DomainException` on an illegal transition (e.g. reactivating a terminal
-	 * contract). The next date is recomputed through the single seam
+	 * The anchor the date is recomputed from is the stored `next_payment_gmt` when one is
+	 * set (hold clears it, so a value means it was re-armed deliberately, or the contract
+	 * was held before hold disarmed it), else the next-due moment stashed by {@see Hold}
+	 * ({@see Hold::ANCHOR_META_KEY}), read through {@see Hold::read_anchor()}, which logs
+	 * and ignores a malformed value. The
+	 * anchor meta is removed. The date is recomputed through the single seam
 	 * ({@see self::recompute_next_payment()}) so a contract that sat on hold past its due
-	 * date does not fire an immediate, back-dated renewal the moment it resumes. Setting
-	 * the contract active with that forward date is the re-arm - the batch due scan picks
-	 * it up when the date arrives; a null next-payment simply leaves it unscheduled.
+	 * date does not fire an immediate, back-dated renewal the moment it resumes; with no
+	 * anchor the contract simply stays unscheduled.
 	 *
 	 * @param Contract               $contract Contract to reactivate. Must have an id, and be ON_HOLD.
 	 * @param DateTimeImmutable|null $now      The current moment; read from the wall clock (UTC) when omitted.
@@ -104,11 +110,9 @@ final class Reactivation {
 			throw new RuntimeException( 'Reactivation::reactivate(): cannot reactivate a contract that has no id.' );
 		}

-		// Only a held contract reactivates. The state machine rejects terminal states on
-		// its own, but an already-ACTIVE contract would silently no-op through it and
-		// still reach the recompute below - and rolling a past-due active contract's
-		// next-payment date forward would skip the charge the due scan owes it. Reject
-		// it explicitly before any date math.
+		// Only a held contract reactivates. In particular an already-ACTIVE contract must
+		// not reach the recompute below: rolling a past-due active contract's next-payment
+		// date forward would skip the charge the due scan owes it.
 		if ( ContractStatus::ON_HOLD !== $contract->get_status() ) {
 			throw new DomainException( 'Reactivation::reactivate(): only an on-hold contract can be reactivated.' );
 		}
@@ -116,8 +120,13 @@ final class Reactivation {
 		// Read the clock at the integration boundary so the Core cadence math stays clock-free.
 		$now = ( $now ?? new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->setTimezone( new DateTimeZone( 'UTC' ) );

+		// A next-due moment set while held was re-armed deliberately (hold clears it), so
+		// it wins; otherwise resume from the hold anchor, ignoring a malformed one.
+		$anchor = $contract->get_next_payment_gmt() ?? Hold::read_anchor( $contract );
+
+		$contract->set_next_payment_gmt( $this->recompute_next_payment( $contract, $anchor, $now, $this->billing_policy( $contract ) ) );
+		$contract->set_meta( Hold::ANCHOR_META_KEY, null );
 		$contract->set_status( ContractStatus::ACTIVE );
-		$contract->set_next_payment_gmt( $this->recompute_next_payment( $contract, $now, $this->billing_policy( $contract ) ) );

 		// Compare-and-set on the ON_HOLD status read above: a concurrent transition
 		// (another request, the renewal engine) makes this write miss loudly rather
@@ -127,7 +136,8 @@ final class Reactivation {
 		}

 		/**
-		 * Fires after a held contract is reactivated and its renewal re-armed.
+		 * Fires after a held contract is reactivated: its renewal is re-armed, or left
+		 * unscheduled when there was no next-due moment to resume from.
 		 *
 		 * @param Contract $contract The reactivated contract.
 		 */
@@ -146,7 +156,7 @@ final class Reactivation {
 	 * Default = "Model 1" (suspend without mutating the immutable current cycle;
 	 * reactivate recomputes the next date FORWARD, with no catch-up / back-charge):
 	 *
-	 *  - A future stored date is kept as-is - resuming before the date arrives changes
+	 *  - A future anchor date is kept as-is - resuming before the date arrives changes
 	 *    nothing.
 	 *  - A past-due date (the contract sat on hold past it) is rolled forward by whole
 	 *    billing cadences (via {@see RenewalCalculator::next_bill_date()}) until it is in
@@ -154,24 +164,24 @@ final class Reactivation {
 	 *    policy available to compute a cadence, the date is floored at `$now` (the due
 	 *    scan then bills the resumed contract on its next pass rather than for the held
 	 *    window).
-	 *  - A contract with no scheduled next payment stays unscheduled.
+	 *  - A contract with no anchor (no scheduled next payment when held) stays unscheduled.
 	 *
 	 * Models 2 (resume immediately and charge for the held period) and 3 (extend the end
 	 * date by the held duration) are deliberately NOT implemented - do not add them here
 	 * until the product decision lands.
 	 *
-	 * @param Contract           $contract The contract being reactivated.
+	 * @param Contract           $contract The contract being reactivated (for the log line).
+	 * @param string|null        $anchor   The GMT next-due moment to recompute from, or null.
 	 * @param DateTimeImmutable  $now      The current moment (UTC; injected at the boundary).
 	 * @param BillingPolicy|null $policy   The plan billing policy for the forward roll, or null.
 	 * @return string|null The recomputed next-payment GMT string, or null when unscheduled.
 	 */
-	private function recompute_next_payment( Contract $contract, DateTimeImmutable $now, ?BillingPolicy $policy ): ?string {
-		$next_payment_gmt = $contract->get_next_payment_gmt();
-		if ( null === $next_payment_gmt ) {
+	private function recompute_next_payment( Contract $contract, ?string $anchor, DateTimeImmutable $now, ?BillingPolicy $policy ): ?string {
+		if ( null === $anchor ) {
 			return null;
 		}

-		$next = new DateTimeImmutable( $next_payment_gmt, new DateTimeZone( 'UTC' ) );
+		$next = new DateTimeImmutable( $anchor, new DateTimeZone( 'UTC' ) );

 		// Still in the future: resuming before the date arrives keeps the schedule.
 		if ( $next > $now ) {
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalDispatcher.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalDispatcher.php
index 3be141144f3..4613a47c753 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalDispatcher.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalDispatcher.php
@@ -3,8 +3,12 @@
  * RenewalDispatcher - the autonomous batch renewal scanner.
  *
  * One recurring Action Scheduler job drives every scheduled renewal: each tick runs
- * {@see self::run_batch()} over the cycle-aware due-index. The class owns the recurring
- * action's registration, scheduling, and hook callback.
+ * {@see self::run_batch()} over the cycle-aware due-index. Due-ness is owner-scoped: a
+ * contract is due when its next-due moment has passed and its owner is a registered
+ * consumer, so an empty consumer registry selects nothing (the scan's interim renewal-flow
+ * predicates, active status among them, are described on
+ * {@see ContractRepository::find_due()}). The class owns the recurring action's
+ * registration, scheduling, and hook callback.
  *
  * The create-as-claim ({@see RenewalEngine}) plus the cycle crash-recovery lease keep
  * overlap correct, so the scan needs no claim of its own: a contract picked up twice
@@ -127,9 +131,10 @@ final class RenewalDispatcher {
 	 * Enqueue the recurring scan action when one is not already scheduled.
 	 *
 	 * Call once Action Scheduler is available (Bootstrap runs it on `action_scheduler_init`,
-	 * the moment AS declares its `as_*` functions ready). Gated on the consumer registry: a store with no consumer extension runs no
-	 * renewals, so it carries no recurring scan action either - one already scheduled is
-	 * removed on the first gated boot after the last consumer deactivates. To avoid an Action Scheduler
+	 * the moment AS declares its `as_*` functions ready). Skipped while the consumer registry
+	 * is empty: the due scan is scoped to registered owners, so a store with no consumer
+	 * extension has nothing due and carries no recurring scan action either (an optimization -
+	 * one already scheduled is removed on the first boot after the last consumer deactivates). To avoid an Action Scheduler
 	 * store query on every request, a positive result is cached in an autoloaded option and
 	 * re-verified only once per re-check window - bounded staleness that self-heals if the
 	 * action is ever cleared. Within a re-verify it still guards with the `is_scheduled()`
@@ -200,12 +205,13 @@ final class RenewalDispatcher {
 	}

 	/**
-	 * Run one scan tick over up to `$limit` due contracts: gate, then drive every due renewal.
+	 * Run one scan tick over up to `$limit` due contracts.
 	 *
-	 * The processing gate comes first - with no registered consumer the engine charges
-	 * nothing and the run returns immediately. Otherwise the cycle-aware scan returns the
-	 * actionable contracts due at `$now`; each is run through read-only selection and, when a
-	 * cycle is due, billed via {@see RenewalEngine::process()}. A pre-flight impossibility
+	 * The scan is owner-scoped: the registered consumers are read once and passed to
+	 * {@see ContractRepository::find_due()}, so only their contracts are selected; with no
+	 * consumer registered the tick logs that and charges nothing. The scan returns the actionable
+	 * contracts due at `$now`; each is run through read-only selection and, when a cycle is due,
+	 * billed via {@see RenewalEngine::process()}. A pre-flight impossibility
 	 * ({@see RenewalNotProcessable}) parks the contract; any other throw is logged - so one bad
 	 * contract cannot stall the batch. A backlog larger than `$limit` drains over successive ticks.
 	 *
@@ -220,7 +226,10 @@ final class RenewalDispatcher {
 			return 0;
 		}

-		if ( ConsumerRegistry::is_empty() ) {
+		// Read the owner set once per tick; with nobody registered there is nothing to scan,
+		// and saying so in the log beats a silent empty batch.
+		$owners = ConsumerRegistry::all();
+		if ( array() === $owners ) {
 			wc_get_logger()->info(
 				'RenewalDispatcher::run(): no consumer extension is registered - skipping the renewal scan (charging nothing).',
 				array( 'source' => self::LOG_SOURCE )
@@ -229,7 +238,7 @@ final class RenewalDispatcher {
 		}

 		$now        = $now ?? new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
-		$candidates = $this->contracts->find_due( $now, $limit );
+		$candidates = $this->contracts->find_due( $now, $limit, $owners );

 		$billed  = 0;
 		$skipped = 0;
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 4b0a78b86a2..fe021b7e95d 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
@@ -208,8 +208,10 @@ final class RenewalEngine {
 	 *
 	 * The structural invariants it does enforce keep the money-path safe whatever the caller:
 	 * it skips (logging, never throwing - a scheduled action would retry a permanent condition
-	 * forever) when the contract is gone, gateway-scheduled, or inactive, and refuses a cycle
-	 * that is neither the head nor its immediate successor (no billing a gap). The claim is the
+	 * forever) when the contract is gone, gateway-scheduled, or not active, and refuses a cycle
+	 * that is neither the head nor its immediate successor (no billing a gap). The non-active
+	 * skip never parks: the engine does not clear the next-due moment of a contract whose
+	 * status it did not set. The claim is the
 	 * concurrency gate: appending the successor collides on `UNIQUE(contract_id, kind, count)`
 	 * and the head is reclaimed only through the lease compare-and-set, so a cycle is charged at
 	 * most once even under overlapping runs. Order reconciliation follows the claim, so the
@@ -217,8 +219,9 @@ final class RenewalEngine {
 	 *
 	 * Throws {@see RenewalNotProcessable} for a pre-flight impossibility (no chain, an
 	 * unresolvable plan, a non-adjacent count, a gateway that cannot charge renewals) so the
-	 * caller can park; returns null for an idempotent no-op (a live claim, an already-settled
-	 * cycle, an unbuildable order).
+	 * scheduled caller can park and a manual caller can return null; returns null for an
+	 * idempotent no-op (a non-active contract, a live claim, an already-settled cycle, an
+	 * unbuildable order).
 	 *
 	 * @param RenewalIntent     $intent The contract and cycle count to bill.
 	 * @param DateTimeImmutable $now    The processing moment (the lease clock for a claim).
@@ -252,6 +255,10 @@ final class RenewalEngine {
 			return null;
 		}

+		// Interim: moves out of the engine with the renewal flow.
+		// The due scan already selects only active contracts; a manual or racing caller that
+		// reaches a non-active one is skipped without parking, so its next-due moment is left
+		// for whichever flow set its status.
 		if ( ContractStatus::ACTIVE !== $contract->get_status() ) {
 			wc_get_logger()->info(
 				sprintf( 'RenewalEngine::process(): contract %d is %s, not active - skipping renewal. No order created.', $contract_id, $contract->get_status() ),
@@ -510,7 +517,7 @@ final class RenewalEngine {
 			return null;
 		}

-		if ( $head->get_status()->equals( CycleStatus::pending() ) && $this->lease_has_expired( $head, $now ) ) {
+		if ( $head->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ) && $this->lease_has_expired( $head, $now ) ) {
 			// Crash recovery, race-safe: only the caller whose CAS UPDATE matches the
 			// still-expired row reclaims it; a concurrent worker that already extended the
 			// lease leaves this caller matching zero rows, so it skips.
@@ -542,7 +549,7 @@ final class RenewalEngine {

 		// Admin retry: flip a failed head back to pending and re-attempt its charge. Scheduled
 		// selection never routes a failed head here; only a manual trigger does.
-		if ( $head->get_status()->equals( CycleStatus::failed() ) ) {
+		if ( $head->get_status()->equals( new CycleStatus( CycleStatus::FAILED ) ) ) {
 			// Race-safe: only the caller whose CAS UPDATE matches the still-failed row wins.
 			if ( $this->contracts->reclaim_failed_cycle( (int) $head->get_id(), self::LEASE_TTL_SECONDS ) ) {
 				wc_get_logger()->info(
@@ -722,7 +729,7 @@ final class RenewalEngine {
 			}
 			// Sync the entity with the row the CAS just wrote, for the action payload.
 			$cycle->set_order_id( $order->get_id() );
-			$cycle->set_status( CycleStatus::billed() );
+			$cycle->set_status( new CycleStatus( CycleStatus::BILLED ) );
 			$cycle->set_claimed_until_gmt( null );

 			// Advance to the period actually billed (this cycle's end), not a recomputed one;
@@ -766,7 +773,7 @@ final class RenewalEngine {
 				return;
 			}
 			$cycle->set_order_id( $order->get_id() );
-			$cycle->set_status( CycleStatus::failed() );
+			$cycle->set_status( new CycleStatus( CycleStatus::FAILED ) );
 			$cycle->set_reason( 'gateway-charge-failed' );
 			$cycle->set_claimed_until_gmt( null );

@@ -1087,19 +1094,22 @@ final class RenewalEngine {
 	 * vanished mid-park, a write error) must not stall the rest of the batch. On failure the
 	 * contract simply stays due and the park is re-attempted next tick.
 	 *
+	 * Only an active contract is parked: one that stopped being active since it was selected
+	 * already left the due set, and its next-due moment belongs to whoever changed its status.
+	 *
 	 * @param int $contract_id The contract to remove from the due set.
 	 */
 	public function park( int $contract_id ): void {
 		try {
 			$contract = $this->contracts->find( $contract_id );
-			if ( null === $contract ) {
+			if ( null === $contract || ContractStatus::ACTIVE !== $contract->get_status() ) {
 				return;
 			}

 			$contract->set_next_payment_gmt( null );
-			// Conditioned on the status just read: a lifecycle transition racing the
-			// park must not be clobbered - the contract is out of the due set either way.
-			$this->contracts->update_if_status( $contract, $contract->get_status() );
+			// Conditioned on active: a status change racing the park must not be
+			// clobbered, nor have its next-due moment cleared.
+			$this->contracts->update_if_status( $contract, ContractStatus::ACTIVE );
 		} catch ( Throwable $e ) {
 			wc_get_logger()->error(
 				sprintf( 'RenewalEngine::park(): failed to park contract %d - %s', $contract_id, $e->getMessage() ),
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
index 27abb9f8eb0..4240f5d5fea 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
@@ -422,7 +422,7 @@ final class ContractRepository {
 		$params  = array();

 		$status = isset( $args['status'] ) && is_string( $args['status'] ) ? $args['status'] : '';
-		if ( '' !== $status && ContractStatus::is_valid( $status ) ) {
+		if ( '' !== $status && ContractStatus::is_registered( $status ) ) {
 			$clauses[] = 'status = %s';
 			$params[]  = $status;
 		}
@@ -498,13 +498,14 @@ final class ContractRepository {

 	/**
 	 * The contract count per status - the views bar's read. One `GROUP BY status` scan,
-	 * returned as a map keyed by EVERY {@see ContractStatus::all()} value (absent statuses
-	 * filled with 0) and in that order, so a consumer can render a fixed set of views
-	 * without knowing which statuses currently have rows. The `All` total is the caller's
-	 * `array_sum()`. Independent of any search / paging (WC-style: the views count the whole
-	 * store, not the current page).
-	 *
-	 * @return array<string, int> Status => count, every known status present.
+	 * returned as a map keyed by EVERY registered contract status ({@see ContractStatus::get_all()}:
+	 * the engine defaults plus extension registrations; absent statuses filled with 0) and in
+	 * that order, so a consumer can render a fixed set of views without knowing which statuses
+	 * currently have rows. A stored status that is not registered is not counted as a key. The
+	 * `All` total is the caller's `array_sum()`. Independent of any search / paging (WC-style:
+	 * the views count the whole store, not the current page).
+	 *
+	 * @return array<string, int> Status => count, every registered status present.
 	 */
 	public function count_by_status(): array {
 		global $wpdb;
@@ -514,9 +515,9 @@ final class ContractRepository {
 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
 		$rows = $wpdb->get_results( "SELECT status, COUNT(*) AS total FROM {$table} GROUP BY status", ARRAY_A );

-		// Seed every known status at 0 so the map is complete and stably ordered.
+		// Seed every registered status at 0 so the map is complete and stably ordered.
 		$counts = array();
-		foreach ( ContractStatus::all() as $status ) {
+		foreach ( ContractStatus::get_all() as $status ) {
 			$counts[ $status ] = 0;
 		}

@@ -525,8 +526,8 @@ final class ContractRepository {
 				continue;
 			}
 			$status = ScalarCoercion::coerce_string( $row['status'] ?? '' );
-			// A row whose status has drifted outside the known set is ignored, not added
-			// as a stray key - the map stays exactly ContractStatus::all().
+			// A row whose stored status is not registered is ignored, not added as a
+			// stray key - the map stays exactly ContractStatus::get_all().
 			if ( array_key_exists( $status, $counts ) ) {
 				$counts[ $status ] = ScalarCoercion::coerce_int( $row['total'] ?? 0 );
 			}
@@ -651,38 +652,74 @@ final class ContractRepository {

 	/**
 	 * Contracts actionable for renewal at `$now`, oldest-due first - the batch dispatcher's scan.
-	 * Active, primitive-scheduled contracts whose `next_payment_gmt` has arrived, joined to their
-	 * head cycle so the scan can filter to the ones actually chargeable now:
-	 *
-	 * - head `billed`/`cancelled` and its period has ended (`ends_at_gmt <= now`) -> advance-ready;
-	 * - head `pending` with an expired crash-recovery lease (`claimed_until <= now`) -> reclaim-ready.
-	 *
-	 * A head that is `failed` (awaits dunning), `processing` (awaits its gateway), or `pending`
-	 * with a live lease is deliberately excluded. Because that filter is in SQL, `LIMIT` counts
-	 * only actionable rows, so a cluster of non-actionable heads (a stuck gateway, a backlog of
-	 * declines) cannot occupy the batch and starve healthy renewals behind them. Gateway-scheduled
-	 * contracts are excluded (the gateway owns their renewal); a null `next_payment_gmt` never
-	 * matches the `<=` comparison. Driven by the `due_contract (status, next_payment_gmt)` index;
-	 * the head cycle is joined per candidate via the `chain_seq` UNIQUE index. Returns the head
-	 * fields selection needs, so the dispatcher does not re-load the head to decide what to bill.
-	 *
-	 * @param DateTimeImmutable $now   The cutoff moment; contracts due at or before it.
-	 * @param int               $limit Maximum rows to return (the batch size).
+	 *
+	 * A contract is due when its next-due moment (`next_payment_gmt`) has passed AND its owner
+	 * (`extension_slug`) is one of `$owners` - the dispatcher passes the registered consumers.
+	 * A contract with a null or unlisted owner waits untouched (its next-due moment is never
+	 * rewritten) until its owner is listed; with no owners the scan returns nothing. A null
+	 * `next_payment_gmt` never matches the `<=` comparison.
+	 *
+	 * Interim: moves out of the engine with the renewal flow (the predicates below; the scan
+	 * then stays owner-scoped only). Until then the scan's only caller is the engine renewal
+	 * flow, so it keeps just what that flow can charge now:
+	 *
+	 * - the contract is `active` (status is otherwise opaque engine data; a contract in any other
+	 *   status, including an extension-registered one, is simply not selected and its next-due
+	 *   moment is left as is);
+	 * - the contract is not gateway-scheduled (the gateway owns its renewal);
+	 * - its head cycle is chargeable now:
+	 *
+	 *   - head `billed` and its period has ended (`ends_at_gmt <= now`) -> advance-ready;
+	 *   - head `pending` with an expired crash-recovery lease (`claimed_until <= now`) -> reclaim-ready.
+	 *
+	 * Any other head (`failed` awaiting dunning, `processing` awaiting its gateway, `pending` with
+	 * a live lease) is excluded in SQL, so `LIMIT` counts only actionable rows and a cluster of
+	 * non-actionable heads cannot starve healthy renewals. Driven by the
+	 * `due_owner (extension_slug, next_payment_gmt)` index (status and `schedule_source` are
+	 * residual filters); the head cycle is joined per candidate
+	 * via the `chain_seq` UNIQUE index. Returns the head fields selection needs, so the dispatcher
+	 * does not re-load the head to decide what to bill.
+	 *
+	 * @param DateTimeImmutable  $now   The cutoff moment; contracts due at or before it.
+	 * @param int                $limit  Maximum rows to return (the batch size).
+	 * @param array<int, string> $owners Owner slugs whose contracts may be selected.
 	 * @return array<int, RenewalCandidate> Actionable renewal candidates, oldest-due first.
 	 */
-	public function find_due( DateTimeImmutable $now, int $limit ): array {
+	public function find_due( DateTimeImmutable $now, int $limit, array $owners ): array {
 		if ( $limit < 1 ) {
 			return array();
 		}

+		$owners = array_values( array_unique( array_map( 'strval', $owners ) ) );
+		if ( array() === $owners ) {
+			return array();
+		}
+
 		global $wpdb;

-		$contracts = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
-		$cycles    = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
-		$cutoff    = $now->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' );
+		$contracts          = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$cycles             = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+		$cutoff             = $now->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' );
+		$owner_placeholders = implode( ', ', array_fill( 0, count( $owners ), '%s' ) );
+
+		$args = array_merge(
+			array( Cycle::KIND_BILLING, Cycle::KIND_BILLING ),
+			$owners,
+			array(
+				ContractStatus::ACTIVE,
+				Contract::SCHEDULE_SOURCE_GATEWAY,
+				$cutoff,
+				CycleStatus::BILLED,
+				$cutoff,
+				CycleStatus::PENDING,
+				$cutoff,
+				$limit,
+			)
+		);

 		// Table names cannot be bound, so they are interpolated; every value is a placeholder.
-		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		// The owner placeholder list is generated, one `%s` per registered owner in `$args`.
+		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
 		$rows = $wpdb->get_results(
 			$wpdb->prepare(
 				"SELECT c.id AS contract_id, cy.count AS head_count, cy.status AS head_status, cy.ends_at_gmt AS head_ends_at_gmt
@@ -690,27 +727,18 @@ final class ContractRepository {
 				JOIN {$cycles} cy
 				  ON cy.contract_id = c.id AND cy.kind = %s
 				 AND cy.sequence_no = ( SELECT MAX(s.sequence_no) FROM {$cycles} s WHERE s.contract_id = c.id AND s.kind = %s )
-				WHERE c.status = %s AND c.schedule_source <> %s AND c.next_payment_gmt IS NOT NULL AND c.next_payment_gmt <= %s
+				WHERE c.extension_slug IN ( {$owner_placeholders} ) AND c.status = %s AND c.schedule_source <> %s AND c.next_payment_gmt IS NOT NULL AND c.next_payment_gmt <= %s
 				  AND (
 				        ( cy.status = %s AND cy.ends_at_gmt <= %s )
 				     OR ( cy.status = %s AND cy.claimed_until IS NOT NULL AND cy.claimed_until <= %s )
 				      )
 				ORDER BY c.next_payment_gmt ASC, c.id ASC
 				LIMIT %d",
-				Cycle::KIND_BILLING,
-				Cycle::KIND_BILLING,
-				ContractStatus::ACTIVE,
-				Contract::SCHEDULE_SOURCE_GATEWAY,
-				$cutoff,
-				CycleStatus::BILLED,
-				$cutoff,
-				CycleStatus::PENDING,
-				$cutoff,
-				$limit
+				$args
 			),
 			ARRAY_A
 		);
-		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber

 		// A failed scan otherwise reads exactly like "nothing due" and renewals stall
 		// store-wide with no signal; the return stays empty either way.
@@ -963,15 +991,24 @@ final class ContractRepository {
 	 * can race across workers), exactly one caller matches the row and wins; the rest match
 	 * zero rows, so status transitions - and the actions fired on them - happen exactly once.
 	 *
+	 * `$to_status` must be a registered cycle status (the write-path allowlist). `$from_status`
+	 * is only the CAS predicate and is not validated, so a cycle carrying an unknown stored
+	 * status can still be settled out of it.
+	 *
 	 * @param int         $cycle_id    The cycle to settle.
 	 * @param string      $from_status The status the caller read; the CAS predicate.
-	 * @param string      $to_status   The settled status to write.
+	 * @param string      $to_status   The settled status to write; must be registered.
 	 * @param int         $order_id    The renewal order carrying the outcome.
 	 * @param string|null $reason      Failure reason to record, or null to clear.
+	 * @throws \DomainException If `$to_status` is not a registered cycle status.
 	 */
 	public function transition_cycle_status( int $cycle_id, string $from_status, string $to_status, int $order_id, ?string $reason = null ): bool {
 		global $wpdb;

+		if ( ! CycleStatus::is_registered( $to_status ) ) {
+			throw new \DomainException( esc_html( sprintf( 'ContractRepository::transition_cycle_status(): cycle status "%s" is not registered.', $to_status ) ) );
+		}
+
 		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );

 		if ( null === $reason ) {
@@ -1159,7 +1196,8 @@ final class ContractRepository {
 	/**
 	 * Hydrate a cycle row, attaching typed snapshot value objects only for an in-flight
 	 * (non-terminal) cycle. A settled record keeps its snapshot ids but skips the extra
-	 * reads to decode their payloads.
+	 * reads to decode their payloads. {@see CycleStatus::is_terminal()} is false for an
+	 * unknown or extension-registered status, so such a cycle's snapshots are decoded.
 	 *
 	 * @param array<string, mixed> $row Cycle row.
 	 * @return Cycle The hydrated cycle.
@@ -1492,7 +1530,9 @@ final class ContractRepository {
 		global $wpdb;

 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-		$wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ), array( 'contract_id' => $contract_id ) );
+		if ( false === $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ), array( 'contract_id' => $contract_id ) ) ) {
+			$this->log_meta_write_failure( $contract_id, 'delete' );
+		}
 		$this->insert_meta( $contract_id, $meta );
 	}

@@ -1561,7 +1601,7 @@ final class ContractRepository {
 			// The engine's own contract-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.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
-			$wpdb->insert(
+			$inserted = $wpdb->insert(
 				SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ),
 				array(
 					'contract_id' => $contract_id,
@@ -1569,9 +1609,33 @@ final class ContractRepository {
 					'meta_value'  => (string) $value,
 				)
 			);
+			if ( false === $inserted ) {
+				$this->log_meta_write_failure( $contract_id, 'insert', (string) $key );
+			}
 		}
 	}

+	/**
+	 * Log a failed contract-meta write. Meta writes follow the row write without a
+	 * transaction, so a failure here leaves the row and its meta out of step; logging it
+	 * makes that visible.
+	 *
+	 * @param int    $contract_id Contract id.
+	 * @param string $operation   The failed operation (`delete` or `insert`).
+	 * @param string $meta_key    The meta key being inserted, if any.
+	 */
+	private function log_meta_write_failure( int $contract_id, string $operation, string $meta_key = '' ): void {
+		global $wpdb;
+
+		wc_get_logger()->error(
+			sprintf( 'ContractRepository: contract meta %s failed for contract %d%s - %s', $operation, $contract_id, '' === $meta_key ? '' : sprintf( ' (key %s)', $meta_key ), $wpdb->last_error ),
+			array(
+				'source'      => self::LOG_SOURCE,
+				'contract_id' => $contract_id,
+			)
+		);
+	}
+
 	/**
 	 * Load line items for a contract.
 	 *
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 76b73801b06..522815db895 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
@@ -39,13 +39,17 @@ final class SchemaInstaller {
 	 *         contracts for the batch renewal scan.
 	 * 2.3.0 - catalog flatten: drop the plan_groups table; plans lose group_id and
 	 *         options, gain merchant_code (UNIQUE).
+	 * 2.4.0 - owner-scoped due scan: `due_owner (extension_slug, next_payment_gmt)` replaces
+	 *         `due_contract (status, next_payment_gmt)` and makes the contracts
+	 *         `extension_slug` index redundant (dropped); pre-freeze, existing tables must be
+	 *         recreated to drop the old indexes.
 	 *
 	 * 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.3.0';
+	private const VERSION = '2.4.0';

 	/**
 	 * Option key tracking the installed schema version.
@@ -222,8 +226,8 @@ final class SchemaInstaller {
 ) {$collate};";

 		// The contract row is the live source of truth: the totals and stamps are live
-		// values, not caches of cycles. The `due_contract (status, next_payment_gmt)` index
-		// keys the batch dispatcher's scan (status equality, then a range on the due date);
+		// values, not caches of cycles. The `due_owner (extension_slug, next_payment_gmt)` index
+		// keys the batch dispatcher's scan (owner equality, then a range on the due date);
 		// `due` is retained for next-bill-cache lookups keyed the other way. `origin_order_id`
 		// is NULLABLE (a manual/admin contract has no origin order). There is no generic
 		// `cycle_count` - counters are per-chain, derived as `MAX(count)` over
@@ -259,9 +263,8 @@ final class SchemaInstaller {
   PRIMARY KEY  (id),
   KEY customer_status (customer_id, status),
   KEY due (next_payment_gmt, status),
-  KEY due_contract (status, next_payment_gmt),
-  KEY origin_order (origin_order_id),
-  KEY extension_slug (extension_slug)
+  KEY due_owner (extension_slug, next_payment_gmt),
+  KEY origin_order (origin_order_id)
 ) {$collate};";

 		$contract_items_sql = "CREATE TABLE {$contract_items} (
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php
index 70c3ee172ef..20b863c8e76 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php
@@ -3,7 +3,7 @@
  * Integration tests for the lifecycle-actions REST controller: the auth + ownership
  * matrix (anonymous 401, valid owner 200, foreign owner 404, unknown id 404), the
  * action round-trips with their domain-summary responses, and the
- * illegal-transition 409.
+ * precondition 409.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine
  */
@@ -211,8 +211,9 @@ class ContractsControllerTest extends EngineIntegrationTestCase {
 	}

 	public function test_illegal_transition_is_a_conflict(): void {
-		// Reactivating an active contract is a no-op (idempotent) - so to force the
-		// illegal path, try to hold a cancelled contract.
+		// There is no status state machine: each flow guards its own preconditions, and
+		// a precondition the current state does not meet (holding a cancelled contract)
+		// surfaces as a 409.
 		wp_set_current_user( $this->owner_id );
 		$id = $this->seed( $this->owner_id, ContractStatus::CANCELLED );

@@ -221,6 +222,32 @@ class ContractsControllerTest extends EngineIntegrationTestCase {
 		$this->assertSame( 409, $response->get_status() );
 	}

+	public function test_hold_on_an_expired_contract_is_a_conflict(): void {
+		wp_set_current_user( $this->owner_id );
+		$id = $this->seed( $this->owner_id, ContractStatus::EXPIRED );
+
+		$response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/hold' ) );
+
+		$this->assertSame( 409, $response->get_status() );
+		$stored = $this->reload( $id );
+		$this->assertSame( ContractStatus::EXPIRED, $stored->get_status() );
+		$this->assertSame( '2099-02-01 00:00:00', $stored->get_next_payment_gmt(), 'The rejected action writes nothing.' );
+	}
+
+	public function test_cancel_on_an_expired_contract_is_a_conflict(): void {
+		wp_set_current_user( $this->owner_id );
+		$id = $this->seed( $this->owner_id, ContractStatus::EXPIRED );
+
+		$request = new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/cancel' );
+		$request->set_body_params( array( 'at_period_end' => false ) );
+		$response = rest_get_server()->dispatch( $request );
+
+		$this->assertSame( 409, $response->get_status() );
+		$stored = $this->reload( $id );
+		$this->assertSame( ContractStatus::EXPIRED, $stored->get_status() );
+		$this->assertSame( '2099-02-01 00:00:00', $stored->get_next_payment_gmt(), 'The rejected action writes nothing.' );
+	}
+
 	/**
 	 * The response body as an array (asserts it is one, narrowing offset access).
 	 *
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 8367fa76492..bd78adfb26f 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
@@ -285,7 +285,7 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
 		$this->seed_list_contract( ContractStatus::ON_HOLD );

 		$by_status = Subscriptions::count_by_status();
-		$this->assertSame( ContractStatus::all(), array_keys( $by_status ) );
+		$this->assertSame( ContractStatus::get_all(), array_keys( $by_status ) );
 		$this->assertSame( 2, $by_status[ ContractStatus::ACTIVE ] );
 		$this->assertSame( 1, $by_status[ ContractStatus::ON_HOLD ] );
 		$this->assertSame( 0, $by_status[ ContractStatus::CANCELLED ] );
@@ -345,7 +345,7 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
 		// Newest first: cycle 2 is billed, linked to the renewal order.
 		$cycle_two = $history[0];
 		$this->assertSame( 2, $cycle_two->get_count() );
-		$this->assertTrue( $cycle_two->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $cycle_two->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
 		$this->assertSame( $renewal_order->get_id(), $cycle_two->get_order_id() );

 		// The schedule advanced one cadence (cycle 1 ended 2026-02-15 + 1 month).
@@ -468,15 +468,19 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
 		$held = Subscriptions::get( $contract_id );
 		$this->assertInstanceOf( Contract::class, $held );
 		$this->assertSame( ContractStatus::ON_HOLD, $held->get_status() );
+		$this->assertNull( $held->get_next_payment_gmt(), 'Hold disarms the next-due moment.' );

 		$this->assertTrue( Subscriptions::reactivate( $contract_id ) );
 		$active = Subscriptions::get( $contract_id );
 		$this->assertInstanceOf( Contract::class, $active );
 		$this->assertSame( ContractStatus::ACTIVE, $active->get_status() );
+		$this->assertNotNull( $active->get_next_payment_gmt(), 'Reactivate re-arms the next-due moment.' );

 		$this->assertTrue( Subscriptions::cancel_at_period_end( $contract_id ) );
 		$pending = Subscriptions::get( $contract_id );
 		$this->assertInstanceOf( Contract::class, $pending );
 		$this->assertSame( ContractStatus::PENDING_CANCELLATION, $pending->get_status() );
+		$this->assertNull( $pending->get_next_payment_gmt(), 'Cancel at period end disarms the next-due moment.' );
+		$this->assertNotNull( $pending->get_end_gmt(), 'The former next-due moment becomes the end date.' );
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php
index 949175199f3..995b2891c41 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php
@@ -122,7 +122,7 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
 		// Cycle 1 is the origin period: count 1, linked to the origin order, billed.
 		$this->assertSame( 1, $cycle->get_count() );
 		$this->assertSame( $order->get_id(), $cycle->get_order_id() );
-		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
 		$this->assertSame( 'lite', $cycle->get_extension_slug() );

 		// Its period runs from the paid time to the first renewal date.
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php
index 756c7a8794d..125be196c7a 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php
@@ -1,9 +1,9 @@
 <?php
 /**
- * Integration tests for the Cancellation contract operation's period-end mode: ACTIVE ->
- * PENDING_CANCELLATION, the end date stamped, and the next-payment date left in place so
- * the contract lapses at period end. (The immediate-cancel mode is covered through the
- * facade suite.)
+ * Integration tests for the Cancellation contract operation: the period-end mode (->
+ * PENDING_CANCELLATION, the end date stamped, the next-due moment disarmed) and the
+ * immediate mode's guards and disarm (its order/cycle effects are covered through the
+ * facade suite).
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine
  */
@@ -17,7 +17,9 @@ use EngineIntegrationTestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation
@@ -46,10 +48,20 @@ class CancellationTest extends EngineIntegrationTestCase {
 	 * @param string|null $end_gmt Optional pre-set end date.
 	 */
 	private function seed_active( ?string $end_gmt = null ): int {
+		return $this->seed( ContractStatus::ACTIVE, $end_gmt );
+	}
+
+	/**
+	 * Seed a contract at a status with a future next-payment date.
+	 *
+	 * @param string      $status  Contract status.
+	 * @param string|null $end_gmt Optional pre-set end date.
+	 */
+	private function seed( string $status, ?string $end_gmt = null ): int {
 		$contract = Contract::create(
 			array(
 				'customer_id'      => 1,
-				'status'           => ContractStatus::ACTIVE,
+				'status'           => $status,
 				'currency'         => 'USD',
 				'selling_plan_id'  => 1,
 				'start_gmt'        => '2026-01-01 00:00:00',
@@ -74,14 +86,16 @@ class CancellationTest extends EngineIntegrationTestCase {
 		$this->assertSame( '2099-01-01 00:00:00', $stored->get_end_gmt() );
 	}

-	public function test_leaves_the_next_payment_in_place_so_the_contract_lapses_at_the_date(): void {
-		// The next-payment date is deliberately left in place; the due scan refuses to
-		// charge a non-active contract, so no renewal fires while it winds down.
+	public function test_cancel_at_period_end_stamps_the_end_and_clears_the_next_payment(): void {
+		// The flow disarms the next-due moment itself, so the due scan never selects the
+		// winding-down contract; its end_gmt is the date it terminates at.
 		$id = $this->seed_active();

 		$this->sut->cancel_at_period_end( $this->reload( $id ) );

-		$this->assertSame( '2099-01-01 00:00:00', $this->reload( $id )->get_next_payment_gmt(), 'The contract lapses at the date; the next-payment date stays in place.' );
+		$stored = $this->reload( $id );
+		$this->assertSame( '2099-01-01 00:00:00', $stored->get_end_gmt() );
+		$this->assertNull( $stored->get_next_payment_gmt() );
 	}

 	public function test_preserves_an_existing_end_date(): void {
@@ -89,7 +103,43 @@ class CancellationTest extends EngineIntegrationTestCase {

 		$this->sut->cancel_at_period_end( $this->reload( $id ) );

-		$this->assertSame( '2026-09-09 00:00:00', $this->reload( $id )->get_end_gmt() );
+		$stored = $this->reload( $id );
+		$this->assertSame( '2026-09-09 00:00:00', $stored->get_end_gmt() );
+		$this->assertNull( $stored->get_next_payment_gmt() );
+	}
+
+	public function test_cancel_at_period_end_from_on_hold_stamps_the_end_from_the_hold_anchor(): void {
+		$id = $this->seed_active();
+		( new Hold( $this->contracts ) )->hold( $this->reload( $id ) );
+
+		$this->assertTrue( $this->sut->cancel_at_period_end( $this->reload( $id ) ) );
+
+		$stored = $this->reload( $id );
+		$this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
+		$this->assertSame( '2099-01-01 00:00:00', $stored->get_end_gmt() );
+		$this->assertNull( $stored->get_next_payment_gmt() );
+		$this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+	}
+
+	public function test_cancel_at_period_end_on_a_pending_cancellation_contract_is_a_no_op(): void {
+		$id = $this->seed_active();
+		$this->sut->cancel_at_period_end( $this->reload( $id ) );
+
+		$fired = 0;
+		add_action(
+			Cancellation::CONTRACT_PENDING_CANCELLATION_ACTION,
+			static function () use ( &$fired ): void {
+				++$fired;
+			}
+		);
+
+		$this->assertTrue( $this->sut->cancel_at_period_end( $this->reload( $id ) ) );
+
+		$stored = $this->reload( $id );
+		$this->assertSame( 1, $fired );
+		$this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
+		$this->assertSame( '2099-01-01 00:00:00', $stored->get_end_gmt() );
+		$this->assertNull( $stored->get_next_payment_gmt() );
 	}

 	public function test_fires_the_pending_cancellation_action(): void {
@@ -124,6 +174,152 @@ class CancellationTest extends EngineIntegrationTestCase {
 		$this->sut->cancel_at_period_end( $this->reload( $id ) );
 	}

+	public function test_cancel_clears_the_next_payment(): void {
+		$id = $this->seed_active();
+
+		$this->assertTrue( $this->sut->cancel( $this->reload( $id ) ) );
+
+		$stored = $this->reload( $id );
+		$this->assertSame( ContractStatus::CANCELLED, $stored->get_status() );
+		$this->assertNull( $stored->get_next_payment_gmt() );
+	}
+
+	public function test_cancel_clears_the_next_payment_and_hold_anchor_of_a_held_contract(): void {
+		$id = $this->seed_active();
+		( new Hold( $this->contracts ) )->hold( $this->reload( $id ) );
+
+		$this->sut->cancel( $this->reload( $id ) );
+
+		$stored = $this->reload( $id );
+		$this->assertSame( ContractStatus::CANCELLED, $stored->get_status() );
+		$this->assertNull( $stored->get_next_payment_gmt() );
+		$this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+	}
+
+	public function test_cancel_accepts_a_pending_cancellation_contract(): void {
+		$id = $this->seed( ContractStatus::PENDING_CANCELLATION );
+
+		$this->assertTrue( $this->sut->cancel( $this->reload( $id ) ) );
+
+		$stored = $this->reload( $id );
+		$this->assertSame( ContractStatus::CANCELLED, $stored->get_status() );
+		$this->assertNull( $stored->get_next_payment_gmt() );
+	}
+
+	public function test_cancel_on_a_cancelled_contract_is_a_no_op_that_refires_the_action(): void {
+		$id = $this->seed( ContractStatus::CANCELLED );
+
+		$fired = 0;
+		add_action(
+			Cancellation::CONTRACT_CANCELLED_ACTION,
+			static function () use ( &$fired ): void {
+				++$fired;
+			}
+		);
+
+		$this->assertTrue( $this->sut->cancel( $this->reload( $id ) ) );
+
+		$this->assertSame( 1, $fired );
+		$this->assertSame( ContractStatus::CANCELLED, $this->reload( $id )->get_status() );
+	}
+
+	public function test_cancel_rejects_an_expired_contract(): void {
+		$id     = $this->seed( ContractStatus::EXPIRED );
+		$before = did_action( Cancellation::CONTRACT_CANCELLED_ACTION );
+
+		try {
+			$this->sut->cancel( $this->reload( $id ) );
+			$this->fail( 'Expected a DomainException for an expired contract.' );
+		} catch ( DomainException $e ) {
+			$this->assertSame( ContractStatus::EXPIRED, $this->reload( $id )->get_status() );
+			$this->assertSame( $before, did_action( Cancellation::CONTRACT_CANCELLED_ACTION ), 'The cancelled action does not fire.' );
+		}
+	}
+
+	public function test_cancel_rejects_an_unregistered_stored_status(): void {
+		global $wpdb;
+
+		$id = $this->seed_active();
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'status' => 'legacy-paused' ), array( 'id' => $id ) );
+
+		try {
+			$this->sut->cancel( $this->reload( $id ) );
+			$this->fail( 'Expected a DomainException for an unregistered stored status.' );
+		} catch ( DomainException $e ) {
+			$this->assertSame( 'legacy-paused', $this->reload( $id )->get_status() );
+		}
+	}
+
+	public function test_cancel_at_period_end_ignores_a_malformed_hold_anchor(): void {
+		$id = $this->seed( ContractStatus::ON_HOLD );
+		$this->contracts->update( $this->with_hold_anchor( $this->reload( $id ), 'not-a-date' ) );
+
+		$this->sut->cancel_at_period_end( $this->reload( $id ) );
+
+		$stored = $this->reload( $id );
+		$this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
+		$this->assertSame( '2099-01-01 00:00:00', $stored->get_end_gmt(), 'The stored next payment, not the malformed anchor, is the period end.' );
+		$this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+	}
+
+	public function test_cancel_at_period_end_from_on_hold_without_a_date_leaves_no_end(): void {
+		$id   = $this->seed( ContractStatus::ON_HOLD );
+		$held = $this->reload( $id );
+		$held->set_next_payment_gmt( null );
+		$this->contracts->update( $this->with_hold_anchor( $held, 'not-a-date' ) );
+
+		$this->sut->cancel_at_period_end( $this->reload( $id ) );
+
+		$stored = $this->reload( $id );
+		$this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
+		$this->assertNull( $stored->get_end_gmt(), 'A malformed anchor is never written as the end date.' );
+	}
+
+	/**
+	 * Set the hold anchor meta on a contract.
+	 *
+	 * @param Contract $contract Contract to change.
+	 * @param string   $anchor   Anchor value to store.
+	 */
+	private function with_hold_anchor( Contract $contract, string $anchor ): Contract {
+		$contract->set_meta( Hold::ANCHOR_META_KEY, $anchor );
+
+		return $contract;
+	}
+
+	public function test_cancel_at_period_end_rejects_an_expired_contract(): void {
+		$id     = $this->seed( ContractStatus::EXPIRED );
+		$before = did_action( Cancellation::CONTRACT_PENDING_CANCELLATION_ACTION );
+
+		try {
+			$this->sut->cancel_at_period_end( $this->reload( $id ) );
+			$this->fail( 'Expected a DomainException for an expired contract.' );
+		} catch ( DomainException $e ) {
+			$reloaded = $this->reload( $id );
+			$this->assertSame( ContractStatus::EXPIRED, $reloaded->get_status() );
+			$this->assertSame( '2099-01-01 00:00:00', $reloaded->get_next_payment_gmt(), 'Nothing was written.' );
+			$this->assertSame( $before, did_action( Cancellation::CONTRACT_PENDING_CANCELLATION_ACTION ), 'The action does not fire.' );
+		}
+	}
+
+	public function test_cancel_at_period_end_rejects_an_unregistered_stored_status(): void {
+		global $wpdb;
+
+		$id = $this->seed_active();
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'status' => 'legacy-paused' ), array( 'id' => $id ) );
+
+		try {
+			$this->sut->cancel_at_period_end( $this->reload( $id ) );
+			$this->fail( 'Expected a DomainException for an unregistered stored status.' );
+		} catch ( DomainException $e ) {
+			$reloaded = $this->reload( $id );
+			$this->assertSame( 'legacy-paused', $reloaded->get_status() );
+			$this->assertSame( '2099-01-01 00:00:00', $reloaded->get_next_payment_gmt(), 'Nothing was written.' );
+		}
+	}
+
 	/**
 	 * Reload a contract, asserting it still exists (narrows the nullable read).
 	 *
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php
index 434fc58c2ae..5880d933451 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php
@@ -1,7 +1,7 @@
 <?php
 /**
  * Integration tests for the Hold contract operation: ACTIVE -> ON_HOLD, the
- * next-payment date preserved, and the held action fired.
+ * next-due moment disarmed (kept as the hold anchor), and the held action fired.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine
  */
@@ -16,6 +16,7 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold
@@ -39,11 +40,12 @@ class HoldTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * Seed a contract at a status with a future next-payment date.
+	 * Seed a contract at a status with a next-payment date (a future one by default).
 	 *
-	 * @param string $status Contract status.
+	 * @param string      $status       Contract status.
+	 * @param string|null $next_payment Next-payment GMT string, or null.
 	 */
-	private function seed( string $status ): int {
+	private function seed( string $status, ?string $next_payment = '2099-01-01 00:00:00' ): int {
 		$contract = Contract::create(
 			array(
 				'customer_id'      => 1,
@@ -51,7 +53,7 @@ class HoldTest extends EngineIntegrationTestCase {
 				'currency'         => 'USD',
 				'selling_plan_id'  => 1,
 				'start_gmt'        => '2026-01-01 00:00:00',
-				'next_payment_gmt' => '2099-01-01 00:00:00',
+				'next_payment_gmt' => $next_payment,
 				'billing_total'    => '19.99',
 			)
 		);
@@ -65,16 +67,51 @@ class HoldTest extends EngineIntegrationTestCase {
 		$result = $this->sut->hold( $this->reload( $id ) );

 		$this->assertTrue( $result );
-		// No charge while held: the contract is on hold, and the batch due scan only bills active contracts.
+		// No charge while held: hold disarms the next-due moment the batch due scan keys on.
 		$this->assertSame( ContractStatus::ON_HOLD, $this->reload( $id )->get_status() );
 	}

-	public function test_hold_keeps_the_next_payment_for_a_later_reactivate(): void {
+	public function test_hold_clears_the_next_payment_and_keeps_the_anchor(): void {
 		$id = $this->seed( ContractStatus::ACTIVE );

 		$this->sut->hold( $this->reload( $id ) );

-		$this->assertSame( '2099-01-01 00:00:00', $this->reload( $id )->get_next_payment_gmt() );
+		$held = $this->reload( $id );
+		$this->assertNull( $held->get_next_payment_gmt() );
+		$this->assertSame( '2099-01-01 00:00:00', $held->get_meta()[ Hold::ANCHOR_META_KEY ] ?? null );
+	}
+
+	public function test_hold_without_a_next_payment_stores_no_anchor(): void {
+		$id = $this->seed( ContractStatus::ACTIVE, null );
+
+		$this->sut->hold( $this->reload( $id ) );
+
+		$held = $this->reload( $id );
+		$this->assertSame( ContractStatus::ON_HOLD, $held->get_status() );
+		$this->assertNull( $held->get_next_payment_gmt() );
+		$this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $held->get_meta() );
+	}
+
+	public function test_hold_on_an_on_hold_contract_is_an_idempotent_no_op(): void {
+		$id = $this->seed( ContractStatus::ACTIVE );
+		$this->sut->hold( $this->reload( $id ) );
+
+		$fired = 0;
+		add_action(
+			Hold::CONTRACT_HELD_ACTION,
+			static function () use ( &$fired ): void {
+				++$fired;
+			}
+		);
+
+		// A second hold must not wipe the anchor stashed by the first.
+		$this->assertTrue( $this->sut->hold( $this->reload( $id ) ) );
+
+		$held = $this->reload( $id );
+		$this->assertSame( 1, $fired );
+		$this->assertSame( ContractStatus::ON_HOLD, $held->get_status() );
+		$this->assertNull( $held->get_next_payment_gmt() );
+		$this->assertSame( '2099-01-01 00:00:00', $held->get_meta()[ Hold::ANCHOR_META_KEY ] ?? null );
 	}

 	public function test_hold_fires_the_held_action(): void {
@@ -110,6 +147,33 @@ class HoldTest extends EngineIntegrationTestCase {
 		}
 	}

+	/**
+	 * The anchor is stored and read back before the hold disarms the contract, so a failed
+	 * meta write aborts the hold with nothing disarmed instead of leaving a held contract
+	 * that reactivation could not re-arm.
+	 */
+	public function test_hold_aborts_without_disarming_when_the_anchor_cannot_be_stored(): void {
+		$id         = $this->seed( ContractStatus::ACTIVE );
+		$meta_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META );
+		$break      = static function ( string $query ) use ( $meta_table ): string {
+			return 0 === strpos( $query, "INSERT INTO `{$meta_table}`" ) ? 'SELECT broken syntax (' : $query;
+		};
+		add_filter( 'query', $break );
+
+		try {
+			$this->sut->hold( $this->reload( $id ) );
+			$this->fail( 'Expected the hold to abort when the anchor cannot be stored.' );
+		} catch ( \RuntimeException $e ) {
+			$this->assertStringContainsString( 'anchor could not be stored', $e->getMessage() );
+		} finally {
+			remove_filter( 'query', $break );
+		}
+
+		$stored = $this->reload( $id );
+		$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
+		$this->assertSame( '2099-01-01 00:00:00', $stored->get_next_payment_gmt(), 'The next-due moment was not disarmed.' );
+	}
+
 	public function test_hold_rejects_a_cancelled_contract(): void {
 		$id = $this->seed( ContractStatus::CANCELLED );

@@ -117,6 +181,29 @@ class HoldTest extends EngineIntegrationTestCase {
 		$this->sut->hold( $this->reload( $id ) );
 	}

+	public function test_hold_rejects_a_pending_cancellation_contract(): void {
+		$id = $this->seed( ContractStatus::PENDING_CANCELLATION );
+
+		$this->expectException( DomainException::class );
+		$this->sut->hold( $this->reload( $id ) );
+	}
+
+	public function test_hold_rejects_an_unregistered_stored_status(): void {
+		global $wpdb;
+
+		$id = $this->seed( ContractStatus::ACTIVE );
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'status' => 'legacy-paused' ), array( 'id' => $id ) );
+
+		try {
+			$this->sut->hold( $this->reload( $id ) );
+			$this->fail( 'Expected a DomainException for an unregistered stored status.' );
+		} catch ( DomainException $e ) {
+			$this->assertSame( 'legacy-paused', $this->reload( $id )->get_status() );
+			$this->assertSame( '2099-01-01 00:00:00', $this->reload( $id )->get_next_payment_gmt() );
+		}
+	}
+
 	/**
 	 * Reload a contract, asserting it still exists (narrows the nullable read).
 	 *
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 ecc0452b01e..606d1c1d4d1 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
@@ -20,9 +20,11 @@ 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;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Reactivation
@@ -82,11 +84,16 @@ class ReactivationTest extends EngineIntegrationTestCase {
 	/**
 	 * Seed a contract with a next-payment date and the given selling plan.
 	 *
-	 * @param string|null $next_payment_gmt Next-payment GMT string, or null.
-	 * @param int         $selling_plan_id  Selling plan id.
-	 * @param string      $status           Contract status. Default ON_HOLD.
+	 * An on-hold row that still carries `next_payment_gmt` and no hold anchor meta is the
+	 * shape of a contract held before hold started disarming the next-due moment; it is the
+	 * reactivation fallback these seeds exercise.
+	 *
+	 * @param string|null           $next_payment_gmt Next-payment GMT string, or null.
+	 * @param int                   $selling_plan_id  Selling plan id.
+	 * @param string                $status           Contract status. Default ON_HOLD.
+	 * @param array<string, string> $meta             Contract meta.
 	 */
-	private function seed_on_hold( ?string $next_payment_gmt, int $selling_plan_id, string $status = ContractStatus::ON_HOLD ): int {
+	private function seed_on_hold( ?string $next_payment_gmt, int $selling_plan_id, string $status = ContractStatus::ON_HOLD, array $meta = array() ): int {
 		$contract = Contract::create(
 			array(
 				'customer_id'      => 1,
@@ -97,6 +104,7 @@ class ReactivationTest extends EngineIntegrationTestCase {
 				'start_gmt'        => '2026-01-01 00:00:00',
 				'next_payment_gmt' => $next_payment_gmt,
 				'billing_total'    => '19.99',
+				'meta'             => $meta,
 			)
 		);

@@ -138,6 +146,55 @@ class ReactivationTest extends EngineIntegrationTestCase {
 		$this->assertSame( '2026-05-01 00:00:00', $this->reload( $id )->get_next_payment_gmt() );
 	}

+	public function test_reactivate_rearms_from_the_hold_anchor(): void {
+		$id = $this->seed_on_hold( '2099-01-01 00:00:00', $this->make_monthly_plan(), ContractStatus::ACTIVE );
+		( new Hold( $this->contracts ) )->hold( $this->reload( $id ) );
+		$this->assertNull( $this->reload( $id )->get_next_payment_gmt(), 'Hold disarmed the next-due moment.' );
+
+		$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
+
+		$stored = $this->reload( $id );
+		$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
+		$this->assertSame( '2099-01-01 00:00:00', $stored->get_next_payment_gmt() );
+		$this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+	}
+
+	public function test_reactivate_rolls_a_past_due_anchor_forward(): void {
+		// Held with a 2026-02-01 anchor, resumed 2026-04-15: rolled forward by whole
+		// cadences exactly like a stored past-due date -> 2026-05-01.
+		$id = $this->seed_on_hold( null, $this->make_monthly_plan(), ContractStatus::ON_HOLD, array( Hold::ANCHOR_META_KEY => '2026-02-01 00:00:00' ) );
+
+		$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-04-15 00:00:00' ) );
+
+		$stored = $this->reload( $id );
+		$this->assertSame( '2026-05-01 00:00:00', $stored->get_next_payment_gmt() );
+		$this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+	}
+
+	/**
+	 * Hold clears the next payment, so one set while held was re-armed deliberately and wins.
+	 */
+	public function test_reactivate_prefers_a_stored_next_payment_over_the_anchor(): void {
+		$id = $this->seed_on_hold( '2099-06-01 00:00:00', $this->make_monthly_plan(), ContractStatus::ON_HOLD, array( Hold::ANCHOR_META_KEY => '2099-01-01 00:00:00' ) );
+
+		$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
+
+		$stored = $this->reload( $id );
+		$this->assertSame( '2099-06-01 00:00:00', $stored->get_next_payment_gmt() );
+		$this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+	}
+
+	public function test_reactivate_ignores_a_malformed_anchor(): void {
+		$id = $this->seed_on_hold( null, $this->make_monthly_plan(), ContractStatus::ON_HOLD, array( Hold::ANCHOR_META_KEY => 'not-a-date' ) );
+
+		$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
+
+		$stored = $this->reload( $id );
+		$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
+		$this->assertNull( $stored->get_next_payment_gmt(), 'A malformed anchor counts as absent.' );
+		$this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+	}
+
 	public function test_reactivate_rolls_by_the_frozen_snapshot_cadence_over_the_live_plan(): void {
 		// The live selling plan is monthly, but the contract's frozen terms are
 		// yearly: the snapshot is what the contract bills under, so the forward
@@ -258,6 +315,36 @@ class ReactivationTest extends EngineIntegrationTestCase {
 		$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
 	}

+	public function test_reactivate_rejects_a_pending_cancellation_contract(): void {
+		$id = $this->seed_on_hold( '2099-01-01 00:00:00', $this->make_monthly_plan(), ContractStatus::PENDING_CANCELLATION );
+
+		try {
+			$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
+			$this->fail( 'Expected a DomainException for a pending-cancellation contract.' );
+		} catch ( DomainException $e ) {
+			$stored = $this->reload( $id );
+			$this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
+			$this->assertSame( '2099-01-01 00:00:00', $stored->get_next_payment_gmt(), 'Nothing was written.' );
+		}
+	}
+
+	public function test_reactivate_rejects_an_unregistered_stored_status(): void {
+		global $wpdb;
+
+		$id = $this->seed_on_hold( '2099-01-01 00:00:00', $this->make_monthly_plan() );
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'status' => 'legacy-paused' ), array( 'id' => $id ) );
+
+		try {
+			$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
+			$this->fail( 'Expected a DomainException for an unregistered stored status.' );
+		} catch ( DomainException $e ) {
+			$stored = $this->reload( $id );
+			$this->assertSame( 'legacy-paused', $stored->get_status() );
+			$this->assertSame( '2099-01-01 00:00:00', $stored->get_next_payment_gmt(), 'Nothing was written.' );
+		}
+	}
+
 	/**
 	 * Reload a contract, asserting it still exists (narrows the nullable read).
 	 *
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
new file mode 100644
index 00000000000..c5380f92158
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php
@@ -0,0 +1,256 @@
+<?php
+/**
+ * Integration tests for the owner-scoped due scan, end to end through the batch
+ * dispatcher: a contract is due when its next-due moment has passed and its owner is a
+ * registered consumer. Lifecycle flows stop renewals by disarming the next-due moment; the
+ * interim renewal-flow status predicate leaves a contract in any other status (including an
+ * extension-registered one) unselected and untouched; a contract whose owner is null or
+ * unregistered waits untouched.
+ *
+ * Note: actually terminating a pending-cancellation contract at its end date (moving it
+ * terminal) is a follow-up slice; today it simply has no next-due moment.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Renewal;
+
+use DateTimeImmutable;
+use DateTimeZone;
+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\ContractFactory;
+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;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository::find_due
+ */
+class OwnerScopedDueScanTest extends EngineIntegrationTestCase {
+
+	/**
+	 * A gateway that approves the scheduled charge inline.
+	 */
+	private const GATEWAY = 'engine_owner_scan_gateway';
+
+	/**
+	 * The registered consumer, and the owner the default test plan carries.
+	 */
+	private const OWNER = 'engine-tests';
+
+	/**
+	 * The date signed-up contracts first come due (one month after the paid date).
+	 */
+	private const FIRST_DUE = '2026-02-15 00:00:00';
+
+	/**
+	 * @var ContractRepository
+	 */
+	private $contracts;
+
+	public function set_up(): void {
+		parent::set_up();
+		GatewayCapabilities::reset();
+		ConsumerRegistry::reset();
+		ConsumerRegistry::register( self::OWNER );
+		$this->approve_charges_for( self::GATEWAY );
+
+		$this->contracts = new ContractRepository();
+	}
+
+	public function tear_down(): void {
+		ConsumerRegistry::reset();
+		StatusRegistry::reset();
+		GatewayCapabilities::reset();
+		parent::tear_down();
+	}
+
+	public function test_a_held_contract_is_not_renewed_at_or_after_its_former_date(): void {
+		$id = $this->sign_up( self::OWNER );
+		( new Hold( $this->contracts ) )->hold( $this->reload( $id ) );
+
+		$this->run_batch_at( self::FIRST_DUE );
+		$this->run_batch_at( '2026-06-01 00:00:00' );
+
+		$this->assertSame( 0, $this->renewal_order_count( $id ) );
+		$held = $this->reload( $id );
+		$this->assertSame( ContractStatus::ON_HOLD, $held->get_status() );
+		$this->assertNull( $held->get_next_payment_gmt() );
+	}
+
+	public function test_a_pending_cancellation_contract_is_not_renewed_at_its_end_date(): void {
+		$id = $this->sign_up( self::OWNER );
+		( new Cancellation( $this->contracts ) )->cancel_at_period_end( $this->reload( $id ) );
+
+		$this->run_batch_at( self::FIRST_DUE );
+
+		$this->assertSame( 0, $this->renewal_order_count( $id ) );
+		$stored = $this->reload( $id );
+		$this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
+		$this->assertSame( self::FIRST_DUE, $stored->get_end_gmt() );
+	}
+
+	public function test_a_due_contract_with_an_extension_registered_status_is_not_selected_and_left_untouched(): void {
+		StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+		$id = $this->sign_up( self::OWNER );
+		$this->force_column( $id, 'status', 'paused-by-merchant' );
+
+		$at = new DateTimeImmutable( '2026-02-20 00:00:00', new DateTimeZone( 'UTC' ) );
+		$this->assertNotContains( $id, $this->due_ids( $at ), 'The scan does not select a non-active contract.' );
+
+		$this->run_batch_at( '2026-02-20 00:00:00' );
+
+		// The engine never clears the due moment of a status it did not set: the extension
+		// that set it decides what happens next.
+		$this->assertSame( 0, $this->renewal_order_count( $id ) );
+		$stored = $this->reload( $id );
+		$this->assertSame( 'paused-by-merchant', $stored->get_status() );
+		$this->assertSame( self::FIRST_DUE, $stored->get_next_payment_gmt() );
+	}
+
+	public function test_a_due_contract_with_an_unregistered_owner_waits_until_the_owner_registers(): void {
+		$id = $this->sign_up( 'other-ext' );
+
+		$this->run_batch_at( '2026-02-20 00:00:00' );
+
+		$this->assertSame( 0, $this->renewal_order_count( $id ) );
+		$this->assertSame( self::FIRST_DUE, $this->reload( $id )->get_next_payment_gmt(), 'The waiting contract is untouched.' );
+
+		ConsumerRegistry::register( 'other-ext' );
+		$this->run_batch_at( '2026-02-20 00:00:00' );
+
+		$this->assertSame( 1, $this->renewal_order_count( $id ) );
+	}
+
+	public function test_a_due_contract_with_no_owner_is_untouched(): void {
+		$id = $this->sign_up( self::OWNER );
+		$this->force_column( $id, 'extension_slug', null );
+
+		$this->run_batch_at( '2026-02-20 00:00:00' );
+
+		$this->assertSame( 0, $this->renewal_order_count( $id ) );
+		$this->assertSame( self::FIRST_DUE, $this->reload( $id )->get_next_payment_gmt() );
+	}
+
+	/**
+	 * Sign up a monthly contract owned by `$owner` via the checkout factory (cycle 1 billed,
+	 * next payment due {@see self::FIRST_DUE}). Returns the contract id.
+	 *
+	 * @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 );
+
+		$order = new WC_Order();
+		$order->set_currency( 'USD' );
+		$order->set_payment_method( self::GATEWAY );
+		$order->set_total( '19.99' );
+		$order->set_date_paid( '2026-01-15 00:00:00' );
+		$order->save();
+
+		$contract = ( new ContractFactory() )->create_from_order( $order, $plan );
+		$id       = (int) $contract->get_id();
+		$this->assertSame( self::FIRST_DUE, $this->reload( $id )->get_next_payment_gmt() );
+
+		return $id;
+	}
+
+	/**
+	 * Run one dispatcher tick at the given UTC moment.
+	 *
+	 * @param string $now GMT moment.
+	 */
+	private function run_batch_at( string $now ): void {
+		( new RenewalDispatcher() )->run_batch( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ), 50 );
+	}
+
+	/**
+	 * Contract ids the due scan selects at `$at`.
+	 *
+	 * @param DateTimeImmutable $at Scan moment.
+	 * @return array<int, int>
+	 */
+	private function due_ids( DateTimeImmutable $at ): array {
+		$ids = array();
+		foreach ( $this->contracts->find_due( $at, 50, ConsumerRegistry::all() ) as $candidate ) {
+			$ids[] = $candidate->get_contract_id();
+		}
+
+		return $ids;
+	}
+
+	/**
+	 * Overwrite one contract column directly (a shape no flow produces).
+	 *
+	 * @param int         $id     Contract id.
+	 * @param string      $column Column name.
+	 * @param string|null $value  Value to store.
+	 */
+	private function force_column( int $id, string $column, ?string $value ): void {
+		global $wpdb;
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( $column => $value ), array( 'id' => $id ) );
+	}
+
+	/**
+	 * Count renewal orders tagged for a contract.
+	 *
+	 * @param int $contract_id Contract id.
+	 */
+	private function renewal_order_count( int $contract_id ): int {
+		$orders = wc_get_orders(
+			array(
+				'limit'      => -1,
+				'status'     => 'any',
+				'type'       => 'shop_order',
+				'meta_key'   => OrderLinkage::META_CONTRACT_ID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+				'meta_value' => (string) $contract_id,          // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+			)
+		);
+
+		$count = 0;
+		foreach ( is_array( $orders ) ? $orders : array() as $order ) {
+			if ( $order instanceof WC_Order && OrderLinkage::RELATION_RENEWAL === $order->get_meta( OrderLinkage::META_RELATION_TYPE ) ) {
+				++$count;
+			}
+		}
+
+		return $count;
+	}
+
+	/**
+	 * Reload a contract, asserting it still exists (narrows the nullable read).
+	 *
+	 * @param int $id Contract id.
+	 */
+	private function reload( int $id ): Contract {
+		$contract = $this->contracts->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+
+		return $contract;
+	}
+}
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 85f3139de7f..660dfba037e 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
@@ -38,9 +38,11 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
 	private const GATEWAY_APPROVING = 'engine_dispatch_gateway_approve';

 	/**
-	 * The consumer slug registered to open the processing gate in charging tests.
+	 * The consumer slug registered in charging tests. It is the owner the test plan (and
+	 * therefore every contract signed up from it) carries, so registering it puts those
+	 * contracts in the owner-scoped due scan.
 	 */
-	private const CONSUMER = 'engine-tests-consumer';
+	private const CONSUMER = 'engine-tests';

 	public function set_up(): void {
 		parent::set_up();
@@ -153,7 +155,7 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
 		// Cycle 2 was billed via the dummy gateway and the schedule advanced one cadence.
 		$this->assertInstanceOf( Cycle::class, $cycle );
 		$this->assertSame( 2, $cycle->get_count() );
-		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );

 		$reloaded = $repo->find( $contract_id );
 		$this->assertInstanceOf( Contract::class, $reloaded );
@@ -326,7 +328,7 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
 		$cycle = ( new ContractRepository() )->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $cycle );
 		$this->assertSame( 2, $cycle->get_count() );
-		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
 	}

 	/**
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 097cc2d318a..42f7d0be5bc 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
@@ -27,6 +27,7 @@ use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine
 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;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine
@@ -169,6 +170,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 				'currency'         => 'USD',
 				'selling_plan_id'  => $plan_id,
 				'origin_order_id'  => $origin_order_id,
+				'extension_slug'   => 'engine-tests',
 				'payment_method'   => self::GATEWAY,
 				'start_gmt'        => '2026-01-15 00:00:00',
 				'next_payment_gmt' => '2026-02-15 00:00:00',
@@ -249,7 +251,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 				'contract_id'    => $contract_id,
 				'sequence_no'    => $previous->get_sequence_no() + 1,
 				'count'          => 2,
-				'status'         => CycleStatus::pending(),
+				'status'         => new CycleStatus( CycleStatus::PENDING ),
 				'starts_at_gmt'  => '2026-02-15 00:00:00',
 				'ends_at_gmt'    => '2026-03-15 00:00:00',
 				'expected_total' => '19.99',
@@ -309,7 +311,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		// SAME cycle row reclaimed (not a duplicate), now billed with the lease cleared.
 		$this->assertSame( $stalled_id, $head->get_id() );
 		$this->assertSame( 2, $head->get_count() );
-		$this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
 		$this->assertNull( $head->get_claimed_until_gmt() );

 		// The schedule advances to the RECLAIMED cycle's own end (2026-03-15), not a
@@ -360,7 +362,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$head = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $head );
 		$this->assertSame( $claimed->get_id(), $head->get_id() );
-		$this->assertTrue( $head->get_status()->equals( CycleStatus::pending() ) );
+		$this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ) );
 		$this->assertSame( $live_until, $head->get_claimed_until_gmt() );
 	}

@@ -389,7 +391,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$this->assertInstanceOf( Cycle::class, $cycle );
 		$this->assertSame( 2, $cycle->get_sequence_no() );
 		$this->assertSame( 2, $cycle->get_count() );
-		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
 		$this->assertSame( $renewal_order->get_id(), $cycle->get_order_id() );
 		$this->assertSame( '19.99000000', $cycle->get_expected_total() );
 		$this->assertSame( 'engine-tests', $cycle->get_extension_slug() );
@@ -489,7 +491,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		// charge did not settle (for dunning + admin visibility).
 		$this->assertInstanceOf( Cycle::class, $cycle );
 		$this->assertSame( 2, $cycle->get_count() );
-		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::failed() ) );
+		$this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::FAILED ) ) );
 		$this->assertSame( $renewal_order->get_id(), $cycle->get_order_id() );

 		// The contract schedule is untouched (left for dunning), still active.
@@ -571,7 +573,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$cycle = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $cycle );
 		$this->assertSame( 2, $cycle->get_count() );
-		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
 	}

 	/**
@@ -630,7 +632,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 					'contract_id'    => $contract_id,
 					'sequence_no'    => 1,
 					'count'          => 1,
-					'status'         => CycleStatus::billed(),
+					'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',
@@ -691,7 +693,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$head = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $head );
 		$this->assertSame( $stalled_id, $head->get_id() );
-		$this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
 		$this->assertNull( $head->get_claimed_until_gmt() );

 		// The schedule advances to the reclaimed cycle's own end - one cadence, not skipped.
@@ -743,7 +745,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$head = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $head );
 		$this->assertSame( 2, $head->get_count() );
-		$this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );

 		$reloaded = $repo->find( $contract_id );
 		$this->assertInstanceOf( Contract::class, $reloaded );
@@ -773,7 +775,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$cycle = ( new ContractRepository() )->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $cycle );
 		$this->assertSame( 2, $cycle->get_count() );
-		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
 	}

 	/**
@@ -784,22 +786,108 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox the scheduled scan skips a non-active contract and creates no renewal order.
+	 * @testdox the due scan excludes a past-due non-active contract: no renewal order, its next-due moment untouched.
+	 *
+	 * A shape no flow produces (on hold while still carrying a due moment), seeded here. The
+	 * scan's interim `active` predicate keeps it out of the batch, so neither `process()` nor
+	 * `park()` is reached; those paths are pinned by the `renew_now` and `park` tests below.
 	 */
-	public function test_scheduled_renewal_skips_non_active_contract(): void {
-		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
+	public function test_due_scan_excludes_a_non_active_contract_and_leaves_it_untouched(): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );

-		$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 );
-		$contract->set_status( ContractStatus::ON_HOLD );
-		( new ContractRepository() )->update( $contract );
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
+		$contract_id = (int) $contract->get_id();
+		$this->force_status( $contract_id, ContractStatus::ON_HOLD );
+
+		$repo         = new ContractRepository();
+		$before       = $repo->find_chain_head( $contract_id );
+		$next_payment = $this->reload_contract( $contract_id )->get_next_payment_gmt();
+		$this->assertInstanceOf( Cycle::class, $before );
+		$this->assertNotNull( $next_payment, 'Seeded with a due moment.' );

 		$this->assertNull( $this->run_scheduled_renewal( $contract_id ) );

-		$this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 1 ) );
+		$this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+		$after = $repo->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $after );
+		$this->assertSame( $before->get_id(), $after->get_id(), 'No successor cycle was claimed.' );
+		$skipped = $this->reload_contract( $contract_id );
+		$this->assertSame( ContractStatus::ON_HOLD, $skipped->get_status() );
+		$this->assertSame( $next_payment, $skipped->get_next_payment_gmt(), 'The non-active contract is not parked.' );
+	}
+
+	/**
+	 * @testdox renew_now returns null for a non-active contract and does not park it.
+	 */
+	public function test_renew_now_returns_null_for_a_non_active_contract_without_parking(): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
+		$contract_id = (int) $contract->get_id();
+		$this->force_status( $contract_id, ContractStatus::ON_HOLD );
+		$next_payment = $this->reload_contract( $contract_id )->get_next_payment_gmt();
+
+		$this->assertNull( ( new RenewalEngine() )->renew_now( $contract_id ) );
+
+		$this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+		$this->assertSame( $next_payment, $this->reload_contract( $contract_id )->get_next_payment_gmt(), 'A manual renew never parks.' );
+	}
+
+	/**
+	 * @testdox park leaves a contract that is no longer active untouched.
+	 *
+	 * A contract can stop being active between being selected and being parked; its next-due
+	 * moment then belongs to whoever changed the status, so the park is a no-op.
+	 */
+	public function test_park_leaves_a_non_active_contract_untouched(): void {
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
+		$contract_id = (int) $contract->get_id();
+		$this->force_status( $contract_id, ContractStatus::ON_HOLD );
+		$next_payment = $this->reload_contract( $contract_id )->get_next_payment_gmt();
+		$this->assertNotNull( $next_payment, 'Seeded with a due moment.' );
+
+		( new RenewalEngine() )->park( $contract_id );
+
+		$this->assertSame( $next_payment, $this->reload_contract( $contract_id )->get_next_payment_gmt() );
+	}
+
+	/**
+	 * @testdox park clears the next-due moment of an active contract.
+	 */
+	public function test_park_clears_the_next_payment_of_an_active_contract(): void {
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
+		$contract_id = (int) $contract->get_id();
+		$this->assertNotNull( $this->reload_contract( $contract_id )->get_next_payment_gmt(), 'Seeded with a due moment.' );
+
+		( new RenewalEngine() )->park( $contract_id );
+
+		$this->assertNull( $this->reload_contract( $contract_id )->get_next_payment_gmt() );
+	}
+
+	/**
+	 * Overwrite a contract's stored status directly, keeping every other column (a shape no
+	 * flow produces, e.g. an on-hold contract that still has a next-due moment).
+	 *
+	 * @param int    $contract_id Contract id.
+	 * @param string $status      Status to store.
+	 */
+	private function force_status( int $contract_id, string $status ): void {
+		global $wpdb;
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'status' => $status ), array( 'id' => $contract_id ) );
+	}
+
+	/**
+	 * Reload a contract, asserting it still exists.
+	 *
+	 * @param int $contract_id Contract id.
+	 */
+	private function reload_contract( int $contract_id ): Contract {
+		$contract = ( new ContractRepository() )->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $contract );
+
+		return $contract;
 	}

 	/**
@@ -837,10 +925,10 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox cancel transitions the contract to cancelled.
+	 * @testdox cancel transitions the contract to cancelled and disarms its next-due moment.
 	 *
-	 * The due scan only selects active contracts, so cancellation needs no schedule
-	 * cleanup - the status transition alone removes the contract from renewal.
+	 * Cancellation clears `next_payment_gmt` itself - the flow disarms its own due moment
+	 * rather than relying on the scan's status predicate.
 	 */
 	public function test_cancel_transitions_the_contract(): void {
 		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
@@ -856,6 +944,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$reloaded = ( new ContractRepository() )->find( $contract_id );
 		$this->assertInstanceOf( Contract::class, $reloaded );
 		$this->assertSame( ContractStatus::CANCELLED, $reloaded->get_status() );
+		$this->assertNull( $reloaded->get_next_payment_gmt() );
 	}

 	/**
@@ -875,7 +964,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 				'contract_id'    => $contract_id,
 				'sequence_no'    => $previous->get_sequence_no() + 1,
 				'count'          => 2,
-				'status'         => CycleStatus::pending(),
+				'status'         => new CycleStatus( CycleStatus::PENDING ),
 				'starts_at_gmt'  => '2026-02-15 00:00:00',
 				'ends_at_gmt'    => '2026-03-15 00:00:00',
 				'expected_total' => '19.99',
@@ -893,7 +982,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {

 		$head = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $head );
-		$this->assertTrue( $head->get_status()->equals( CycleStatus::cancelled() ) );
+		$this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::CANCELLED ) ) );
 	}

 	/**
@@ -909,7 +998,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		// Cycle 1 stays billed (only a pending head is closed by cancel).
 		$head = ( new ContractRepository() )->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $head );
-		$this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
 	}


@@ -939,7 +1028,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$cycle = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $cycle );
 		$this->assertSame( 2, $cycle->get_count() );
-		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::processing() ) );
+		$this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::PROCESSING ) ) );
 		// A processing cycle carries no crash-recovery lease.
 		$this->assertNull( $cycle->get_claimed_until_gmt() );

@@ -974,7 +1063,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$repo            = new ContractRepository();
 		$processing_head = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $processing_head );
-		$this->assertTrue( $processing_head->get_status()->equals( CycleStatus::processing() ) );
+		$this->assertTrue( $processing_head->get_status()->equals( new CycleStatus( CycleStatus::PROCESSING ) ) );

 		// The async confirmation arrives: the order is paid. Drive completion as the listener would.
 		$order->payment_complete();
@@ -984,7 +1073,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {

 		$head = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $head );
-		$this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );

 		$reloaded = $repo->find( $contract_id );
 		$this->assertInstanceOf( Contract::class, $reloaded );
@@ -994,7 +1083,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$engine->complete_from_order( $paid_order );
 		$billed_head = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $billed_head );
-		$this->assertTrue( $billed_head->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $billed_head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
 	}

 	/**
@@ -1020,7 +1109,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$head = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $head );
 		$this->assertSame( 2, $head->get_count() );
-		$this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );

 		// The scheduled path before cycle 2's period ends: selection skips, so nothing advances.
 		$ahead = $this->run_scheduled_renewal( $contract_id, new \DateTimeImmutable( '2026-03-01 00:00:00', new \DateTimeZone( 'UTC' ) ) );
@@ -1058,7 +1147,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$head = ( new ContractRepository() )->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $head );
 		$this->assertSame( 2, $head->get_count() );
-		$this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
 	}

 	/**
@@ -1087,7 +1176,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$cycle = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $cycle );
 		$this->assertSame( 2, $cycle->get_count() );
-		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );

 		// Schedule preserved: cycle 2 runs from cycle 1's end (2026-02-15), so next payment is one
 		// cadence on from that (2026-03-15) - not one cadence from the manual-renewal moment.
@@ -1119,7 +1208,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$failed = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $failed );
 		$this->assertSame( 2, $failed->get_count() );
-		$this->assertTrue( $failed->get_status()->equals( CycleStatus::failed() ) );
+		$this->assertTrue( $failed->get_status()->equals( new CycleStatus( CycleStatus::FAILED ) ) );

 		// The customer fixes their payment method; the same gateway now approves the retry.
 		remove_all_actions( 'woocommerce_subscriptions_engine_scheduled_payment_' . self::GATEWAY_DECLINING );
@@ -1144,7 +1233,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$head = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $head );
 		$this->assertSame( 2, $head->get_count() );
-		$this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );

 		// The failed order was reused, not duplicated.
 		$this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
@@ -1204,7 +1293,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {

 		$head = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $head );
-		$this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
 	}

 	/**
@@ -1236,7 +1325,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {

 		$head = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $head );
-		$this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
 		$this->assertSame( $draft->get_id(), $head->get_order_id(), 'The cycle link is healed from the meta match.' );
 	}

@@ -1268,7 +1357,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$head = ( new ContractRepository() )->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $head );
 		$this->assertSame( 2, $head->get_count() );
-		$this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
 		$this->assertSame( $renewal_order->get_id(), $head->get_order_id(), 'The cycle is linked to its own order, not the stray.' );

 		$stray_after = wc_get_order( $stray->get_id() );
@@ -1350,7 +1439,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {

 		$head = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $head );
-		$this->assertTrue( $head->get_status()->equals( CycleStatus::pending() ), 'The linked head is not settled by a rogue order.' );
+		$this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ), 'The linked head is not settled by a rogue order.' );
 		$this->assertSame( $order_a->get_id(), $head->get_order_id() );
 	}

@@ -1380,7 +1469,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {

 		$head = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $head );
-		$this->assertTrue( $head->get_status()->equals( CycleStatus::pending() ), 'No settlement happens from a stale order copy.' );
+		$this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ), 'No settlement happens from a stale order copy.' );
 	}

 	/**
@@ -1456,14 +1545,14 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$repo = new ContractRepository();
 		$head = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $head );
-		$this->assertTrue( $head->get_status()->equals( CycleStatus::processing() ) );
+		$this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::PROCESSING ) ) );

 		// An admin marks the order paid by hand; the status-transition listener settles.
 		$renewal_order->update_status( 'processing' );

 		$settled = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $settled );
-		$this->assertTrue( $settled->get_status()->equals( CycleStatus::billed() ), 'The manual paid transition bills the cycle.' );
+		$this->assertTrue( $settled->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ), 'The manual paid transition bills the cycle.' );

 		// The schedule advanced to the billed cycle's own period end.
 		$reloaded = $repo->find( $contract_id );
@@ -1678,7 +1767,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 				'contract_id'    => $contract_id,
 				'sequence_no'    => $previous->get_sequence_no() + 1,
 				'count'          => 2,
-				'status'         => CycleStatus::pending(),
+				'status'         => new CycleStatus( CycleStatus::PENDING ),
 				'starts_at_gmt'  => '2026-02-15 00:00:00',
 				'ends_at_gmt'    => '2026-03-15 00:00:00',
 				'expected_total' => '19.99',
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/StatusAwareDueScanTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/StatusAwareDueScanTest.php
deleted file mode 100644
index 5510c34462c..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/StatusAwareDueScanTest.php
+++ /dev/null
@@ -1,168 +0,0 @@
-<?php
-/**
- * Integration tests for the status-aware due scan: a non-active contract is never
- * charged when its renewal fires. An on-hold contract is skipped (no charge while
- * held) and a pending-cancellation contract creates no renewal order at its date.
- *
- * Note: actually terminating a pending-cancellation contract at the date (moving it
- * terminal) is a follow-up slice; the current dispatcher only refuses to charge it.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Renewal;
-
-use EngineIntegrationTestCase;
-use WC_Order;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalIntent;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine
- */
-class StatusAwareDueScanTest extends EngineIntegrationTestCase {
-
-	private const GATEWAY = 'engine_test_gateway';
-
-	/**
-	 * @var ContractRepository
-	 */
-	private $contracts;
-
-	/**
-	 * @var RenewalEngine
-	 */
-	private $engine;
-
-	public function set_up(): void {
-		parent::set_up();
-		GatewayCapabilities::reset();
-		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
-
-		$this->contracts = new ContractRepository();
-		$this->engine    = new RenewalEngine( $this->contracts );
-	}
-
-	public function tear_down(): void {
-		GatewayCapabilities::reset();
-		parent::tear_down();
-	}
-
-	private function make_origin_order(): WC_Order {
-		$order = new WC_Order();
-		$order->set_currency( 'USD' );
-		$order->set_payment_method( self::GATEWAY );
-		$order->set_total( '19.99' );
-		$order->save();
-
-		return $order;
-	}
-
-	/**
-	 * Seed a contract at a status with a due (past) next-payment date.
-	 *
-	 * @param string $status Contract status.
-	 */
-	private function seed_due( string $status ): int {
-		$order    = $this->make_origin_order();
-		$contract = Contract::create(
-			array(
-				'customer_id'      => 1,
-				'status'           => $status,
-				'currency'         => 'USD',
-				'selling_plan_id'  => 1,
-				'origin_order_id'  => $order->get_id(),
-				'payment_method'   => self::GATEWAY,
-				'start_gmt'        => '2026-01-01 00:00:00',
-				'next_payment_gmt' => '2026-02-01 00:00:00',
-				'end_gmt'          => ContractStatus::PENDING_CANCELLATION === $status ? '2026-02-01 00:00:00' : null,
-				'billing_total'    => '19.99',
-			)
-		);
-
-		return $this->contracts->insert( $contract );
-	}
-
-	/**
-	 * Process a renewal for the contract, mirroring how the batch dispatcher hands the
-	 * engine a resolved intent for the due contract. The non-active status guard in
-	 * {@see RenewalEngine::process()} short-circuits ahead of any cycle selection, so the
-	 * exact cycle count handed in is immaterial here.
-	 *
-	 * @param int $contract_id Contract id.
-	 */
-	private function process_renewal( int $contract_id ): ?WC_Order {
-		return $this->engine->process(
-			new RenewalIntent( $contract_id, 1 ),
-			new \DateTimeImmutable( '2026-02-01 00:00:00', new \DateTimeZone( 'UTC' ) )
-		);
-	}
-
-	/**
-	 * Count renewal orders tagged for a contract.
-	 *
-	 * @param int $contract_id Contract id.
-	 */
-	private function renewal_order_count( int $contract_id ): int {
-		$orders = wc_get_orders(
-			array(
-				'limit'      => -1,
-				'status'     => 'any',
-				'type'       => 'shop_order',
-				'meta_key'   => OrderLinkage::META_CONTRACT_ID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
-				'meta_value' => (string) $contract_id,          // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
-			)
-		);
-
-		$count = 0;
-		foreach ( is_array( $orders ) ? $orders : array() as $order ) {
-			if ( $order instanceof WC_Order && OrderLinkage::RELATION_RENEWAL === $order->get_meta( OrderLinkage::META_RELATION_TYPE ) ) {
-				++$count;
-			}
-		}
-
-		return $count;
-	}
-
-	public function test_on_hold_contract_at_its_date_creates_no_renewal_order(): void {
-		$id = $this->seed_due( ContractStatus::ON_HOLD );
-
-		$result = $this->process_renewal( $id );
-
-		$this->assertNull( $result );
-		$this->assertSame( 0, $this->renewal_order_count( $id ) );
-		// Still on hold; the next-payment date is left for reactivate to re-arm.
-		$this->assertSame( ContractStatus::ON_HOLD, $this->reload( $id )->get_status() );
-	}
-
-	public function test_pending_cancellation_at_its_date_creates_no_renewal_order(): void {
-		$id = $this->seed_due( ContractStatus::PENDING_CANCELLATION );
-
-		$result = $this->process_renewal( $id );
-
-		$this->assertNull( $result, 'No renewal order is created for a non-active contract.' );
-		$this->assertSame( 0, $this->renewal_order_count( $id ) );
-		// The contract is not charged. Terminating it at the date is a follow-up slice,
-		// so it remains pending-cancellation for now.
-		$this->assertSame( ContractStatus::PENDING_CANCELLATION, $this->reload( $id )->get_status() );
-	}
-
-	/**
-	 * Reload a contract, asserting it still exists (narrows the nullable read).
-	 *
-	 * @param int $id Contract id.
-	 */
-	private function reload( int $id ): Contract {
-		$contract = $this->contracts->find( $id );
-		$this->assertInstanceOf( Contract::class, $contract );
-
-		return $contract;
-	}
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
index a0a0c0d6908..767b88862b9 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
@@ -14,6 +14,7 @@ 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\StatusRegistry;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
@@ -33,11 +34,21 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	 */
 	private $sut;

+	/**
+	 * The owner the due-scan fixtures carry, registered as a consumer in setUp().
+	 */
+	private const OWNER = 'engine-tests';
+
 	public function setUp(): void {
 		parent::setUp();
 		$this->sut = new ContractRepository();
 	}

+	public function tearDown(): void {
+		StatusRegistry::reset();
+		parent::tearDown();
+	}
+
 	private function make_contract(): Contract {
 		return Contract::create(
 			array(
@@ -498,8 +509,8 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {

 		$counts = $this->sut->count_by_status();

-		// Every known status is a key, in ContractStatus::all() order, with absent ones 0.
-		$this->assertSame( ContractStatus::all(), array_keys( $counts ) );
+		// Every known status is a key, in ContractStatus::get_all() order, with absent ones 0.
+		$this->assertSame( ContractStatus::get_all(), array_keys( $counts ) );
 		$this->assertSame( 2, $counts[ ContractStatus::ACTIVE ] );
 		$this->assertSame( 1, $counts[ ContractStatus::ON_HOLD ] );
 		$this->assertSame( 0, $counts[ ContractStatus::PENDING_CANCELLATION ] );
@@ -513,7 +524,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	public function test_count_by_status_is_all_zero_when_empty(): void {
 		$counts = $this->sut->count_by_status();

-		$this->assertSame( ContractStatus::all(), array_keys( $counts ) );
+		$this->assertSame( ContractStatus::get_all(), array_keys( $counts ) );
 		$this->assertSame( array( 0, 0, 0, 0, 0 ), array_values( $counts ) );
 	}

@@ -622,7 +633,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	public function test_insert_with_origin_cycle_records_refs_on_the_contract(): void {
 		$contract = $this->make_contract();
 		$cycle    = $this->make_cycle( 0, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', $this->sample_plan_snapshot(), $this->sample_items_snapshot(), 1001 );
-		$cycle->set_status( CycleStatus::billed() );
+		$cycle->set_status( new CycleStatus( CycleStatus::BILLED ) );

 		$id = $this->sut->insert_with_origin_cycle( $contract, $cycle );
 		$this->assertGreaterThan( 0, $id );
@@ -642,7 +653,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$current = $this->sut->find_chain_head( $id );
 		$this->assertInstanceOf( Cycle::class, $current );
 		$this->assertSame( 1, $current->get_count() );
-		$this->assertTrue( $current->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $current->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
 	}

 	/**
@@ -797,7 +808,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$this->assertSame( $cycle->get_id(), $current->get_id() );
 		$this->assertSame( 1, $current->get_sequence_no() );
 		$this->assertSame( 1, $current->get_count() );
-		$this->assertTrue( $current->get_status()->equals( CycleStatus::pending() ) );
+		$this->assertTrue( $current->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ) );
 		$this->assertSame( '2026-07-15 00:00:00', $current->get_starts_at_gmt() );
 		$this->assertSame( '19.99000000', $current->get_expected_total() );
 		$this->assertSame( 'lite', $current->get_extension_slug() );
@@ -946,12 +957,12 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$cycle = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' );
 		$this->sut->append_cycle( $cycle );

-		$cycle->set_status( CycleStatus::billed() );
+		$cycle->set_status( new CycleStatus( CycleStatus::BILLED ) );
 		$this->sut->update_cycle( $cycle );

 		$reloaded = $this->sut->find_chain_head( $id );
 		$this->assertInstanceOf( Cycle::class, $reloaded );
-		$this->assertTrue( $reloaded->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $reloaded->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
 	}

 	/**
@@ -966,7 +977,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 				'contract_id'    => $id,
 				'sequence_no'    => 1,
 				'count'          => 1,
-				'status'         => CycleStatus::pending(),
+				'status'         => new CycleStatus( CycleStatus::PENDING ),
 				'starts_at_gmt'  => '2026-07-15 00:00:00',
 				'ends_at_gmt'    => '2026-08-15 00:00:00',
 				'expected_total' => '19.99',
@@ -981,7 +992,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$this->assertSame( '2026-07-15 00:15:00', $reloaded->get_claimed_until_gmt() );

 		// Cleared on update (a settled cycle holds no lease).
-		$reloaded->set_status( CycleStatus::billed() );
+		$reloaded->set_status( new CycleStatus( CycleStatus::BILLED ) );
 		$reloaded->set_claimed_until_gmt( null );
 		$this->sut->update_cycle( $reloaded );

@@ -1047,7 +1058,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$cycle = $this->append_pending_cycle_with_lease( $id, gmdate( 'Y-m-d H:i:s', time() - 60 ) );

 		// Settle it billed (clearing the lease, as the money-path does).
-		$cycle->set_status( CycleStatus::billed() );
+		$cycle->set_status( new CycleStatus( CycleStatus::BILLED ) );
 		$cycle->set_claimed_until_gmt( null );
 		$this->sut->update_cycle( $cycle );

@@ -1067,7 +1078,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 				'contract_id'    => $contract_id,
 				'sequence_no'    => 1,
 				'count'          => 1,
-				'status'         => CycleStatus::pending(),
+				'status'         => new CycleStatus( CycleStatus::PENDING ),
 				'starts_at_gmt'  => '2026-07-15 00:00:00',
 				'ends_at_gmt'    => '2026-08-15 00:00:00',
 				'expected_total' => '19.99',
@@ -1093,12 +1104,66 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {

 		$ids = $this->due_ids( $now, 50 );

-		// Only the two due+active contracts, oldest-due first; the future and the non-active excluded.
+		// Only the two due+active contracts, oldest-due first; the future and the past-due
+		// on-hold row excluded (the interim renewal-flow status predicate).
 		$this->assertSame( array( $due_old, $due_recent ), $ids );
 		$this->assertNotContains( $not_yet, $ids );
 		$this->assertNotContains( $on_hold, $ids );
 	}

+	/**
+	 * @testdox find_due skips a due contract whose stored status is not registered.
+	 */
+	public function test_find_due_skips_a_contract_with_an_unregistered_stored_status(): void {
+		global $wpdb;
+
+		$now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+		$id  = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'status' => 'legacy-paused' ), array( 'id' => $id ) );
+
+		$this->assertSame( array(), $this->due_ids( $now, 50 ) );
+	}
+
+	/**
+	 * @testdox find_due skips a due contract with no owner.
+	 */
+	public function test_find_due_skips_a_contract_with_no_owner(): void {
+		$now      = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+		$owned    = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
+		$no_owner = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::BILLED, null, null, null );
+
+		$ids = $this->due_ids( $now, 50 );
+
+		$this->assertContains( $owned, $ids );
+		$this->assertNotContains( $no_owner, $ids );
+	}
+
+	/**
+	 * @testdox find_due skips a contract whose owner is not registered, and selects it untouched once it registers.
+	 */
+	public function test_find_due_skips_an_unregistered_owner_until_it_registers(): void {
+		$now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+		$id  = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::BILLED, null, null, 'other-ext' );
+
+		$this->assertNotContains( $id, $this->due_ids( $now, 50 ) );
+
+		$this->assertContains( $id, $this->due_ids( $now, 50, array( self::OWNER, 'other-ext' ) ) );
+		$contract = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+		$this->assertSame( '2026-06-15 00:00:00', $contract->get_next_payment_gmt(), 'The waiting contract keeps its due moment.' );
+	}
+
+	/**
+	 * @testdox find_due returns nothing when no consumer is registered.
+	 */
+	public function test_find_due_returns_nothing_when_no_consumer_is_registered(): void {
+		$now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+		$this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
+
+		$this->assertSame( array(), $this->sut->find_due( $now, 50, array() ) );
+	}
+
 	/**
 	 * @testdox find_due treats the cutoff as inclusive and excludes a null next_payment.
 	 */
@@ -1150,7 +1215,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );

 		$id         = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
-		$candidates = $this->sut->find_due( $now, 50 );
+		$candidates = $this->sut->find_due( $now, 50, array( self::OWNER ) );

 		$this->assertCount( 1, $candidates );
 		$row = $candidates[0];
@@ -1213,8 +1278,8 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
 		$this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );

-		$this->assertSame( array(), $this->sut->find_due( $now, 0 ) );
-		$this->assertSame( array(), $this->sut->find_due( $now, -1 ) );
+		$this->assertSame( array(), $this->sut->find_due( $now, 0, array( self::OWNER ) ) );
+		$this->assertSame( array(), $this->sut->find_due( $now, -1, array( self::OWNER ) ) );
 	}

 	/**
@@ -1246,19 +1311,125 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$this->assertSame( 4242, $after->get_order_id() );
 	}

+	/**
+	 * @testdox transition_cycle_status rejects an unregistered target status and writes nothing.
+	 */
+	public function test_transition_cycle_status_rejects_an_unregistered_target(): void {
+		$contract_id = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::PENDING );
+
+		$head = $this->sut->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $head );
+
+		try {
+			$this->sut->transition_cycle_status( (int) $head->get_id(), CycleStatus::PENDING, 'never-registered', 4242 );
+			$this->fail( 'Expected a DomainException for an unregistered target status.' );
+		} catch ( \DomainException $e ) {
+			$after = $this->sut->find_chain_head( $contract_id );
+			$this->assertInstanceOf( Cycle::class, $after );
+			$this->assertSame( CycleStatus::PENDING, $after->get_status()->get_value() );
+		}
+	}
+
+	/**
+	 * @testdox transition_cycle_status accepts an extension-registered target status.
+	 */
+	public function test_transition_cycle_status_accepts_an_extension_registered_target(): void {
+		StatusRegistry::register( StatusRegistry::KIND_CYCLE, 'disputed' );
+		$contract_id = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::PENDING );
+
+		$head = $this->sut->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $head );
+
+		$this->assertTrue( $this->sut->transition_cycle_status( (int) $head->get_id(), CycleStatus::PENDING, 'disputed', 4242 ) );
+
+		$after = $this->sut->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $after );
+		$this->assertSame( 'disputed', $after->get_status()->get_value() );
+	}
+
+	/**
+	 * @testdox An unknown stored contract status hydrates and survives an unrelated update.
+	 */
+	public function test_an_unknown_stored_contract_status_round_trips(): void {
+		global $wpdb;
+
+		$id    = $this->sut->insert( $this->make_contract() );
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$wpdb->update( $table, array( 'status' => 'legacy-paused' ), array( 'id' => $id ) );
+
+		$contract = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+		$this->assertSame( 'legacy-paused', $contract->get_status() );
+
+		$contract->set_next_payment_gmt( '2026-09-15 00:00:00' );
+		$this->assertTrue( $this->sut->update( $contract ) );
+		$this->assertTrue( $this->sut->update_if_status( $contract, 'legacy-paused' ) );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$stored = $wpdb->get_row( $wpdb->prepare( "SELECT status, next_payment_gmt FROM {$table} WHERE id = %d", $id ), ARRAY_A );
+		$this->assertSame( 'legacy-paused', $stored['status'] );
+		$this->assertSame( '2026-09-15 00:00:00', $stored['next_payment_gmt'] );
+	}
+
+	/**
+	 * @testdox An unknown stored cycle status hydrates through every cycle read and survives an update.
+	 */
+	public function test_an_unknown_stored_cycle_status_hydrates_and_round_trips(): void {
+		global $wpdb;
+
+		$id    = $this->sut->insert( $this->make_contract() );
+		$cycle = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', $this->sample_plan_snapshot(), $this->sample_items_snapshot() );
+		$this->sut->append_cycle( $cycle );
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$wpdb->update( $table, array( 'status' => 'legacy-x' ), array( 'id' => $cycle->get_id() ) );
+
+		$head = $this->sut->find_chain_head( $id );
+		$this->assertInstanceOf( Cycle::class, $head );
+		$this->assertSame( 'legacy-x', $head->get_status()->get_value() );
+
+		$history = $this->sut->find_cycle_history( $id );
+		$this->assertCount( 1, $history );
+		$this->assertSame( 'legacy-x', $history[0]->get_status()->get_value() );
+
+		$head->set_reason( 'annotated' );
+		$this->sut->update_cycle( $head );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$stored = $wpdb->get_row( $wpdb->prepare( "SELECT status, reason FROM {$table} WHERE id = %d", $cycle->get_id() ), ARRAY_A );
+		$this->assertSame( 'legacy-x', $stored['status'] );
+		$this->assertSame( 'annotated', $stored['reason'] );
+	}
+
+	/**
+	 * @testdox count_by_status keys include extension-registered contract statuses.
+	 */
+	public function test_count_by_status_includes_registered_extension_statuses(): void {
+		StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+		$this->insert_list_contract( 'paused-by-merchant' );
+
+		$counts = $this->sut->count_by_status();
+
+		$this->assertSame( ContractStatus::get_all(), array_keys( $counts ) );
+		$this->assertSame( 1, $counts['paused-by-merchant'] );
+	}
+
 	/**
 	 * The contract ids of the due scan at `$now`, in scan order.
 	 *
-	 * @param \DateTimeImmutable $now   The cutoff moment.
-	 * @param int                $limit The batch size.
+	 * @param \DateTimeImmutable      $now    The cutoff moment.
+	 * @param int                     $limit  The batch size.
+	 * @param array<int, string>|null $owners Owners to scan; defaults to the test owner.
 	 * @return array<int, int>
 	 */
-	private function due_ids( \DateTimeImmutable $now, int $limit ): array {
+	private function due_ids( \DateTimeImmutable $now, int $limit, ?array $owners = null ): array {
 		return array_map(
 			static function ( RenewalCandidate $candidate ): int {
 				return $candidate->get_contract_id();
 			},
-			$this->sut->find_due( $now, $limit )
+			$this->sut->find_due( $now, $limit, $owners ?? array( self::OWNER ) )
 		);
 	}

@@ -1273,6 +1444,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	 * @param string      $head_status      The head cycle status (a CycleStatus value).
 	 * @param string|null $claimed_until    The head cycle lease expiry, or null for none.
 	 * @param string|null $head_ends_at     The head period end; defaults to `$next_payment_gmt`.
+	 * @param string|null $owner            The owning extension slug; defaults to the registered test owner.
 	 */
 	private function insert_contract_due_at(
 		?string $next_payment_gmt,
@@ -1280,7 +1452,8 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		string $schedule_source = Contract::SCHEDULE_SOURCE_PRIMITIVE,
 		string $head_status = CycleStatus::BILLED,
 		?string $claimed_until = null,
-		?string $head_ends_at = null
+		?string $head_ends_at = null,
+		?string $owner = self::OWNER
 	): int {
 		$contract = Contract::create(
 			array(
@@ -1292,6 +1465,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 				'next_payment_gmt' => $next_payment_gmt,
 				'status'           => $status,
 				'schedule_source'  => $schedule_source,
+				'extension_slug'   => $owner,
 			)
 		);
 		$id       = $this->sut->insert( $contract );
@@ -1303,7 +1477,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 						'contract_id'    => $id,
 						'sequence_no'    => 1,
 						'count'          => 1,
-						'status'         => CycleStatus::from( $head_status ),
+						'status'         => new CycleStatus( $head_status ),
 						'starts_at_gmt'  => '2026-01-15 00:00:00',
 						'ends_at_gmt'    => $head_ends_at ?? $next_payment_gmt,
 						'expected_total' => '19.99',
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 bb92abb6525..78bd8fa2528 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
@@ -234,20 +234,29 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox The due_contract index keys the dispatcher scan as (status, next_payment_gmt).
+	 * @testdox The due_owner index keys the dispatcher scan as (extension_slug, next_payment_gmt).
 	 */
-	public function test_contracts_due_contract_index_keys_the_dispatcher_scan(): void {
+	public function test_contracts_due_owner_index_keys_the_dispatcher_scan(): void {
 		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );

-		// The dispatcher scans status=active AND next_payment_gmt <= now; the status-first
-		// column order is load-bearing for the index, so assert it exactly.
-		$this->assertContains( 'due_contract', $this->index_names( $table ) );
+		// The dispatcher scans extension_slug IN (registered owners) AND next_payment_gmt <= now;
+		// the owner-first column order is load-bearing for the index, so assert it exactly.
+		// The retired due_contract index is not asserted absent: dbDelta never drops an index,
+		// so it lingers on any reused database until the tables are recreated.
+		$this->assertContains( 'due_owner', $this->index_names( $table ) );
 		$this->assertSame(
-			array( 'status', 'next_payment_gmt' ),
-			$this->index_columns( $table, 'due_contract' )
+			array( 'extension_slug', 'next_payment_gmt' ),
+			$this->index_columns( $table, 'due_owner' )
 		);
 	}

+	/**
+	 * @testdox The schema version is 2.4.0 (owner-scoped due scan).
+	 */
+	public function test_schema_version_is_2_4_0(): void {
+		$this->assertSame( '2.4.0', SchemaInstaller::get_version() );
+	}
+
 	public function test_cycles_table_has_expected_columns(): void {
 		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );

diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTest.php
index f7eaa2bc833..7082643bf13 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTest.php
@@ -1,6 +1,6 @@
 <?php
 /**
- * Unit tests for the ContractStatus state machine.
+ * Unit tests for the registry-backed ContractStatus helpers.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine
  */
@@ -9,93 +9,63 @@ declare( strict_types=1 );

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

-use DomainException;
 use PHPUnit\Framework\TestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus
  */
 class ContractStatusTest extends TestCase {

-	public function test_known_statuses_are_valid(): void {
-		$this->assertTrue( ContractStatus::is_valid( ContractStatus::ACTIVE ) );
-		$this->assertFalse( ContractStatus::is_valid( 'nonsense' ) );
+	protected function tearDown(): void {
+		StatusRegistry::reset();
+		parent::tearDown();
 	}

-	public function test_active_can_move_to_hold_and_back(): void {
-		$this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::ON_HOLD ) );
-		$this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ON_HOLD, ContractStatus::ACTIVE ) );
+	public function test_known_statuses_are_valid(): void {
+		$this->assertTrue( ContractStatus::is_registered( ContractStatus::ACTIVE ) );
+		$this->assertFalse( ContractStatus::is_registered( 'nonsense' ) );
 	}

-	public function test_active_reaches_every_other_status(): void {
-		$this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::ON_HOLD ) );
-		$this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::PENDING_CANCELLATION ) );
-		$this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::CANCELLED ) );
-		$this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::EXPIRED ) );
+	public function test_defaults_lists_the_five_engine_slugs(): void {
+		$this->assertSame(
+			array( 'active', 'on-hold', 'pending-cancellation', 'cancelled', 'expired' ),
+			ContractStatus::get_defaults()
+		);
 	}

-	public function test_on_hold_cannot_expire(): void {
-		$this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ON_HOLD, ContractStatus::PENDING_CANCELLATION ) );
-		$this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ON_HOLD, ContractStatus::CANCELLED ) );
-		$this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::ON_HOLD, ContractStatus::EXPIRED ) );
+	public function test_all_equals_the_defaults_with_nothing_registered(): void {
+		$this->assertSame( ContractStatus::get_defaults(), ContractStatus::get_all() );
 	}

-	public function test_cancelled_and_expired_are_terminal(): void {
-		$this->assertTrue( ContractStatus::is_terminal( ContractStatus::CANCELLED ) );
-		$this->assertTrue( ContractStatus::is_terminal( ContractStatus::EXPIRED ) );
+	public function test_an_extension_registered_status_is_listed_and_valid(): void {
+		StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );

-		foreach ( ContractStatus::all() as $target ) {
-			$this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::CANCELLED, $target ) );
-			$this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::EXPIRED, $target ) );
-		}
-	}
+		$all = ContractStatus::get_all();

-	public function test_pending_cancellation_only_reaches_active_or_cancelled(): void {
-		$this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::PENDING_CANCELLATION, ContractStatus::ACTIVE ) );
-		$this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::PENDING_CANCELLATION, ContractStatus::CANCELLED ) );
-		$this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::PENDING_CANCELLATION, ContractStatus::ON_HOLD ) );
-		$this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::PENDING_CANCELLATION, ContractStatus::EXPIRED ) );
+		$this->assertSame( 'paused-by-merchant', end( $all ) );
+		$this->assertTrue( ContractStatus::is_registered( 'paused-by-merchant' ) );
 	}

-	public function test_unknown_statuses_never_transition(): void {
-		$this->assertFalse( ContractStatus::is_transition_allowed( 'nonsense', ContractStatus::ACTIVE ) );
-		$this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, 'nonsense' ) );
-	}
+	public function test_a_cycle_registration_does_not_make_a_contract_status_valid(): void {
+		StatusRegistry::register( StatusRegistry::KIND_CYCLE, 'disputed' );

-	public function test_same_status_is_not_an_allowed_transition(): void {
-		// set_status() short-circuits no-ops; the table itself reports a
-		// same-status move as not allowed.
-		$this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::ACTIVE ) );
+		$this->assertFalse( ContractStatus::is_registered( 'disputed' ) );
 	}

-	public function test_can_transition_aliases_is_transition_allowed(): void {
-		$this->assertSame(
-			ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::ON_HOLD ),
-			ContractStatus::can_transition( ContractStatus::ACTIVE, ContractStatus::ON_HOLD )
-		);
-		$this->assertSame(
-			ContractStatus::is_transition_allowed( ContractStatus::CANCELLED, ContractStatus::ACTIVE ),
-			ContractStatus::can_transition( ContractStatus::CANCELLED, ContractStatus::ACTIVE )
-		);
-	}
-
-	public function test_assert_transition_allowed_passes_for_a_legal_move(): void {
-		ContractStatus::assert_transition_allowed( ContractStatus::ACTIVE, ContractStatus::CANCELLED );
-
-		// No exception thrown.
-		$this->addToAssertionCount( 1 );
-	}
-
-	public function test_assert_transition_allowed_throws_for_an_illegal_move(): void {
-		$this->expectException( DomainException::class );
-
-		ContractStatus::assert_transition_allowed( ContractStatus::CANCELLED, ContractStatus::ACTIVE );
+	public function test_the_retired_transition_api_is_gone(): void {
+		foreach ( array( 'is_transition_allowed', 'can_transition', 'assert_transition_allowed', 'is_terminal' ) as $method ) {
+			$this->assertFalse( method_exists( ContractStatus::class, $method ), "ContractStatus::{$method}() should not exist." );
+		}
 	}

-	public function test_assert_transition_allowed_throws_out_of_a_terminal_status(): void {
-		$this->expectException( DomainException::class );
-
-		ContractStatus::assert_transition_allowed( ContractStatus::EXPIRED, ContractStatus::ON_HOLD );
+	public function test_is_valid_checks_the_slug_format_only(): void {
+		$this->assertTrue( ContractStatus::is_valid( ContractStatus::PENDING_CANCELLATION ) );
+		$this->assertTrue( ContractStatus::is_valid( 'legacy-x' ), 'Well-formed but unregistered.' );
+		$this->assertFalse( ContractStatus::is_registered( 'legacy-x' ) );
+		$this->assertFalse( ContractStatus::is_valid( 'On Hold' ) );
+		$this->assertFalse( ContractStatus::is_valid( "active\n" ) );
+		$this->assertFalse( ContractStatus::is_valid( str_repeat( 'a', 21 ) ) );
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTransitionTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTransitionTest.php
deleted file mode 100644
index 8c8cbd3261d..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTransitionTest.php
+++ /dev/null
@@ -1,85 +0,0 @@
-<?php
-/**
- * Unit tests for status-transition enforcement on the Contract entity.
- *
- * Confirms that Contract::set_status() routes every status change through the
- * ContractStatus state machine, so illegal moves are rejected at the entity
- * boundary rather than being persisted.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
-
-use DomainException;
-use PHPUnit\Framework\TestCase;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract::set_status
- */
-class ContractStatusTransitionTest extends TestCase {
-
-	/**
-	 * Build a minimal active contract for transition tests.
-	 *
-	 * @param string $status Starting status.
-	 */
-	private function make_contract( string $status = ContractStatus::ACTIVE ): Contract {
-		return Contract::create(
-			array(
-				'customer_id'     => 1,
-				'currency'        => 'USD',
-				'selling_plan_id' => 1,
-				'origin_order_id' => 1,
-				'start_gmt'       => '2026-01-01 00:00:00',
-				'status'          => $status,
-				'billing_total'   => '10.00',
-				'schedule_source' => Contract::SCHEDULE_SOURCE_PRIMITIVE,
-				'items'           => array(),
-				'addresses'       => array(),
-				'meta'            => array(),
-			)
-		);
-	}
-
-	public function test_legal_transition_is_applied(): void {
-		$contract = $this->make_contract( ContractStatus::ACTIVE );
-
-		$contract->set_status( ContractStatus::ON_HOLD );
-
-		$this->assertSame( ContractStatus::ON_HOLD, $contract->get_status() );
-	}
-
-	public function test_same_status_is_a_noop(): void {
-		$contract = $this->make_contract( ContractStatus::ACTIVE );
-
-		// Active is terminal-free but cannot transition to itself in the table;
-		// set_status() must treat the no-op as a no-op, not an exception.
-		$contract->set_status( ContractStatus::ACTIVE );
-
-		$this->assertSame( ContractStatus::ACTIVE, $contract->get_status() );
-	}
-
-	public function test_illegal_transition_throws_and_leaves_status_unchanged(): void {
-		$contract = $this->make_contract( ContractStatus::CANCELLED );
-
-		try {
-			$contract->set_status( ContractStatus::ACTIVE );
-			$this->fail( 'Expected a DomainException for a transition out of a terminal status.' );
-		} catch ( DomainException $e ) {
-			$this->assertSame( ContractStatus::CANCELLED, $contract->get_status() );
-		}
-	}
-
-	public function test_pending_cancellation_to_on_hold_is_rejected(): void {
-		$contract = $this->make_contract( ContractStatus::PENDING_CANCELLATION );
-
-		$this->expectException( DomainException::class );
-
-		$contract->set_status( ContractStatus::ON_HOLD );
-	}
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
index 93c24a1c0ef..d04957c122f 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
@@ -19,6 +19,7 @@ use DomainException;
 use PHPUnit\Framework\TestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\InstrumentRef;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;

@@ -27,6 +28,11 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
  */
 class ContractTest extends TestCase {

+	protected function tearDown(): void {
+		StatusRegistry::reset();
+		parent::tearDown();
+	}
+
 	/**
 	 * A complete, valid contract row.
 	 *
@@ -401,4 +407,114 @@ class ContractTest extends TestCase {

 		$this->assertArrayNotHasKey( 'plan_snapshot', $contract->to_storage(), 'plan_snapshot is a hydrated read-only field, not a stored column.' );
 	}
+
+	/**
+	 * Hydrate a stored contract row with the given status.
+	 *
+	 * @param string $status Stored status.
+	 */
+	private function stored_contract( string $status ): Contract {
+		$row           = $this->valid_row();
+		$row['status'] = $status;
+
+		return Contract::from_storage( $row );
+	}
+
+	/**
+	 * @testdox set_status() moves between any two registered statuses (no transition table).
+	 */
+	public function test_set_status_moves_between_any_registered_statuses(): void {
+		$cancelled = $this->stored_contract( ContractStatus::CANCELLED );
+		$cancelled->set_status( ContractStatus::ACTIVE );
+		$this->assertSame( ContractStatus::ACTIVE, $cancelled->get_status() );
+
+		$pending_cancellation = $this->stored_contract( ContractStatus::PENDING_CANCELLATION );
+		$pending_cancellation->set_status( ContractStatus::ON_HOLD );
+		$this->assertSame( ContractStatus::ON_HOLD, $pending_cancellation->get_status() );
+	}
+
+	/**
+	 * @testdox set_status() with the current status is a no-op.
+	 */
+	public function test_set_status_to_the_same_status_is_a_no_op(): void {
+		$contract = $this->make_contract();
+
+		$contract->set_status( ContractStatus::ACTIVE );
+
+		$this->assertSame( ContractStatus::ACTIVE, $contract->get_status() );
+	}
+
+	/**
+	 * @testdox set_status() rejects an unregistered status and leaves the status unchanged.
+	 */
+	public function test_set_status_rejects_an_unregistered_status(): void {
+		$contract = $this->make_contract();
+
+		try {
+			$contract->set_status( 'never-registered' );
+			$this->fail( 'Expected a DomainException for an unregistered status.' );
+		} catch ( DomainException $e ) {
+			$this->assertSame( ContractStatus::ACTIVE, $contract->get_status() );
+		}
+	}
+
+	/**
+	 * @testdox An extension-registered status is accepted by create() and set_status().
+	 */
+	public function test_an_extension_registered_status_is_writable(): void {
+		StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+
+		$created = Contract::create(
+			array(
+				'customer_id'     => 1,
+				'currency'        => 'USD',
+				'selling_plan_id' => 2,
+				'start_gmt'       => '2026-01-01 00:00:00',
+				'status'          => 'paused-by-merchant',
+			)
+		);
+		$this->assertSame( 'paused-by-merchant', $created->get_status() );
+
+		$contract = $this->make_contract();
+		$contract->set_status( 'paused-by-merchant' );
+		$this->assertSame( 'paused-by-merchant', $contract->get_status() );
+	}
+
+	/**
+	 * @testdox from_storage() hydrates an unregistered stored status and to_storage() returns it verbatim.
+	 */
+	public function test_an_unregistered_stored_status_round_trips(): void {
+		$contract = $this->stored_contract( 'legacy-paused' );
+
+		$this->assertSame( 'legacy-paused', $contract->get_status() );
+		$this->assertSame( 'legacy-paused', $contract->to_storage()['status'] );
+
+		$contract->set_next_payment_gmt( '2026-03-01 00:00:00' );
+		// Setting the same unregistered value is a no-op, not a registration failure.
+		$contract->set_status( 'legacy-paused' );
+
+		$this->assertSame( 'legacy-paused', $contract->to_storage()['status'] );
+	}
+
+	/**
+	 * @testdox set_meta() adds, overwrites, and (with null) removes a key.
+	 */
+	public function test_set_meta_adds_overwrites_and_removes_a_key(): void {
+		$contract = Contract::from_storage( $this->valid_row(), null, array(), array(), array( 'keep' => 'me' ) );
+
+		$contract->set_meta( 'k', 'v' );
+		$this->assertSame(
+			array(
+				'keep' => 'me',
+				'k'    => 'v',
+			),
+			$contract->get_meta()
+		);
+
+		$contract->set_meta( 'k', 'w' );
+		$this->assertSame( 'w', $contract->get_meta()['k'] );
+
+		$contract->set_meta( 'k', null );
+		$this->assertSame( array( 'keep' => 'me' ), $contract->get_meta() );
+	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleStatusTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleStatusTest.php
index 87ba8a67499..95d28f19b7d 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleStatusTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleStatusTest.php
@@ -1,6 +1,6 @@
 <?php
 /**
- * Unit tests for the CycleStatus state machine.
+ * Unit tests for the registry-backed CycleStatus value object.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine
  */
@@ -12,13 +12,19 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
 use DomainException;
 use PHPUnit\Framework\TestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;

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

-	public function test_all_returns_exactly_the_known_statuses(): void {
+	protected function tearDown(): void {
+		StatusRegistry::reset();
+		parent::tearDown();
+	}
+
+	public function test_get_all_returns_exactly_the_known_statuses(): void {
 		$this->assertSame(
 			array(
 				CycleStatus::PENDING,
@@ -27,50 +33,22 @@ class CycleStatusTest extends TestCase {
 				CycleStatus::FAILED,
 				CycleStatus::CANCELLED,
 			),
-			CycleStatus::all()
+			CycleStatus::get_all()
 		);
 	}

 	public function test_known_statuses_are_valid(): void {
-		$this->assertTrue( CycleStatus::is_valid( CycleStatus::PENDING ) );
-		$this->assertTrue( CycleStatus::is_valid( CycleStatus::PROCESSING ) );
-		$this->assertTrue( CycleStatus::is_valid( CycleStatus::BILLED ) );
-		$this->assertTrue( CycleStatus::is_valid( CycleStatus::FAILED ) );
-		$this->assertTrue( CycleStatus::is_valid( CycleStatus::CANCELLED ) );
-		$this->assertFalse( CycleStatus::is_valid( 'nonsense' ) );
-	}
-
-	public function test_pending_reaches_processing_billed_failed_and_cancelled(): void {
-		$this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::PROCESSING ) );
-		$this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::BILLED ) );
-		$this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::FAILED ) );
-		$this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::CANCELLED ) );
-	}
-
-	public function test_processing_settles_to_billed_failed_or_cancelled_but_not_back_to_pending(): void {
-		$this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PROCESSING, CycleStatus::BILLED ) );
-		$this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PROCESSING, CycleStatus::FAILED ) );
-		$this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PROCESSING, CycleStatus::CANCELLED ) );
-		$this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::PROCESSING, CycleStatus::PENDING ) );
-		$this->assertFalse( CycleStatus::is_terminal( CycleStatus::PROCESSING ) );
-	}
-
-	public function test_failed_can_be_retried_to_pending_or_cancelled(): void {
-		// A failed cycle can be retried (re-queued to pending by an admin trigger) or cancelled,
-		// but not settled directly to billed without re-attempting the charge.
-		$this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::FAILED, CycleStatus::PENDING ) );
-		$this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::FAILED, CycleStatus::CANCELLED ) );
-		$this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::FAILED, CycleStatus::BILLED ) );
+		$this->assertTrue( CycleStatus::is_registered( CycleStatus::PENDING ) );
+		$this->assertTrue( CycleStatus::is_registered( CycleStatus::PROCESSING ) );
+		$this->assertTrue( CycleStatus::is_registered( CycleStatus::BILLED ) );
+		$this->assertTrue( CycleStatus::is_registered( CycleStatus::FAILED ) );
+		$this->assertTrue( CycleStatus::is_registered( CycleStatus::CANCELLED ) );
+		$this->assertFalse( CycleStatus::is_registered( 'nonsense' ) );
 	}

 	public function test_billed_and_cancelled_are_terminal(): void {
 		$this->assertTrue( CycleStatus::is_terminal( CycleStatus::BILLED ) );
 		$this->assertTrue( CycleStatus::is_terminal( CycleStatus::CANCELLED ) );
-
-		foreach ( CycleStatus::all() as $target ) {
-			$this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::BILLED, $target ) );
-			$this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::CANCELLED, $target ) );
-		}
 	}

 	public function test_pending_processing_and_failed_are_not_terminal(): void {
@@ -79,82 +57,83 @@ class CycleStatusTest extends TestCase {
 		$this->assertFalse( CycleStatus::is_terminal( CycleStatus::FAILED ) );
 	}

-	public function test_unknown_statuses_never_transition(): void {
-		$this->assertFalse( CycleStatus::is_transition_allowed( 'nonsense', CycleStatus::BILLED ) );
-		$this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::PENDING, 'nonsense' ) );
-	}
+	public function test_unknown_and_extension_registered_statuses_are_not_terminal(): void {
+		StatusRegistry::register( StatusRegistry::KIND_CYCLE, 'disputed' );

-	public function test_same_status_is_not_an_allowed_transition(): void {
-		$this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::PENDING ) );
+		$this->assertFalse( CycleStatus::is_terminal( 'disputed' ) );
+		$this->assertFalse( CycleStatus::is_terminal( 'legacy-x' ) );
 	}

-	public function test_can_transition_aliases_is_transition_allowed(): void {
-		$this->assertSame(
-			CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::BILLED ),
-			CycleStatus::can_transition( CycleStatus::PENDING, CycleStatus::BILLED )
-		);
-		$this->assertSame(
-			CycleStatus::is_transition_allowed( CycleStatus::BILLED, CycleStatus::PENDING ),
-			CycleStatus::can_transition( CycleStatus::BILLED, CycleStatus::PENDING )
-		);
+	public function test_constructor_wraps_a_default_status(): void {
+		$this->assertSame( CycleStatus::PENDING, ( new CycleStatus( CycleStatus::PENDING ) )->get_value() );
 	}

-	public function test_assert_transition_allowed_passes_for_a_legal_move(): void {
-		CycleStatus::assert_transition_allowed( CycleStatus::PENDING, CycleStatus::BILLED );
-
-		// No exception thrown.
-		$this->addToAssertionCount( 1 );
-	}
-
-	public function test_assert_transition_allowed_throws_for_an_illegal_move(): void {
-		$this->expectException( DomainException::class );
-
-		CycleStatus::assert_transition_allowed( CycleStatus::FAILED, CycleStatus::BILLED );
+	/**
+	 * Registration is checked where a cycle status is written, not when the value is built,
+	 * so a stored status an unregistered extension wrote can still be loaded.
+	 */
+	public function test_constructor_wraps_a_well_formed_unregistered_status(): void {
+		$this->assertSame( 'legacy-x', ( new CycleStatus( 'legacy-x' ) )->get_value() );
 	}

-	public function test_assert_transition_allowed_throws_out_of_a_terminal_status(): void {
+	/**
+	 * @dataProvider malformed_slugs
+	 *
+	 * @param string $slug Malformed slug.
+	 */
+	public function test_constructor_rejects_a_malformed_slug( string $slug ): void {
 		$this->expectException( DomainException::class );

-		CycleStatus::assert_transition_allowed( CycleStatus::BILLED, CycleStatus::PENDING );
+		new CycleStatus( $slug );
+	}
+
+	/**
+	 * Strings that are not well-formed status slugs.
+	 *
+	 * @return array<string, array{0: string}>
+	 */
+	public function malformed_slugs(): array {
+		return array(
+			'empty'            => array( '' ),
+			'uppercase'        => array( 'Pending' ),
+			'double hyphen'    => array( 'on--hold' ),
+			'trailing newline' => array( "pending\n" ),
+			'markup'           => array( '<b>x</b>' ),
+			'over 20 chars'    => array( str_repeat( 'a', 21 ) ),
+		);
 	}

-	public function test_named_factories_carry_their_status_value(): void {
-		$this->assertSame( CycleStatus::PENDING, CycleStatus::pending()->get_value() );
-		$this->assertSame( CycleStatus::PROCESSING, CycleStatus::processing()->get_value() );
-		$this->assertSame( CycleStatus::BILLED, CycleStatus::billed()->get_value() );
-		$this->assertSame( CycleStatus::FAILED, CycleStatus::failed()->get_value() );
-		$this->assertSame( CycleStatus::CANCELLED, CycleStatus::cancelled()->get_value() );
+	public function test_is_valid_checks_the_slug_format_only(): void {
+		$this->assertTrue( CycleStatus::is_valid( CycleStatus::PENDING ) );
+		$this->assertTrue( CycleStatus::is_valid( 'legacy-x' ), 'Well-formed but unregistered.' );
+		$this->assertFalse( CycleStatus::is_registered( 'legacy-x' ) );
 	}

-	public function test_from_builds_a_known_status(): void {
-		$this->assertSame( CycleStatus::PENDING, CycleStatus::from( CycleStatus::PENDING )->get_value() );
+	/**
+	 * @dataProvider malformed_slugs
+	 *
+	 * @param string $slug Malformed slug.
+	 */
+	public function test_is_valid_rejects_a_malformed_slug( string $slug ): void {
+		$this->assertFalse( CycleStatus::is_valid( $slug ) );
 	}

-	public function test_from_rejects_an_unknown_status(): void {
-		$this->expectException( DomainException::class );
+	public function test_is_registered_is_scoped_to_cycle_registrations(): void {
+		StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'disputed' );
+		$this->assertFalse( CycleStatus::is_registered( 'disputed' ) );

-		CycleStatus::from( 'nonsense' );
+		StatusRegistry::register( StatusRegistry::KIND_CYCLE, 'disputed' );
+		$this->assertTrue( CycleStatus::is_registered( 'disputed' ) );
 	}

 	public function test_equals_compares_by_value(): void {
-		$this->assertTrue( CycleStatus::pending()->equals( CycleStatus::pending() ) );
-		$this->assertFalse( CycleStatus::pending()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( ( new CycleStatus( CycleStatus::PENDING ) )->equals( new CycleStatus( CycleStatus::PENDING ) ) );
+		$this->assertFalse( ( new CycleStatus( CycleStatus::PENDING ) )->equals( new CycleStatus( CycleStatus::BILLED ) ) );
 	}

-	public function test_can_transition_to_mirrors_the_static_table(): void {
-		$this->assertTrue( CycleStatus::pending()->can_transition_to( CycleStatus::billed() ) );
-		$this->assertFalse( CycleStatus::failed()->can_transition_to( CycleStatus::billed() ) );
-	}
-
-	public function test_transition_to_returns_the_target_for_a_legal_move(): void {
-		$next = CycleStatus::pending()->transition_to( CycleStatus::billed() );
-
-		$this->assertTrue( $next->equals( CycleStatus::billed() ) );
-	}
-
-	public function test_transition_to_throws_for_an_illegal_move(): void {
-		$this->expectException( DomainException::class );
-
-		CycleStatus::failed()->transition_to( CycleStatus::billed() );
+	public function test_the_retired_transition_api_and_factories_are_gone(): void {
+		foreach ( array( 'is_transition_allowed', 'can_transition', 'assert_transition_allowed', 'can_transition_to', 'transition_to', 'from', 'stored', 'pending', 'processing', 'billed', 'failed', 'cancelled', 'all', 'defaults' ) as $method ) {
+			$this->assertFalse( method_exists( CycleStatus::class, $method ), "CycleStatus::{$method}() should not exist." );
+		}
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
index e695a67e070..8f6480c7d73 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
@@ -13,6 +13,7 @@ use DomainException;
 use PHPUnit\Framework\TestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;

@@ -21,6 +22,11 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
  */
 class CycleTest extends TestCase {

+	protected function tearDown(): void {
+		StatusRegistry::reset();
+		parent::tearDown();
+	}
+
 	/**
 	 * Build a pending billing cycle with sensible defaults, overridable per test.
 	 *
@@ -51,7 +57,7 @@ class CycleTest extends TestCase {
 		$this->assertSame( 1, $cycle->get_sequence_no() );
 		$this->assertSame( 1, $cycle->get_count() );
 		$this->assertSame( Cycle::KIND_BILLING, $cycle->get_kind() );
-		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::pending() ) );
+		$this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ) );
 		$this->assertNull( $cycle->get_reason() );
 		$this->assertSame( '2026-02-01 00:00:00', $cycle->get_starts_at_gmt() );
 		$this->assertSame( '2026-03-01 00:00:00', $cycle->get_ends_at_gmt() );
@@ -74,9 +80,9 @@ class CycleTest extends TestCase {

 	public function test_create_can_build_a_billed_cycle_directly(): void {
 		// The checkout signup cycle is created directly billed (the origin order is paid).
-		$cycle = $this->make_pending( array( 'status' => CycleStatus::billed() ) );
+		$cycle = $this->make_pending( array( 'status' => new CycleStatus( CycleStatus::BILLED ) ) );

-		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
 	}

 	public function test_create_requires_a_contract_id(): void {
@@ -169,24 +175,93 @@ class CycleTest extends TestCase {
 	public function test_status_changes_go_through_cycle_status(): void {
 		$cycle = $this->make_pending();

-		$cycle->set_status( CycleStatus::billed() );
-		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+		$cycle->set_status( new CycleStatus( CycleStatus::BILLED ) );
+		$this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
+	}
+
+	public function test_status_change_is_not_constrained_by_a_transition_table(): void {
+		$cycle = $this->make_pending( array( 'status' => new CycleStatus( CycleStatus::BILLED ) ) );
+
+		$cycle->set_status( new CycleStatus( CycleStatus::FAILED ) );
+
+		$this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::FAILED ) ) );
+	}
+
+	public function test_create_accepts_an_extension_registered_status_string(): void {
+		StatusRegistry::register( StatusRegistry::KIND_CYCLE, 'disputed' );
+
+		$cycle = $this->make_pending( array( 'status' => 'disputed' ) );
+
+		$this->assertSame( 'disputed', $cycle->get_status()->get_value() );
+	}
+
+	public function test_from_storage_hydrates_an_unregistered_stored_status(): void {
+		$cycle = Cycle::from_storage(
+			array(
+				'id'             => 5,
+				'contract_id'    => 7,
+				'sequence_no'    => 1,
+				'count'          => 1,
+				'kind'           => Cycle::KIND_BILLING,
+				'status'         => 'legacy-x',
+				'starts_at_gmt'  => '2026-03-01 00:00:00',
+				'ends_at_gmt'    => '2026-04-01 00:00:00',
+				'expected_total' => '20.00',
+				'currency'       => 'USD',
+			)
+		);
+
+		$this->assertSame( 'legacy-x', $cycle->get_status()->get_value() );
+		$this->assertSame( 'legacy-x', $cycle->to_storage()['status'] );
 	}

-	public function test_status_change_rejects_an_illegal_transition(): void {
-		$cycle = $this->make_pending( array( 'status' => CycleStatus::billed() ) );
+	public function test_set_status_rejects_an_unregistered_status_and_leaves_the_status_unchanged(): void {
+		$cycle = $this->make_pending();
+
+		try {
+			$cycle->set_status( new CycleStatus( 'nope' ) );
+			$this->fail( 'Expected a DomainException for an unregistered status.' );
+		} catch ( DomainException $e ) {
+			$this->assertStringContainsString( 'nope', $e->getMessage() );
+		}
+
+		$this->assertSame( CycleStatus::PENDING, $cycle->get_status()->get_value() );
+	}

-		// billed is terminal, so it cannot move to failed.
+	public function test_create_rejects_an_unregistered_status_instance(): void {
 		$this->expectException( DomainException::class );
-		$cycle->set_status( CycleStatus::failed() );
+
+		$this->make_pending( array( 'status' => new CycleStatus( 'nope' ) ) );
+	}
+
+	public function test_a_hydrated_unregistered_status_can_be_kept_unchanged(): void {
+		$cycle = Cycle::from_storage(
+			array(
+				'id'             => 5,
+				'contract_id'    => 7,
+				'sequence_no'    => 1,
+				'count'          => 1,
+				'kind'           => Cycle::KIND_BILLING,
+				'status'         => 'legacy-x',
+				'starts_at_gmt'  => '2026-03-01 00:00:00',
+				'ends_at_gmt'    => '2026-04-01 00:00:00',
+				'expected_total' => '20.00',
+				'currency'       => 'USD',
+			)
+		);
+
+		$cycle->set_status( new CycleStatus( 'legacy-x' ) );
+		$cycle->set_reason( 'annotated' );
+
+		$this->assertSame( 'legacy-x', $cycle->to_storage()['status'] );
 	}

 	public function test_setting_the_same_status_is_a_no_op(): void {
 		$cycle = $this->make_pending();

-		$cycle->set_status( CycleStatus::pending() );
+		$cycle->set_status( new CycleStatus( CycleStatus::PENDING ) );

-		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::pending() ) );
+		$this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ) );
 	}

 	public function test_period_boundaries_are_frozen_at_construction(): void {
@@ -259,7 +334,7 @@ class CycleTest extends TestCase {
 	public function test_reason_can_be_annotated_when_cancelling_a_pending_cycle(): void {
 		$cycle = $this->make_pending();

-		$cycle->set_status( CycleStatus::cancelled() );
+		$cycle->set_status( new CycleStatus( CycleStatus::CANCELLED ) );
 		$cycle->set_reason( 'customer requested cancellation' );

 		$this->assertSame( 'customer requested cancellation', $cycle->get_reason() );
@@ -267,7 +342,7 @@ class CycleTest extends TestCase {

 	public function test_reason_can_be_annotated_on_a_billed_cycle(): void {
 		// `reason` is one of the few mutable fields: any cycle may carry one.
-		$cycle = $this->make_pending( array( 'status' => CycleStatus::billed() ) );
+		$cycle = $this->make_pending( array( 'status' => new CycleStatus( CycleStatus::BILLED ) ) );

 		$cycle->set_reason( 'settled after retry' );

@@ -276,7 +351,7 @@ class CycleTest extends TestCase {

 	public function test_reason_can_be_annotated_on_a_failed_cycle(): void {
 		$cycle = $this->make_pending();
-		$cycle->set_status( CycleStatus::failed() );
+		$cycle->set_status( new CycleStatus( CycleStatus::FAILED ) );

 		$cycle->set_reason( 'gateway declined the charge' );

@@ -308,7 +383,7 @@ class CycleTest extends TestCase {
 		$this->assertSame( 7, $cycle->get_contract_id() );
 		$this->assertSame( 2, $cycle->get_sequence_no() );
 		$this->assertSame( 2, $cycle->get_count() );
-		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
 		$this->assertSame( 11, $cycle->get_plan_snapshot_id() );
 		$this->assertSame( 22, $cycle->get_items_snapshot_id() );
 		$this->assertSame( 123, $cycle->get_order_id() );
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
new file mode 100644
index 00000000000..ac3a19aa865
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/StatusRegistryTest.php
@@ -0,0 +1,166 @@
+<?php
+/**
+ * Unit tests for the WordPress-free StatusRegistry.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
+
+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\StatusRegistry;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry
+ */
+class StatusRegistryTest extends TestCase {
+
+	protected function tearDown(): void {
+		StatusRegistry::reset();
+		parent::tearDown();
+	}
+
+	public function test_all_returns_exactly_the_engine_defaults_with_nothing_registered(): void {
+		$this->assertSame(
+			array( 'active', 'on-hold', 'pending-cancellation', 'cancelled', 'expired' ),
+			StatusRegistry::get_all( StatusRegistry::KIND_CONTRACT )
+		);
+		$this->assertSame(
+			array( 'pending', 'processing', 'billed', 'failed', 'cancelled' ),
+			StatusRegistry::get_all( StatusRegistry::KIND_CYCLE )
+		);
+	}
+
+	public function test_registered_statuses_append_after_the_defaults_in_registration_order(): void {
+		StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+		StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'awaiting-shipment' );
+
+		$this->assertSame(
+			array_merge( ContractStatus::get_defaults(), array( 'paused-by-merchant', 'awaiting-shipment' ) ),
+			StatusRegistry::get_all( StatusRegistry::KIND_CONTRACT )
+		);
+	}
+
+	public function test_is_registered_covers_defaults_and_registrations_only(): void {
+		StatusRegistry::register( StatusRegistry::KIND_CYCLE, 'disputed' );
+
+		$this->assertTrue( StatusRegistry::is_registered( StatusRegistry::KIND_CYCLE, CycleStatus::BILLED ) );
+		$this->assertTrue( StatusRegistry::is_registered( StatusRegistry::KIND_CYCLE, 'disputed' ) );
+		$this->assertFalse( StatusRegistry::is_registered( StatusRegistry::KIND_CYCLE, 'never-registered' ) );
+	}
+
+	public function test_re_registering_a_default_or_a_registered_slug_is_a_no_op(): void {
+		StatusRegistry::register( StatusRegistry::KIND_CONTRACT, ContractStatus::ACTIVE );
+		StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+		StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+
+		$this->assertSame(
+			array_merge( ContractStatus::get_defaults(), array( 'paused-by-merchant' ) ),
+			StatusRegistry::get_all( StatusRegistry::KIND_CONTRACT )
+		);
+	}
+
+	public function test_kinds_are_independent(): void {
+		StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+
+		$this->assertTrue( StatusRegistry::is_registered( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' ) );
+		$this->assertFalse( StatusRegistry::is_registered( StatusRegistry::KIND_CYCLE, 'paused-by-merchant' ) );
+		$this->assertSame( CycleStatus::get_defaults(), StatusRegistry::get_all( StatusRegistry::KIND_CYCLE ) );
+	}
+
+	/**
+	 * @dataProvider provide_invalid_slugs
+	 *
+	 * @param string $slug Invalid slug.
+	 */
+	public function test_register_rejects_an_invalid_slug( string $slug ): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		StatusRegistry::register( StatusRegistry::KIND_CONTRACT, $slug );
+	}
+
+	/**
+	 * @dataProvider provide_invalid_slugs
+	 *
+	 * @param string $slug Invalid slug.
+	 */
+	public function test_is_valid_slug_rejects_an_invalid_slug( string $slug ): void {
+		$this->assertFalse( StatusRegistry::is_valid_slug( $slug ) );
+	}
+
+	/**
+	 * @return array<string, array{0: string}>
+	 */
+	public function provide_invalid_slugs(): array {
+		return array(
+			'empty'           => array( '' ),
+			'uppercase'       => array( 'Active' ),
+			'underscore'      => array( 'on_hold' ),
+			'leading hyphen'  => array( '-x' ),
+			'trailing hyphen' => array( 'x-' ),
+			'double hyphen'   => array( 'a--b' ),
+			'space'           => array( 'with space' ),
+			'markup'          => array( '<b>' ),
+			'over 20 chars'   => array( str_repeat( 'a', 21 ) ),
+			'trailing LF'     => array( "paused\n" ),
+			'trailing CRLF'   => array( "paused\r\n" ),
+		);
+	}
+
+	/**
+	 * @dataProvider provide_valid_edge_slugs
+	 *
+	 * @param string $slug Valid slug.
+	 */
+	public function test_register_accepts_a_valid_edge_case_slug( string $slug ): void {
+		StatusRegistry::register( StatusRegistry::KIND_CONTRACT, $slug );
+
+		$this->assertTrue( StatusRegistry::is_valid_slug( $slug ) );
+		$this->assertTrue( StatusRegistry::is_registered( StatusRegistry::KIND_CONTRACT, $slug ) );
+	}
+
+	/**
+	 * @return array<string, array{0: string}>
+	 */
+	public function provide_valid_edge_slugs(): array {
+		return array(
+			'single char'       => array( 'a' ),
+			'digits and hyphen' => array( 'a1-b2' ),
+			'exactly 20 chars'  => array( str_repeat( 'a', 20 ) ),
+		);
+	}
+
+	public function test_register_rejects_an_unknown_kind(): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		StatusRegistry::register( 'plan', 'draft' );
+	}
+
+	public function test_all_rejects_an_unknown_kind(): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		StatusRegistry::get_all( 'plan' );
+	}
+
+	public function test_is_registered_rejects_an_unknown_kind(): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		StatusRegistry::is_registered( 'plan', '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::reset();
+
+		$this->assertSame( ContractStatus::get_defaults(), StatusRegistry::get_all( StatusRegistry::KIND_CONTRACT ) );
+		$this->assertSame( CycleStatus::get_defaults(), StatusRegistry::get_all( StatusRegistry::KIND_CYCLE ) );
+		$this->assertFalse( StatusRegistry::is_registered( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' ) );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Renewal/RenewalCalculatorTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Renewal/RenewalCalculatorTest.php
index 46fb9fe8dbe..62fc5f3e865 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Renewal/RenewalCalculatorTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Renewal/RenewalCalculatorTest.php
@@ -146,7 +146,7 @@ class RenewalCalculatorTest extends TestCase {
 			)
 		);

-		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::pending() ) );
+		$this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ) );
 		$this->assertSame( 7, $cycle->get_contract_id() );
 		$this->assertSame( 5, $cycle->get_count() );
 		$this->assertSame( 5, $cycle->get_sequence_no() );