Commit 35bd288b3b9 for woocommerce

commit 35bd288b3b99baac6adce4aa3eed3f8813428b46
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date:   Fri Jul 3 13:03:13 2026 +0200

    Add batch renewal dispatching to the subscriptions engine (#66007)

    - Drive renewals from one recurring Action Scheduler job over a cycle-aware due-index, gated on a consumer registry - the engine charges nothing and holds no scan action unless a consumer extension registers
    - Claim the target cycle before any order work (create-as-claim UNIQUE plus a crash-recovery lease arbitrated on the database clock), link renewal orders to cycles draft-first, and settle via atomic status compare-and-set
    - Complete renewals from the order's paid state, so synchronous, asynchronous, and manually settled charges share one idempotent path
    - Add admin-triggered renewals: renew_now() forces the next cycle for a settled head or retries a failed one in place, without the scheduled due-guard
    - Pre-flight the gateway's recurring capability (parking unchargeable contracts), log real database failures distinctly from benign claim races, and remove the superseded per-contract scheduler

diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-renewal-dispatch b/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-renewal-dispatch
new file mode 100644
index 00000000000..754a7e1801d
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-renewal-dispatch
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add subscriptions engine batch renewal dispatcher with cycle-aware selection, order-driven completion, admin-triggered renew and retry, and the processing gate.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
index 1775d2b05c0..5992360a64f 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
@@ -89,12 +89,15 @@ final class Subscriptions {
 	}

 	/**
-	 * Run the contract's renewal now (advance the billing chain a cycle).
+	 * Renew the contract now on an admin's request, regardless of the schedule. A settled cycle
+	 * is billed ahead of its due date (the period continues from the previous end, so the schedule
+	 * is preserved); a failed cycle is retried. Returns null when the contract is not renewable
+	 * (no chain, awaiting a gateway, or inactive).
 	 *
 	 * @param int $contract_id Contract id.
 	 * @return WC_Order|null The renewal order, or null when the renewal was skipped.
 	 */
 	public static function renew_now( int $contract_id ): ?WC_Order {
-		return ( new RenewalEngine() )->process_due( $contract_id );
+		return ( new RenewalEngine() )->renew_now( $contract_id );
 	}
 }
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 be41fc42d19..6abe8d370b9 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
@@ -141,6 +141,23 @@ final class Cycle {
 	 */
 	private $extension_slug;

+	/**
+	 * Crash-recovery lease expiry (GMT string), or null. Set when a `pending` cycle is
+	 * claimed; a stuck pending cycle past this moment is reclaimable. The create-as-claim
+	 * UNIQUE index is the primary concurrency guard - this only recovers a crashed charge.
+	 *
+	 * @var string|null
+	 */
+	private $claimed_until_gmt;
+
+	/**
+	 * Reserved retry moment (GMT string), or null. Additive column for a later
+	 * retry/dunning pass; not wired by the dispatcher yet.
+	 *
+	 * @var string|null
+	 */
+	private $retry_at_gmt;
+
 	/**
 	 * Typed plan snapshot held in memory, or null.
 	 *
@@ -177,6 +194,8 @@ final class Cycle {
 		$this->items_snapshot_id = ScalarCoercion::coerce_nullable_int( $data['items_snapshot_id'] ?? null );
 		$this->order_id          = ScalarCoercion::coerce_nullable_int( $data['order_id'] ?? null );
 		$this->extension_slug    = ScalarCoercion::coerce_nullable_string( $data['extension_slug'] ?? null );
+		$this->claimed_until_gmt = ScalarCoercion::coerce_nullable_string( $data['claimed_until'] ?? null );
+		$this->retry_at_gmt      = ScalarCoercion::coerce_nullable_string( $data['retry_at'] ?? null );
 		$this->plan_snapshot     = ( $data['plan_snapshot'] ?? null ) instanceof PlanSnapshot ? $data['plan_snapshot'] : null;
 		$this->items_snapshot    = ( $data['items_snapshot'] ?? null ) instanceof ItemsSnapshot ? $data['items_snapshot'] : null;
 	}
@@ -427,6 +446,39 @@ final class Cycle {
 		return $this->extension_slug;
 	}

+	/**
+	 * Crash-recovery lease expiry (GMT string), or null.
+	 */
+	public function get_claimed_until_gmt(): ?string {
+		return $this->claimed_until_gmt;
+	}
+
+	/**
+	 * Set (or clear) the crash-recovery lease expiry. Post-freeze-mutable (with `status`,
+	 * `order_id`, `reason`): a claim stamps it, and a reclaim/resolve may re-stamp or clear it.
+	 *
+	 * @param string|null $claimed_until_gmt Lease expiry GMT string, or null to clear.
+	 */
+	public function set_claimed_until_gmt( ?string $claimed_until_gmt ): void {
+		$this->claimed_until_gmt = $claimed_until_gmt;
+	}
+
+	/**
+	 * Reserved retry moment (GMT string), or null.
+	 */
+	public function get_retry_at_gmt(): ?string {
+		return $this->retry_at_gmt;
+	}
+
+	/**
+	 * Set (or clear) the reserved retry moment. Reserved for a later retry/dunning pass.
+	 *
+	 * @param string|null $retry_at_gmt Retry moment GMT string, or null to clear.
+	 */
+	public function set_retry_at_gmt( ?string $retry_at_gmt ): void {
+		$this->retry_at_gmt = $retry_at_gmt;
+	}
+
 	/**
 	 * Typed plan snapshot held in memory, or null.
 	 */
@@ -491,6 +543,8 @@ final class Cycle {
 			'items_snapshot_id' => $this->items_snapshot_id,
 			'order_id'          => $this->order_id,
 			'extension_slug'    => $this->extension_slug,
+			'claimed_until'     => $this->claimed_until_gmt,
+			'retry_at'          => $this->retry_at_gmt,
 		);
 	}

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 59f081d03db..7a29582015f 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
@@ -4,10 +4,14 @@
  * Owns the valid statuses and allowed transitions so an invalid state cannot be
  * represented. Mirrors {@see ContractStatus}.
  *
- * Lifecycle: a cycle is born `pending`, settles to `billed` (terminal) or `failed`,
- * and any non-settled cycle can be `cancelled` (terminal). `failed` is deliberately
- * non-terminal so a later change can add a retry edge. Instance methods serve the
- * entity; the static string helpers operate on raw strings at the storage boundary.
+ * 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.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
  */
@@ -28,10 +32,11 @@ defined( 'ABSPATH' ) || exit;
  */
 final class CycleStatus {

-	public const PENDING   = 'pending';
-	public const BILLED    = 'billed';
-	public const FAILED    = 'failed';
-	public const CANCELLED = 'cancelled';
+	public const PENDING    = 'pending';
+	public const PROCESSING = 'processing';
+	public const BILLED     = 'billed';
+	public const FAILED     = 'failed';
+	public const CANCELLED  = 'cancelled';

 	/**
 	 * The status string this value wraps.
@@ -72,6 +77,13 @@ final class CycleStatus {
 		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).
 	 */
@@ -138,6 +150,7 @@ final class CycleStatus {
 	public static function all(): array {
 		return array(
 			self::PENDING,
+			self::PROCESSING,
 			self::BILLED,
 			self::FAILED,
 			self::CANCELLED,
@@ -213,10 +226,11 @@ final class CycleStatus {
 	 */
 	private static function transitions(): array {
 		return array(
-			self::PENDING   => array( self::BILLED, self::FAILED, self::CANCELLED ),
-			self::BILLED    => array(),
-			self::FAILED    => array( self::CANCELLED ),
-			self::CANCELLED => 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(),
 		);
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
index 68a64613c91..ee13600bbb7 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
@@ -15,6 +15,7 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\SubscriptionsEngine\Integration;

 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway\CapabilityRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\Rest\PlansController;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
@@ -46,23 +47,28 @@ final class Bootstrap {

 		CapabilityRegistry::init();

-		// Register the Action Scheduler callback that dispatches a due renewal
-		// back into the engine. Must run on every boot (not just activation) so
-		// AS can fire scheduled renewals.
-		RenewalEngine::register_hooks();
+		// Register the callbacks that dispatch renewals back into the engine. Plain
+		// add_action calls, safe before Action Scheduler has loaded; must run on every
+		// boot (not just activation) so the hooks can fire.
+		( new RenewalEngine() )->register_hooks();
 		PlansController::register_hooks();
+		( new RenewalDispatcher() )->register_hooks();

+		// Deferred boot work, each on the most specific moment it needs: the schema install
+		// reads options and runs dbDelta, so it waits for `init`; the recurring-action
+		// enqueue needs the `as_*` functions, so it waits for `action_scheduler_init` - the
+		// hook Action Scheduler fires once it is ready. Run immediately when a consumer
+		// boots the engine after a hook already fired.
 		if ( did_action( 'init' ) ) {
-			self::maybe_install_schema();
+			SchemaInstaller::maybe_install();
 		} else {
-			add_action( 'init', array( __CLASS__, 'maybe_install_schema' ) );
+			add_action( 'init', array( SchemaInstaller::class, 'maybe_install' ) );
 		}
-	}

-	/**
-	 * Install or upgrade the engine schema when it is missing or behind.
-	 */
-	public static function maybe_install_schema(): void {
-		SchemaInstaller::maybe_install();
+		if ( did_action( 'action_scheduler_init' ) ) {
+			RenewalDispatcher::ensure_scheduled();
+		} else {
+			add_action( 'action_scheduler_init', array( RenewalDispatcher::class, 'ensure_scheduled' ) );
+		}
 	}
 }
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 18fc655947e..f70b6c180ce 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
@@ -1,11 +1,12 @@
 <?php
 /**
  * Builds and persists a {@see Contract} (plus its origin {@see Cycle}) from a paid
- * checkout order, and links order <-> contract in both directions. Does not schedule
- * the first renewal - the caller arms that separately via {@see RenewalEngine::schedule()}.
+ * checkout order, and links order <-> contract in both directions. Renewals need no
+ * arming beyond this: the contract's `next_payment_gmt` places it on the due index the
+ * batch dispatcher scans.
  *
- * Integration zone: WordPress-native. Reads a live `WC_Order`; the order never
- * crosses into Core - only the snapshot values pulled off it do.
+ * Reads a live `WC_Order`; the order never crosses into Core - only the snapshot
+ * values pulled off it do.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout
  */
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php
index dd47640f9b2..9fa91cee1cc 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php
@@ -5,8 +5,8 @@
  * `origin_order_id`). The engine owns these keys; consumers read them through
  * this class rather than hard-coding the strings.
  *
- * Integration zone: WordPress-native. Written to WooCommerce order meta, which
- * works under both HPOS and the legacy CPT order store.
+ * Written to WooCommerce order meta, which works under both HPOS and the legacy
+ * CPT order store.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout
  */
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 c484fd103ba..a9b57d8608f 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
@@ -18,7 +18,6 @@ use RuntimeException;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalScheduler;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;

 defined( 'ABSPATH' ) || exit;
@@ -50,13 +49,14 @@ final class Cancellation {
 	}

 	/**
-	 * Cancel `$contract`: transition to cancelled, close any mid-charge cycle, and clear its
-	 * pending renewal.
+	 * Cancel `$contract`: transition to cancelled 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.
+	 * 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.
 	 *
 	 * @param Contract $contract Contract to cancel. Must have an id.
 	 * @return bool True when the contract was cancelled and persisted.
@@ -73,16 +73,14 @@ 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_current_cycle( $id );
+		$current = $this->contracts->find_chain_head( $id );
 		if ( null !== $current && $current->get_status()->equals( CycleStatus::pending() ) ) {
 			$current->set_status( CycleStatus::cancelled() );
 			$this->contracts->update_cycle( $current );
 		}

-		RenewalScheduler::unschedule( $id );
-
 		/**
-		 * Fires after a contract is cancelled and its pending renewal cleared.
+		 * Fires after a contract is cancelled.
 		 *
 		 * @param Contract $contract The cancelled contract.
 		 */
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Gateway/CapabilityRegistry.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Gateway/CapabilityRegistry.php
index 975b974b07c..40854371bef 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Gateway/CapabilityRegistry.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Gateway/CapabilityRegistry.php
@@ -11,8 +11,6 @@
  * and adds the live-gateway lookup, the override filter, and the ready-hook
  * wiring on top.
  *
- * Integration zone: WordPress-native. May use WooCommerce.
- *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway
  */

diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php
new file mode 100644
index 00000000000..85b52844e84
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php
@@ -0,0 +1,93 @@
+<?php
+/**
+ * ConsumerRegistry - the set of extensions consuming engine functionality.
+ *
+ * A consumer extension registers its slug here on load, identifying itself as an
+ * active owner of the primitives it drives. Engine components read the registry to
+ * decide whether, and on whose behalf, to act: an empty registry means "no consumer
+ * present," so the engine stays inert when bundled but unused - the renewal
+ * dispatcher charges nothing, and future components gate the same way.
+ *
+ * Static (not instance state) because registration is a load-time, by-class-name
+ * call - every consumer reaches the registry by class name, mirroring
+ * {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities}.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registry of the extensions consuming engine functionality.
+ */
+final class ConsumerRegistry {
+
+	/**
+	 * Registered consumer slugs, de-duplicated (a set keyed by slug).
+	 *
+	 * @var array<string, true>
+	 */
+	private static $slugs = array();
+
+	/**
+	 * Register a consumer extension by its slug.
+	 *
+	 * Idempotent: registering the same slug twice is a no-op. The slug is trimmed, and
+	 * an empty or whitespace-only slug is ignored so a misconfigured caller cannot flip
+	 * the gate open with a blank registration.
+	 *
+	 * @param string $slug The consumer extension's registered slug.
+	 */
+	public static function register( string $slug ): void {
+		$slug = trim( $slug );
+		if ( '' === $slug ) {
+			return;
+		}
+
+		self::$slugs[ $slug ] = true;
+	}
+
+	/**
+	 * Remove a consumer registration. A deactivating consumer deregisters itself so the
+	 * engine's gates re-evaluate while its code is still loaded - the engine only loads
+	 * through its consumers, so the deactivation request is the last chance for components
+	 * to clean up (e.g. the renewal dispatcher removes its recurring scan when the last
+	 * consumer leaves). An unknown slug is a no-op.
+	 *
+	 * @param string $slug The consumer extension's registered slug.
+	 */
+	public static function unregister( string $slug ): void {
+		unset( self::$slugs[ trim( $slug ) ] );
+	}
+
+	/**
+	 * Whether no consumer is registered. The dispatcher gate: true means charge
+	 * nothing this run.
+	 */
+	public static function is_empty(): bool {
+		return array() === self::$slugs;
+	}
+
+	/**
+	 * The registered consumer slugs (order not significant).
+	 *
+	 * @return array<int, string>
+	 */
+	public static function all(): array {
+		return array_keys( self::$slugs );
+	}
+
+	/**
+	 * Clear every registration.
+	 *
+	 * @internal Public only so test setUp can isolate per-test state. Not part
+	 *           of the consumer API.
+	 */
+	public static function reset(): void {
+		self::$slugs = array();
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalDispatcher.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalDispatcher.php
new file mode 100644
index 00000000000..3be141144f3
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalDispatcher.php
@@ -0,0 +1,287 @@
+<?php
+/**
+ * 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.
+ *
+ * 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
+ * (a slow tick overlapping the next) bills at most once.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use Throwable;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Batch renewal dispatcher.
+ */
+final class RenewalDispatcher {
+
+	/**
+	 * Action Scheduler hook fired on each recurring scan tick.
+	 *
+	 * Public so tooling and tests can inspect, run, or cancel the recurring action via
+	 * `as_next_scheduled_action()` and friends.
+	 */
+	public const HOOK = 'woocommerce_subscriptions_engine_dispatch_due_renewals';
+
+	/**
+	 * Action Scheduler group - used for admin filterability (Tools -> Scheduled Actions)
+	 * and bulk teardown. Shared with the rest of the engine's actions.
+	 */
+	public const GROUP = 'woocommerce_subscriptions_engine';
+
+	/**
+	 * Default scan cadence, in seconds (every 10 minutes). Frequent enough that a due
+	 * renewal fires close to its moment without scanning so often it churns the index.
+	 */
+	private const INTERVAL_SECONDS = 600;
+
+	/**
+	 * Contracts processed per tick. Bounds the work (and the open order/charge volume)
+	 * of a single run; a backlog drains over successive ticks.
+	 */
+	private const BATCH_SIZE = 50;
+
+	/**
+	 * Logger source tag.
+	 */
+	private const LOG_SOURCE = 'woocommerce-subscriptions-engine';
+
+	/**
+	 * Option holding the next moment the recurring action is re-verified against the Action
+	 * Scheduler store. Autoloaded (bulk-loaded, effectively free per request), so the common
+	 * path skips the AS store query {@see self::is_scheduled()} would otherwise run every load.
+	 */
+	private const SCHEDULE_CHECK_OPTION = 'woocommerce_subscriptions_engine_dispatch_scheduled_check';
+
+	/**
+	 * How long a positive schedule check is trusted before re-verifying, in seconds. Bounds the
+	 * staleness if the recurring action is ever cleared externally: the next check past this
+	 * window re-creates it, so the dispatcher self-heals rather than stopping silently.
+	 */
+	private const SCHEDULE_RECHECK_SECONDS = 3600;
+
+	/**
+	 * Repository used to scan the contract due-index.
+	 *
+	 * @var ContractRepository
+	 */
+	private $contracts;
+
+	/**
+	 * The money-path the scan drives per due contract.
+	 *
+	 * @var RenewalEngine
+	 */
+	private $engine;
+
+	/**
+	 * The read-only selector that turns a due scan row into the cycle to bill.
+	 *
+	 * @var RenewalSelector
+	 */
+	private $selector;
+
+	/**
+	 * Build a dispatcher over the given collaborators.
+	 *
+	 * @param ContractRepository|null $contracts Contract repository; default instance when omitted.
+	 * @param RenewalEngine|null      $engine    Renewal engine; default instance when omitted.
+	 * @param RenewalSelector|null    $selector  Cycle selector; default instance when omitted.
+	 */
+	public function __construct( ?ContractRepository $contracts = null, ?RenewalEngine $engine = null, ?RenewalSelector $selector = null ) {
+		$this->contracts = $contracts ?? new ContractRepository();
+		$this->engine    = $engine ?? new RenewalEngine();
+		$this->selector  = $selector ?? new RenewalSelector();
+	}
+
+	/**
+	 * Register the scan-tick handler on THIS instance.
+	 *
+	 * Must run on every boot (not just activation) so Action Scheduler can dispatch a tick
+	 * back into {@see self::handle_tick()}. A plain `add_action`, safe to call before Action
+	 * Scheduler has loaded; the recurring action itself is enqueued later via
+	 * {@see self::ensure_scheduled()}. Instance-based (not static) so the boot-built
+	 * dispatcher - with whatever collaborators it was constructed over - is the one the
+	 * tick runs.
+	 */
+	public function register_hooks(): void {
+		add_action( self::HOOK, array( $this, 'handle_tick' ) );
+	}
+
+	/**
+	 * 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
+	 * 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()`
+	 * fast-path plus a best-effort store-level dedup.
+	 */
+	public static function ensure_scheduled(): void {
+		// No consumer, no scan - and a store that previously scheduled the recurring action
+		// removes it here, so the job does not keep ticking against the gate after every
+		// consumer deactivates. The check option doubles as the "scheduled before" marker,
+		// keeping the common no-consumer path to a single autoloaded option read; nothing is
+		// re-cached, so a consumer registering later schedules promptly.
+		if ( ConsumerRegistry::is_empty() ) {
+			if ( false !== get_option( self::SCHEDULE_CHECK_OPTION, false ) ) {
+				as_unschedule_all_actions( self::HOOK, array(), self::GROUP );
+				delete_option( self::SCHEDULE_CHECK_OPTION );
+			}
+			return;
+		}
+
+		// Skip the Action Scheduler store query while a recent positive check is still trusted.
+		$next_check = get_option( self::SCHEDULE_CHECK_OPTION, 0 );
+		if ( is_numeric( $next_check ) && time() < (int) $next_check ) {
+			return;
+		}
+
+		if ( self::is_scheduled() ) {
+			update_option( self::SCHEDULE_CHECK_OPTION, time() + self::SCHEDULE_RECHECK_SECONDS, true );
+			return;
+		}
+
+		// $unique = true is a best-effort store-level dedup: Action Scheduler checks for an
+		// existing pending/running action before inserting, but that is not an atomic unique
+		// constraint, so two concurrent first-boots could still create two rows. The downstream
+		// create-as-claim cycle UNIQUE prevents any double-charge regardless; at worst a duplicate
+		// recurring row means redundant scan work until it is cleared. With the is_scheduled()
+		// fast-path this keeps the common case to a single recurring action.
+		as_schedule_recurring_action(
+			time() + self::INTERVAL_SECONDS,
+			self::INTERVAL_SECONDS,
+			self::HOOK,
+			array(),
+			self::GROUP,
+			true
+		);
+
+		update_option( self::SCHEDULE_CHECK_OPTION, time() + self::SCHEDULE_RECHECK_SECONDS, true );
+	}
+
+	/**
+	 * Whether the recurring scan action is currently scheduled. Private - it queries the
+	 * Action Scheduler store, so per-request callers must go through the option-cached
+	 * {@see self::ensure_scheduled()} instead.
+	 */
+	private static function is_scheduled(): bool {
+		return false !== as_next_scheduled_action( self::HOOK, array(), self::GROUP );
+	}
+
+	/**
+	 * Triggers a batch run - the Action Scheduler dispatch entry point, fired once per tick.
+	 *
+	 * A thin wrapper around {@see self::run_batch()}, needed (vs registering that method
+	 * directly) because a stray `do_action( self::HOOK, ... )` carrying arguments would
+	 * reach run_batch's typed parameters and fatal; the argument-less wrapper absorbs
+	 * whatever the hook carries.
+	 */
+	public function handle_tick(): void {
+		$this->run_batch();
+	}
+
+	/**
+	 * Run one scan tick over up to `$limit` due contracts: gate, then drive every due renewal.
+	 *
+	 * 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
+	 * ({@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.
+	 *
+	 * @param DateTimeImmutable|null $now   The scan moment; defaults to now (UTC). Injectable for tests.
+	 * @param int                    $limit Maximum due contracts to process this tick; defaults to
+	 *                                      the batch size. A non-positive limit is a no-op returning 0.
+	 * @return int The number of actionable due contracts scanned this tick (0 when gated).
+	 *             A billed/skipped/failed breakdown is logged at debug level.
+	 */
+	public function run_batch( ?DateTimeImmutable $now = null, int $limit = self::BATCH_SIZE ): int {
+		if ( $limit < 1 ) {
+			return 0;
+		}
+
+		if ( ConsumerRegistry::is_empty() ) {
+			wc_get_logger()->info(
+				'RenewalDispatcher::run(): no consumer extension is registered - skipping the renewal scan (charging nothing).',
+				array( 'source' => self::LOG_SOURCE )
+			);
+			return 0;
+		}
+
+		$now        = $now ?? new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
+		$candidates = $this->contracts->find_due( $now, $limit );
+
+		$billed  = 0;
+		$skipped = 0;
+		$failed  = 0;
+
+		foreach ( $candidates as $candidate ) {
+			try {
+				$cycle_count = $this->selector->select_scheduled_cycle( $candidate, $now );
+				if ( null === $cycle_count ) {
+					++$skipped;
+					continue;
+				}
+				$renewal_intent = new RenewalIntent( $candidate->get_contract_id(), $cycle_count );
+				if ( null === $this->engine->process( $renewal_intent, $now ) ) {
+					// A skip or idempotent no-op (a live claim, an already-settled cycle).
+					++$skipped;
+				} else {
+					++$billed;
+				}
+			} catch ( RenewalNotProcessable $e ) {
+				++$failed;
+				// Pre-flight impossibility (e.g. an unresolvable plan): park so the contract
+				// leaves the due window and cannot re-poison the scan; a repair re-arms it.
+				$this->engine->park( $candidate->get_contract_id() );
+				wc_get_logger()->warning(
+					sprintf( 'RenewalDispatcher::run(): parking contract %d - %s', $candidate->get_contract_id(), $e->getMessage() ),
+					array(
+						'source'      => self::LOG_SOURCE,
+						'contract_id' => $candidate->get_contract_id(),
+					)
+				);
+			} catch ( Throwable $e ) {
+				// One contract's failure must not stall the batch (or make AS retry the
+				// whole tick forever). Log and continue to the next due contract.
+				++$failed;
+				wc_get_logger()->error(
+					sprintf( 'RenewalDispatcher::run(): processing contract %d threw: %s', $candidate->get_contract_id(), $e->getMessage() ),
+					array(
+						'source'      => self::LOG_SOURCE,
+						'contract_id' => $candidate->get_contract_id(),
+					)
+				);
+			}
+		}
+
+		if ( array() !== $candidates ) {
+			wc_get_logger()->debug(
+				sprintf( 'RenewalDispatcher::run(): scanned %d candidate(s) - %d billed, %d skipped, %d failed.', count( $candidates ), $billed, $skipped, $failed ),
+				array( 'source' => self::LOG_SOURCE )
+			);
+		}
+
+		return count( $candidates );
+	}
+}
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 bba17af32b2..0f44d08e888 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
@@ -1,17 +1,26 @@
 <?php
 /**
- * The seam between consumers and the renewal money-path: `schedule()` enqueues a
- * contract's next renewal, `process_due()` runs it when fired (by Action Scheduler
- * or driven directly). Wraps Action Scheduler (whose hook names and dedup behaviour
- * stay private) and adds the contract-aware semantics: capability gating, the renewal
- * order, the charge. One AS job per contract; the AS coupling lives in {@see RenewalScheduler}.
+ * The renewal money-path, in two separated concerns joined by a {@see RenewalIntent}:
  *
- * `process_due()` advances the billing chain at fire time - it claims the next cycle
- * `pending` (create-as-claim), charges its `expected_total`, then settles `billed` or
- * `failed` and advances the contract schedule on success. It stays a single synchronous
- * entry; re-arming the next due moment via a recurring scan is a later slice.
+ * - Selection ({@see RenewalSelector}, read-only) decides which cycle a contract should
+ *   bill, and whether it is due at all. The batch {@see RenewalDispatcher} runs it over the
+ *   cycle-aware due scan; `renew_now()` runs its manual variant for a single contract.
+ * - Processing ({@see self::process()}) bills exactly the cycle it is handed: it claims that
+ *   cycle `pending` (create-as-claim, stamping a crash-recovery lease, or reclaiming a
+ *   stalled one), reconciles the renewal order AFTER the claim (reuse-or-build, draft-first
+ *   and linked onto the cycle before the order goes live - so the cycle chain, not the
+ *   mutable order, is the idempotency gate), charges, and completes.
  *
- * Integration zone: WordPress-native. Action Scheduler, WC orders, gateways.
+ * Completion is driven by the renewal order's paid state, not the charge call's return, so
+ * synchronous and asynchronous gateways share one path: {@see self::complete_from_order()}
+ * runs both as a post-charge reconciliation and from `woocommerce_payment_complete` / the
+ * failed transition, and every settlement lands through an atomic status compare-and-set so
+ * it happens exactly once. A charge with no terminal outcome yet (an async method awaiting
+ * confirmation) settles the cycle `processing`, which the lease never reclaims and the scan
+ * never re-selects.
+ *
+ * The batch dispatcher drives renewals off the due-index; no per-contract Action
+ * Scheduler rows exist.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal
  */
@@ -25,6 +34,7 @@ use DateTimeZone;
 use Throwable;
 use WC_Order;
 use WC_Order_Item_Product;
+use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
@@ -32,26 +42,23 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal\RenewalCalculator;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway\CapabilityRegistry;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\DuplicateCycleException;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\RenewalCandidate;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;

 defined( 'ABSPATH' ) || exit;

 /**
- * Renewal engine - schedule, advance, charge, cancel.
+ * Renewal engine - select, process, complete.
  */
 final class RenewalEngine {

-	/**
-	 * Action fired after a contract is scheduled, with `( $contract, $when )`.
-	 * Listeners observe a scheduled state, not an in-flight one.
-	 */
-	public const RENEWAL_SCHEDULED_ACTION = 'woocommerce_subscriptions_engine_renewal_scheduled';
-
 	/**
 	 * Action fired after a renewal order is created, with `( $renewal_order, $contract )`.
 	 */
@@ -68,6 +75,16 @@ final class RenewalEngine {
 	 */
 	protected const LOG_SOURCE = 'woocommerce-subscriptions-engine';

+	/**
+	 * Crash-recovery lease window, in seconds. When a cycle is claimed `pending` its
+	 * `claimed_until` is set this far ahead; a pending cycle still unsettled past that
+	 * moment is treated as a crashed in-flight charge and is reclaimable on a later run.
+	 * Generous enough to outlast a normal synchronous charge plus gateway round-trip. It
+	 * guards only the submit window: an accepted async charge moves the cycle `processing`,
+	 * which carries no lease and is never reclaimed here.
+	 */
+	private const LEASE_TTL_SECONDS = 900;
+
 	/**
 	 * Repository for loading and persisting contracts, and targeted cycle access.
 	 *
@@ -83,124 +100,139 @@ final class RenewalEngine {
 	private $plans;

 	/**
-	 * Build a renewal engine over the given repositories.
+	 * The read-only cycle selector `renew_now()` runs for a single contract.
+	 *
+	 * @var RenewalSelector
+	 */
+	private $selector;
+
+	/**
+	 * Build a renewal engine over the given collaborators.
 	 *
 	 * @param ContractRepository|null $contracts Contract repository; default instance when omitted.
 	 * @param PlanRepository|null     $plans     Plan repository; default instance when omitted.
+	 * @param RenewalSelector|null    $selector  Cycle selector; default instance when omitted.
 	 */
-	public function __construct( ?ContractRepository $contracts = null, ?PlanRepository $plans = null ) {
+	public function __construct( ?ContractRepository $contracts = null, ?PlanRepository $plans = null, ?RenewalSelector $selector = null ) {
 		$this->contracts = $contracts ?? new ContractRepository();
 		$this->plans     = $plans ?? new PlanRepository();
+		$this->selector  = $selector ?? new RenewalSelector();
 	}

 	/**
-	 * Register the Action Scheduler callback. Must run on every page load (not just
-	 * activation) so AS can dispatch a due action back into {@see self::handle_due_action()}.
+	 * Register the order-driven completion listeners on THIS instance. Must run on every page
+	 * load so a renewal order reaching a terminal state completes its cycle through
+	 * {@see self::handle_order_settled()}. Instance-based (not static) so the boot-built
+	 * engine - with whatever collaborators it was constructed over - is the one the listeners
+	 * run.
 	 */
-	public static function register_hooks(): void {
-		add_action( RenewalScheduler::HOOK, array( __CLASS__, 'handle_due_action' ), 10, 1 );
+	public function register_hooks(): void {
+		add_action( 'woocommerce_payment_complete', array( $this, 'handle_order_settled' ), 10, 1 );
+		add_action( 'woocommerce_order_status_failed', array( $this, 'handle_order_settled' ), 10, 1 );
+
+		// payment_complete() never fires for a renewal settled by hand - an admin marking a
+		// cash-on-delivery-style order processing/completed. Listen to the paid-status
+		// transitions too; the CAS settle keeps the double-fire (payment_complete plus its
+		// own status transition) idempotent.
+		foreach ( wc_get_is_paid_statuses() as $paid_status ) {
+			add_action( 'woocommerce_order_status_' . $paid_status, array( $this, 'handle_order_settled' ), 10, 1 );
+		}
 	}

 	/**
-	 * Action Scheduler dispatch entry point - fires when a renewal is due.
-	 *
-	 * Static so it can be registered as a plain callback; routes through the instance
-	 * `process_due()` so dispatch and any synchronous test driver share one code path.
+	 * Completion listener - fires when a renewal order reaches a paid or failed state, and
+	 * settles the matching cycle from that state. The mapping and idempotency live in
+	 * {@see self::complete_from_order()}. A non-renewal order is ignored there.
 	 *
-	 * @param int $contract_id Contract whose renewal is firing.
+	 * @param int $order_id The order whose state changed.
 	 */
-	public static function handle_due_action( int $contract_id ): void {
-		( new self() )->process_due( $contract_id );
+	public function handle_order_settled( int $order_id ): void {
+		$order = wc_get_order( $order_id );
+		if ( $order instanceof WC_Order ) {
+			$this->complete_from_order( $order );
+		}
 	}

 	/**
-	 * Schedule (or re-schedule) the next renewal for `$contract` at its `next_payment_gmt`.
-	 *
-	 * Clear-then-enqueue keeps at most one pending AS row per contract, so callers
-	 * moving the date forward just call `schedule()` again. Skips (and clears any stale
-	 * row) when the contract is gateway-scheduled (the gateway runs its own schedule) or
-	 * has no `next_payment_gmt`. Capability gate: a primitive-scheduled contract is only
-	 * enqueued when its gateway declares the `recurring` capability via
-	 * {@see CapabilityRegistry::supports()}, so renewals nothing can charge are refused
-	 * at the boundary rather than failing later on a customer-facing order.
-	 *
-	 * @param Contract $contract Contract to schedule. Must have an id.
-	 * @return bool True when an AS row was enqueued; false when scheduling was
-	 *              skipped (gateway-scheduled, incapable gateway, no date, no id).
+	 * Renew a contract now at an admin's request, regardless of the schedule. Selection is by head
+	 * state without the scheduled due-guard ({@see RenewalSelector::select_manual_cycle()}): a
+	 * settled head is force-advanced to the next cycle (whose period continues from the previous
+	 * end, so the schedule is preserved, not reset), while a failed or stalled head is re-attempted
+	 * at its own count. Unlike the scheduled path it never parks the contract - a manual action
+	 * should not clear the schedule when it cannot proceed.
+	 *
+	 * @param int                    $contract_id The contract to renew.
+	 * @param DateTimeImmutable|null $now         The processing moment; defaults to now (UTC).
+	 * @return WC_Order|null The renewal order, or null when the contract is not currently renewable.
 	 */
-	public function schedule( Contract $contract ): bool {
-		$id = $contract->get_id();
-		if ( null === $id ) {
-			return false;
-		}
+	public function renew_now( int $contract_id, ?DateTimeImmutable $now = null ): ?WC_Order {
+		$now = $now ?? new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );

-		// Gateway-scheduled: the gateway owns the schedule. Clear any stale row and bail.
-		if ( Contract::SCHEDULE_SOURCE_GATEWAY === $contract->get_schedule_source() ) {
-			RenewalScheduler::unschedule( $id );
-			return false;
+		$head = $this->contracts->find_chain_head( $contract_id );
+		if ( null === $head ) {
+			wc_get_logger()->warning(
+				sprintf( 'RenewalEngine::renew_now(): contract %d has no billing chain to renew.', $contract_id ),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => $contract_id,
+				)
+			);
+			return null;
 		}

-		$next_payment_gmt = $contract->get_next_payment_gmt();
-		if ( null === $next_payment_gmt ) {
-			RenewalScheduler::unschedule( $id );
-			return false;
+		$cycle_count = $this->selector->select_manual_cycle( RenewalCandidate::from_cycle( $head ) );
+		if ( null === $cycle_count ) {
+			return null;
 		}

-		// Schedule-time capability gate.
-		$gateway_id = $contract->get_payment_instrument()->get_gateway();
-		if ( null === $gateway_id || '' === $gateway_id || ! CapabilityRegistry::supports( (string) $gateway_id, GatewayCapabilities::RECURRING ) ) {
-			RenewalScheduler::unschedule( $id );
+		try {
+			return $this->process( new RenewalIntent( $contract_id, $cycle_count ), $now );
+		} catch ( RenewalNotProcessable $e ) {
 			wc_get_logger()->warning(
-				sprintf(
-					'RenewalEngine::schedule(): not scheduling contract %d - gateway "%s" does not declare the "recurring" capability. Declare it via CapabilityRegistry, or set the contract to gateway-scheduled if the gateway runs its own renewals.',
-					$id,
-					(string) $gateway_id
-				),
+				sprintf( 'RenewalEngine::renew_now(): cannot renew contract %d. %s', $contract_id, $e->getMessage() ),
 				array(
 					'source'      => self::LOG_SOURCE,
-					'contract_id' => $id,
-					'gateway_id'  => (string) $gateway_id,
+					'contract_id' => $contract_id,
 				)
 			);
-			return false;
+			return null;
 		}
-
-		$when = new DateTimeImmutable( $next_payment_gmt, new DateTimeZone( 'UTC' ) );
-
-		// Clear-then-enqueue: AS does not dedup on hook+args, so without the clear a
-		// re-schedule would leave two rows and fire twice.
-		RenewalScheduler::unschedule( $id );
-		RenewalScheduler::schedule( $id, $when );
-
-		do_action( self::RENEWAL_SCHEDULED_ACTION, $contract, $when );
-
-		return true;
 	}

 	/**
-	 * Run the renewal due for `$contract_id`. Fired by the AS hook.
-	 *
-	 * Loads the contract and skips (logging only, never throwing - AS would retry a
-	 * permanent failure forever) when it is gone, gateway-scheduled, or not active.
-	 * Then advances the billing chain: it creates the next cycle `pending` as the
-	 * create-as-claim (the `UNIQUE(contract_id, kind, count)` index makes a concurrent
-	 * or retried fire a no-op), builds and charges the renewal order at that cycle's
-	 * `expected_total`, and resolves the outcome - on a paid order the cycle settles
-	 * `billed`, the order is linked, and the contract schedule advances one cadence; on
-	 * an unpaid order the cycle settles `failed` and the schedule is left untouched.
-	 *
-	 * Writes are ordered durable-intent-first (cycle create -> charge -> cycle resolve ->
-	 * contract advance) with no surrounding transaction. The single synchronous entry a
-	 * later batch dispatcher calls per-claimed-contract.
-	 *
-	 * @param int $contract_id Contract whose renewal cycle is firing.
-	 * @return WC_Order|null The created renewal order, or null when skipped/idempotent.
+	 * Bill the cycle named by `$intent` - the trigger-agnostic processing primitive.
+	 *
+	 * It owns no "which cycle" or "is it due" policy: selection (scheduled, admin, or early
+	 * renewal) decides the target elsewhere and hands it in, so one primitive serves every
+	 * trigger and a caller can force a renewal the scheduled guard would otherwise defer.
+	 *
+	 * 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
+	 * 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
+	 * cycle chain - not the mutable order - is the idempotency authority.
+	 *
+	 * 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).
+	 *
+	 * @param RenewalIntent     $intent The contract and cycle count to bill.
+	 * @param DateTimeImmutable $now    The processing moment (the lease clock for a claim).
+	 * @return WC_Order|null The renewal order, or null when skipped/idempotent.
+	 * @throws RenewalNotProcessable When the renewal cannot start at all.
 	 */
-	public function process_due( int $contract_id ): ?WC_Order {
+	public function process( RenewalIntent $intent, DateTimeImmutable $now ): ?WC_Order {
+		$contract_id = $intent->get_contract_id();
+		$cycle_count = $intent->get_cycle_count();
+
 		$contract = $this->contracts->find( $contract_id );
 		if ( null === $contract ) {
 			wc_get_logger()->warning(
-				sprintf( 'RenewalEngine::process_due(): unknown contract %d - skipping (stale scheduled action).', $contract_id ),
+				sprintf( 'RenewalEngine::process(): unknown contract %d - skipping (stale scheduled action).', $contract_id ),
 				array(
 					'source'      => self::LOG_SOURCE,
 					'contract_id' => $contract_id,
@@ -211,7 +243,7 @@ final class RenewalEngine {

 		if ( Contract::SCHEDULE_SOURCE_GATEWAY === $contract->get_schedule_source() ) {
 			wc_get_logger()->warning(
-				sprintf( 'RenewalEngine::process_due(): contract %d is gateway-scheduled - skipping. The gateway owns the renewal; this primitive row should not have fired.', $contract_id ),
+				sprintf( 'RenewalEngine::process(): contract %d is gateway-scheduled - skipping. The gateway owns the renewal; this primitive row should not have fired.', $contract_id ),
 				array(
 					'source'      => self::LOG_SOURCE,
 					'contract_id' => $contract_id,
@@ -222,7 +254,7 @@ final class RenewalEngine {

 		if ( ContractStatus::ACTIVE !== $contract->get_status() ) {
 			wc_get_logger()->info(
-				sprintf( 'RenewalEngine::process_due(): contract %d is %s, not active - skipping renewal. No order created.', $contract_id, $contract->get_status() ),
+				sprintf( 'RenewalEngine::process(): contract %d is %s, not active - skipping renewal. No order created.', $contract_id, $contract->get_status() ),
 				array(
 					'source'      => self::LOG_SOURCE,
 					'contract_id' => $contract_id,
@@ -232,123 +264,104 @@ final class RenewalEngine {
 			return null;
 		}

-		$previous = $this->contracts->find_current_cycle( $contract_id );
-		if ( null === $previous ) {
-			// No billing chain to advance: checkout always creates cycle 1, so a chainless
-			// contract is a manual/corrupt case the engine does not renew. Skip without throwing
-			// (never silently bill it as cycle 1) so a scheduled action does not retry forever.
-			wc_get_logger()->warning(
-				sprintf( 'RenewalEngine::process_due(): contract %d has no billing chain to advance - skipping.', $contract_id ),
-				array(
-					'source'      => self::LOG_SOURCE,
-					'contract_id' => $contract_id,
-				)
+		// Pre-flight capability gate, ahead of the claim so an unchargeable renewal never
+		// claims a cycle or creates an order. Without it the charge hook would fire into
+		// nothing and the cycle would park `processing` - a stall that misreads as an
+		// in-flight charge. Every attempt is futile until the payment method is updated, so
+		// the throw lets the scheduled caller park the contract out of the due set.
+		$gateway_id = $contract->get_payment_instrument()->get_gateway();
+		if ( null === $gateway_id || '' === $gateway_id ) {
+			throw new RenewalNotProcessable( 'the contract has no payment gateway to charge renewals with' );
+		}
+		if ( ! CapabilityRegistry::supports( (string) $gateway_id, GatewayCapabilities::RECURRING ) ) {
+			throw new RenewalNotProcessable(
+				esc_html( sprintf( 'gateway "%s" does not declare the "recurring" capability - unchargeable until the payment method is updated.', $gateway_id ) )
 			);
-			return null;
 		}

-		$next_count = $this->target_count( $previous );
+		$head = $this->contracts->find_chain_head( $contract_id );
+		if ( null === $head ) {
+			throw new RenewalNotProcessable( 'no billing chain to advance' );
+		}

-		// Idempotency pre-check: a renewal order already tagged for this number means the
-		// action already ran (AS retry, double-fire). Bail before claiming a new cycle.
-		if ( $this->renewal_exists_for_cycle( $contract_id, $next_count ) ) {
-			wc_get_logger()->info(
-				sprintf( 'RenewalEngine::process_due(): renewal for contract %d cycle %d already exists - skipping (idempotent retry).', $contract_id, $next_count ),
-				array(
-					'source'      => self::LOG_SOURCE,
-					'contract_id' => $contract_id,
-				)
-			);
-			return null;
+		$head_count = $head->get_count();
+		if ( null === $head_count ) {
+			throw new RenewalNotProcessable( esc_html( sprintf( 'head cycle %d has no count to advance from', (int) $head->get_id() ) ) );
 		}

-		// Resolve the billing cadence from the contract's plan snapshot. A deleted/unresolvable
-		// plan is a recoverable data condition, not a fatal: skip (logging only) like the guards
-		// above so a scheduled action does not retry a permanent failure forever.
-		$policy = $this->resolve_billing_policy( $contract );
-		if ( null === $policy ) {
-			wc_get_logger()->warning(
-				sprintf( 'RenewalEngine::process_due(): cannot resolve the billing plan for contract %d - skipping. The selling plan may have been deleted.', $contract_id ),
-				array(
-					'source'      => self::LOG_SOURCE,
-					'contract_id' => $contract_id,
-				)
+		// Claim the target cycle - the authoritative idempotency gate, ahead of any order lookup.
+		$reclaimed = false;
+		if ( $cycle_count === $head_count + 1 ) {
+			$cycle = $this->claim_advance( $contract, $head, $cycle_count, $now );
+			if ( null === $cycle ) {
+				// The append collided: this number was already claimed by an earlier or
+				// concurrent run. Take over a stalled claim, or skip a live one.
+				$cycle     = $this->reclaim_head( $contract_id, $cycle_count, $now );
+				$reclaimed = null !== $cycle;
+			}
+		} elseif ( $cycle_count === $head_count ) {
+			$cycle     = $this->reclaim_head( $contract_id, $cycle_count, $now );
+			$reclaimed = null !== $cycle;
+		} else {
+			throw new RenewalNotProcessable(
+				esc_html( sprintf( 'cycle %d is not adjacent to head cycle %d - refusing to bill a gap.', $cycle_count, $head_count ) )
 			);
-			return null;
 		}

-		// Build the next cycle from the contract's live values (amount, currency, snapshots),
-		// one cadence forward from the anchor.
-		$new_cycle = RenewalCalculator::compute_next_cycle(
-			$policy,
-			array(
-				'contract_id'       => $contract_id,
-				'sequence_no'       => $previous->get_sequence_no() + 1,
-				'count'             => $next_count,
-				'period_start'      => $previous->get_ends_at_gmt(),
-				'expected_total'    => $contract->get_billing_total(),
-				'currency'          => $contract->get_currency(),
-				'extension_slug'    => $contract->get_extension_slug(),
-				'plan_snapshot_id'  => $contract->get_plan_snapshot_id(),
-				'items_snapshot_id' => $contract->get_items_snapshot_id(),
-			)
-		);
-
-		// Create-as-claim: the cycle is inserted `pending` before any charge. A concurrent or
-		// duplicate fire loses the UNIQUE(contract_id, kind, count) race and is an idempotent no-op.
-		if ( ! $this->claim_cycle( $new_cycle, $previous ) ) {
+		if ( null === $cycle ) {
 			return null;
 		}

-		$renewal_order = $this->build_renewal_order( $contract, $next_count, $new_cycle->get_expected_total() );
+		// Reconcile the order AFTER the claim: reuse the one linked or tagged for this cycle, or
+		// build one. The cycle being settled is the price + period authority; a reclaimed cycle
+		// carries its OWN stored total, so the order bills that, never a freshly-computed next
+		// period. A cycle appended by THIS run cannot have an order yet - order work strictly
+		// follows the claim - so the lookup (and its meta scan) runs only for a reclaimed cycle,
+		// where an earlier attempt may have left one.
+		$renewal_order = $reclaimed ? $this->find_renewal_order_for_cycle( $cycle ) : null;
+		$order_created = false;
 		if ( null === $renewal_order ) {
-			// build_renewal_order logged the reason. The claimed cycle stays pending for
-			// a later run/dunning to resolve; no schedule change is made here.
-			return null;
+			$renewal_order = $this->build_renewal_order( $contract, $cycle );
+			if ( null === $renewal_order ) {
+				// build_renewal_order logged the reason. The claimed cycle stays pending for a
+				// later run to resolve; no schedule change is made here.
+				return null;
+			}
+			$order_created = true;
+		} elseif ( $cycle->get_order_id() !== $renewal_order->get_id() ) {
+			// Found via the meta fallback: heal the missing cycle link before the order acts.
+			$cycle->set_order_id( $renewal_order->get_id() );
+			$this->contracts->update_cycle( $cycle );
 		}

-		do_action( self::RENEWAL_ORDER_CREATED_ACTION, $renewal_order, $contract );
-
-		$this->attempt_charge( $renewal_order, $contract );
-
-		$this->resolve_outcome( $contract, $new_cycle, $renewal_order );
-
-		return $renewal_order;
-	}
+		// A reused order abandoned mid-creation may still be a draft: promote it before the
+		// charge (a draft is not payable).
+		if ( $renewal_order->has_status( OrderStatus::CHECKOUT_DRAFT ) ) {
+			$renewal_order->set_status( OrderStatus::PENDING );
+			$renewal_order->save();
+		}

-	/**
-	 * The chargeable number this renewal targets - the idempotency anchor.
-	 *
-	 * One past the head cycle's count once it has settled forward (`billed`/`cancelled`): the
-	 * chain advances. While the head is still unsettled (`pending`/`failed`) the same count is
-	 * targeted again, so a retry resolves the in-flight cycle rather than skipping a number - and
-	 * the order-meta pre-check / the create-as-claim UNIQUE then make the retry a no-op.
-	 *
-	 * Only called once a billing chain exists ({@see self::process_due()} skips a chainless
-	 * contract), so the head must carry a count; a countless head is a corrupt chain to refuse.
-	 *
-	 * @param Cycle $previous The chain's most-recent cycle.
-	 * @return int The chargeable number to target.
-	 * @throws \RuntimeException If the head cycle has no count to advance from.
-	 */
-	private function target_count( Cycle $previous ): int {
-		$count = $previous->get_count();
-		if ( null === $count ) {
-			// A counting renewal advances off the head cycle's count; a head with no count is a
-			// corrupt chain we refuse to bill against rather than guess a number.
-			throw new \RuntimeException(
-				sprintf(
-					'RenewalEngine::target_count(): contract %d head cycle %d has no count to advance from.',
-					(int) $previous->get_contract_id(),
-					(int) $previous->get_id()
-				)
-			);
+		// Charge only when the order is not already paid - a crash after the charge, or a prior
+		// async attempt that has since settled, needs no second charge; completion handles it.
+		// The order was built or loaded moments ago with no gateway in between, so its own paid
+		// state is current.
+		if ( ! $renewal_order->is_paid() ) {
+			$this->ensure_payment_token( $renewal_order, $contract );
+			// The created action fires once, for a genuinely new order only. A reused order - a
+			// reclaimed stall resuming an earlier attempt - already announced its creation, so
+			// re-firing would double one-time side effects (customer emails, analytics).
+			if ( $order_created ) {
+				do_action( self::RENEWAL_ORDER_CREATED_ACTION, $renewal_order, $contract );
+			}
+			$this->attempt_charge( $renewal_order, $contract );
 		}

-		$status          = $previous->get_status()->get_value();
-		$settled_forward = CycleStatus::BILLED === $status || CycleStatus::CANCELLED === $status;
+		// Complete from the order's paid state. Idempotent and re-reading fresh, so a sync
+		// gateway that already settled the cycle via the nested payment_complete listener is a
+		// no-op here, and an async charge with no terminal outcome yet lands on `processing`.
+		$this->complete_from_order( $renewal_order );

-		return $settled_forward ? (int) $count + 1 : (int) $count;
+		return $renewal_order;
 	}

 	/**
@@ -387,108 +400,391 @@ final class RenewalEngine {
 	}

 	/**
-	 * Claim the freshly-computed `pending` cycle as the create-as-claim. Returns false when
-	 * the insert loses the `UNIQUE(contract_id, kind, count)` race (a concurrent/duplicate
-	 * fire) - treated as an idempotent no-op.
+	 * Claim the head's successor cycle as the create-as-claim: resolve the cadence, compute the
+	 * new `pending` cycle one period past the head, stamp a crash-recovery lease, and insert it.
+	 * Returns ONLY a freshly appended cycle - on a UNIQUE(contract_id, kind, count) collision
+	 * (another worker already appended this number) it returns null and the caller routes
+	 * through {@see self::reclaim_head()}. Any other write failure is logged as an error (never
+	 * mistaken for the benign collision) and also returns null, so the contract is retried on a
+	 * later tick. Keeping the fresh/reclaimed distinction at the caller lets it skip order
+	 * lookups a brand-new cycle cannot need.
 	 *
-	 * @param Cycle      $cycle    The pending cycle to claim.
-	 * @param Cycle|null $previous The chain's previous cycle (for snapshot copy-forward), or null.
-	 * @return bool True when the cycle was claimed; false when the claim was lost.
+	 * @param Contract          $contract    The contract being renewed.
+	 * @param Cycle             $head        The chain's head cycle (the new cycle's predecessor).
+	 * @param int               $cycle_count The chargeable number to append (the head's successor).
+	 * @param DateTimeImmutable $now         The processing moment (the lease clock).
+	 * @return Cycle|null The freshly appended pending cycle, or null on an append collision.
+	 * @throws RenewalNotProcessable When the billing plan cannot be resolved (a deleted plan).
 	 */
-	private function claim_cycle( Cycle $cycle, ?Cycle $previous ): bool {
+	private function claim_advance( Contract $contract, Cycle $head, int $cycle_count, DateTimeImmutable $now ): ?Cycle {
+		$policy = $this->resolve_billing_policy( $contract );
+		if ( null === $policy ) {
+			throw new RenewalNotProcessable( 'cannot resolve the billing plan (the selling plan may have been deleted)' );
+		}
+
+		$new_cycle = RenewalCalculator::compute_next_cycle(
+			$policy,
+			array(
+				'contract_id'       => (int) $contract->get_id(),
+				'sequence_no'       => $head->get_sequence_no() + 1,
+				'count'             => $cycle_count,
+				'period_start'      => $head->get_ends_at_gmt(),
+				'expected_total'    => $contract->get_billing_total(),
+				'currency'          => $contract->get_currency(),
+				'extension_slug'    => $contract->get_extension_slug(),
+				'plan_snapshot_id'  => $contract->get_plan_snapshot_id(),
+				'items_snapshot_id' => $contract->get_items_snapshot_id(),
+			)
+		);
+		$new_cycle->set_claimed_until_gmt( $this->lease_until( $now ) );
+
 		try {
-			$this->contracts->append_cycle( $cycle, $previous );
+			$this->contracts->append_cycle( $new_cycle, $head );
+		} catch ( DuplicateCycleException $e ) {
+			// The UNIQUE(contract_id, kind, count) index rejected the row: another worker
+			// already appended this number. Null routes the caller to the reclaim path.
+			return null;
 		} catch ( Throwable $e ) {
-			// A duplicate (contract_id, kind, count) is rejected by the UNIQUE index: the cycle
-			// was already claimed by a concurrent/earlier fire. Idempotent no-op.
+			// A real write failure, not the benign collision - surface it instead of
+			// mistaking it for a claim race. The contract is retried on a later scan tick.
+			wc_get_logger()->error(
+				sprintf( 'RenewalEngine::claim_advance(): cannot claim cycle %d for contract %d - will retry on a later scan. %s', $cycle_count, (int) $contract->get_id(), $e->getMessage() ),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => (int) $contract->get_id(),
+				)
+			);
+			return null;
+		}
+
+		return $new_cycle;
+	}
+
+	/**
+	 * Reclaim the chain head at `$count` for a re-attempt, or skip. Re-reads the head; two heads
+	 * are reclaimable, each via an atomic compare-and-set so that among concurrent workers only
+	 * the one whose UPDATE matches the row wins (the rest match zero rows and skip, so the cycle
+	 * is charged at most once):
+	 *
+	 * - a `pending` cycle whose `claimed_until` lease has expired - a charge that claimed but
+	 *   never settled (crash recovery), via {@see ContractRepository::reclaim_expired_cycle()};
+	 * - a `failed` cycle - an admin-triggered retry that flips it back to `pending`, via
+	 *   {@see ContractRepository::reclaim_failed_cycle()}. Scheduled selection never routes a
+	 *   failed head here; only a manual trigger does.
+	 *
+	 * A still-leased pending cycle (a live claim), a settled cycle, or a `processing` head
+	 * (awaiting its gateway) is a no-op (null).
+	 *
+	 * @param int               $contract_id The contract being renewed.
+	 * @param int               $count       The chargeable number to reclaim.
+	 * @param DateTimeImmutable $now         The processing moment (the lease clock).
+	 * @return Cycle|null The reclaimed cycle (this caller won the CAS), or null to skip.
+	 */
+	private function reclaim_head( int $contract_id, int $count, DateTimeImmutable $now ): ?Cycle {
+		$head = $this->contracts->find_chain_head( $contract_id );
+
+		if ( null === $head || $count !== $head->get_count() ) {
+			// The chain moved on (or vanished) between selection and the claim: nothing at
+			// this number to reclaim.
+			wc_get_logger()->info(
+				sprintf( 'RenewalEngine::process(): cycle %d for contract %d is no longer the chain head - skipping.', $count, $contract_id ),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => $contract_id,
+				)
+			);
+
+			return null;
+		}
+
+		if ( $head->get_status()->equals( 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.
+			$won = $this->contracts->reclaim_expired_cycle( (int) $head->get_id(), self::LEASE_TTL_SECONDS );
+
+			if ( $won ) {
+				wc_get_logger()->info(
+					sprintf( 'RenewalEngine::process(): reclaiming stalled cycle %d for contract %d (lease expired) - re-attempting.', $count, $contract_id ),
+					array(
+						'source'      => self::LOG_SOURCE,
+						'contract_id' => $contract_id,
+					)
+				);
+
+				return $head;
+			}
+
+			// Another worker won the reclaim CAS between our read and write: skip.
 			wc_get_logger()->info(
-				sprintf( 'RenewalEngine::process_due(): could not claim cycle %d for contract %d (already claimed) - skipping. %s', (int) $cycle->get_count(), $cycle->get_contract_id(), $e->getMessage() ),
+				sprintf( 'RenewalEngine::process(): cycle %d for contract %d was reclaimed by another worker - skipping.', $count, $contract_id ),
 				array(
 					'source'      => self::LOG_SOURCE,
-					'contract_id' => $cycle->get_contract_id(),
+					'contract_id' => $contract_id,
 				)
 			);
+
+			return null;
+		}
+
+		// 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() ) ) {
+			// 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(
+					sprintf( 'RenewalEngine::process(): retrying failed cycle %d for contract %d - re-attempting.', $count, $contract_id ),
+					array(
+						'source'      => self::LOG_SOURCE,
+						'contract_id' => $contract_id,
+					)
+				);
+
+				return $head;
+			}
+
+			return null;
+		}
+
+		// A live lease (concurrent worker), an already-settled cycle, or one awaiting its
+		// gateway (`processing`): idempotent no-op.
+		wc_get_logger()->info(
+			sprintf( 'RenewalEngine::process(): cycle %d for contract %d is already claimed or settled - skipping.', $count, $contract_id ),
+			array(
+				'source'      => self::LOG_SOURCE,
+				'contract_id' => $contract_id,
+			)
+		);
+
+		return null;
+	}
+
+	/**
+	 * The lease expiry to stamp on a freshly-claimed cycle: `$now` + {@see self::LEASE_TTL_SECONDS},
+	 * as a GMT string. `$now` is the same processing moment the due-guard uses, so a single
+	 * `process()` call reads one clock throughout.
+	 *
+	 * @param DateTimeImmutable $now The processing moment.
+	 */
+	private function lease_until( DateTimeImmutable $now ): string {
+		return gmdate( 'Y-m-d H:i:s', $now->getTimestamp() + self::LEASE_TTL_SECONDS );
+	}
+
+	/**
+	 * Whether `$cycle`'s crash-recovery lease has expired (it is reclaimable).
+	 *
+	 * Every cycle the engine claims stamps a lease, so an in-flight pending cycle carries
+	 * one. A cycle with NO lease recorded is treated as NOT expired (not reclaimable): the
+	 * engine cannot prove it is stale, so it is left as a live claim rather than risk
+	 * re-charging a cycle some other path created. Only an explicit lease whose moment has
+	 * passed is reclaimable.
+	 *
+	 * A cheap local pre-check only: the reclaim compare-and-set runs its own expiry predicate
+	 * on the DATABASE clock, so a skewed PHP clock here cannot win a takeover early - at worst
+	 * it wastes (fast clock) or defers (slow clock) a reclaim attempt by the skew.
+	 *
+	 * @param Cycle             $cycle The cycle whose lease to test.
+	 * @param DateTimeImmutable $now   The processing moment (the lease clock).
+	 */
+	private function lease_has_expired( Cycle $cycle, DateTimeImmutable $now ): bool {
+		$claimed_until = $cycle->get_claimed_until_gmt();
+		if ( null === $claimed_until ) {
 			return false;
 		}

-		return true;
+		$expires_at = strtotime( $claimed_until . ' UTC' );
+
+		// An unparsable lease is treated as live (not reclaimable): never re-charge on bad data.
+		return false !== $expires_at && $expires_at <= $now->getTimestamp();
 	}

 	/**
-	 * Resolve the renewal outcome from the order's paid state.
+	 * Complete a renewal from its order's paid state - the single completion routine, reached
+	 * as a post-charge reconciliation in {@see self::process()} and from the order-status
+	 * listener {@see self::handle_order_settled()}. Keying completion on the order (not the
+	 * charge call's return) lets synchronous and asynchronous gateways share one path.
 	 *
-	 * Paid -> CAS the cycle `pending -> billed`, link the order, advance the contract's
-	 * `next_payment_gmt` (the cycle's own period end) and `last_payment_gmt`, persist. Not
-	 * paid -> CAS the cycle `pending -> failed` (recording a reason) and leave the contract
-	 * schedule unchanged for a later dunning pass.
+	 * The order's renewal meta locates the contract; whether this order settles the head is
+	 * then decided by the CYCLE's own data: a head linked to an order settles only from that
+	 * order, and the order meta count is consulted only for an unlinked head (a pre-link
+	 * crash). Re-reading the head fresh keeps it idempotent: it acts only while the head is the
+	 * still-in-flight cycle this order bills (`pending`/`processing`) and no-ops once it is
+	 * terminal or the chain has advanced. A non-renewal order is ignored.
 	 *
-	 * @param Contract $contract      The contract being renewed.
-	 * @param Cycle    $cycle         The claimed pending cycle to settle.
-	 * @param WC_Order $renewal_order The charged renewal order.
+	 * @param WC_Order $order The order whose state may settle a cycle.
 	 */
-	private function resolve_outcome( Contract $contract, Cycle $cycle, WC_Order $renewal_order ): void {
+	public function complete_from_order( WC_Order $order ): void {
+		if ( OrderLinkage::RELATION_RENEWAL !== $order->get_meta( OrderLinkage::META_RELATION_TYPE ) ) {
+			return;
+		}
+
+		$contract_id = ScalarCoercion::coerce_int( $order->get_meta( OrderLinkage::META_CONTRACT_ID ) );
+		if ( $contract_id <= 0 ) {
+			return;
+		}
+
+		$contract = $this->contracts->find( $contract_id );
+		if ( null === $contract ) {
+			return;
+		}
+
+		$cycle = $this->contracts->find_chain_head( $contract_id );
+		if ( null === $cycle ) {
+			return;
+		}
+
+		$linked_id = $cycle->get_order_id();
+		if ( null !== $linked_id ) {
+			// The head knows its order: settle only from that order, whatever the meta says.
+			if ( $linked_id !== $order->get_id() ) {
+				return;
+			}
+		} else {
+			// Unlinked head (a crash before the link was stamped): fall back to the order's
+			// chargeable-number meta to decide whether it bills this head.
+			$count_meta = $order->get_meta( self::renewal_cycle_meta_key() );
+			if ( ! is_numeric( $count_meta ) || (int) $count_meta !== $cycle->get_count() ) {
+				return;
+			}
+		}
+
+		$status = $cycle->get_status()->get_value();
+		if ( CycleStatus::PENDING !== $status && CycleStatus::PROCESSING !== $status ) {
+			// Already terminal: idempotent no-op (a concurrent path settled it first).
+			return;
+		}
+
+		$this->settle_cycle( $contract, $cycle, $order );
+	}
+
+	/**
+	 * Settle an in-flight cycle from `$order`'s paid state, and advance the contract on success.
+	 *
+	 * Every outcome lands through {@see ContractRepository::transition_cycle_status()} - an
+	 * atomic compare-and-set on the status the caller read - so among racing settlers (the
+	 * post-charge reconciliation and the order-status listener can overlap across workers)
+	 * exactly one wins each transition, and the billed action fires exactly once per cycle.
+	 *
+	 * Paid -> cycle `billed`, `next_payment_gmt` advanced to the cycle's OWN `ends_at_gmt` (the
+	 * period actually charged, so a reclaimed cycle advances exactly one cadence, never
+	 * skipping one) and `last_payment_gmt` taken from the order's paid date - inputs that do
+	 * not move between invocations. Failed -> cycle `failed` (recording a reason), schedule
+	 * left for a later dunning pass. Neither yet -> cycle `processing`: the gateway accepted an
+	 * async charge whose outcome will arrive later; the crash-recovery lease is cleared (a
+	 * submitted charge is no longer a mid-submit window to reclaim) and the schedule is left
+	 * untouched until the order settles.
+	 *
+	 * @param Contract $contract The contract being renewed.
+	 * @param Cycle    $cycle    The in-flight (`pending`/`processing`) cycle to settle.
+	 * @param WC_Order $order    The renewal order carrying the outcome.
+	 */
+	private function settle_cycle( Contract $contract, Cycle $cycle, WC_Order $order ): void {
 		$now = gmdate( 'Y-m-d H:i:s' );

 		// Re-fetch the order: a gateway handler that called payment_complete() on its own
 		// freshly-loaded instance leaves the passed object stale, which would misread a
-		// successful charge as unpaid. Read the paid state from the fresh instance.
-		$fresh = wc_get_order( $renewal_order->get_id() );
-		$paid  = $fresh instanceof WC_Order ? $fresh->is_paid() : $renewal_order->is_paid();
+		// successful charge. Never settle from the stale copy: when the fresh read fails (the
+		// order vanished mid-flight) the cycle stays in flight for a later run to resolve.
+		$fresh = wc_get_order( $order->get_id() );
+		if ( ! $fresh instanceof WC_Order ) {
+			wc_get_logger()->warning(
+				sprintf( 'RenewalEngine::settle_cycle(): renewal order %d could not be re-read - leaving cycle %d unsettled.', $order->get_id(), (int) $cycle->get_id() ),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => (int) $contract->get_id(),
+					'order_id'    => $order->get_id(),
+				)
+			);
+			return;
+		}
+		$order = $fresh;

-		// The renewal order exists regardless of the charge outcome, so record it on the
-		// cycle either way - a failed/pending cycle still references its order for dunning
-		// and admin visibility.
-		$cycle->set_order_id( $renewal_order->get_id() );
+		$cycle_id    = (int) $cycle->get_id();
+		$read_status = $cycle->get_status()->get_value();

-		if ( $paid ) {
-			// CAS pending -> billed (the entity validates the transition).
+		if ( $order->is_paid() ) {
+			if ( ! $this->contracts->transition_cycle_status( $cycle_id, $read_status, CycleStatus::BILLED, $order->get_id() ) ) {
+				// Another settler won the CAS; its transition carried the side effects.
+				return;
+			}
+			// 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() );
-			$this->contracts->update_cycle( $cycle );
+			$cycle->set_claimed_until_gmt( null );

+			// Advance to the period actually billed (this cycle's end), not a recomputed one;
+			// the payment moment comes from the order itself.
+			$paid_at = $order->get_date_paid();
 			$contract->set_next_payment_gmt( $cycle->get_ends_at_gmt() );
-			$contract->set_last_payment_gmt( $now );
+			$contract->set_last_payment_gmt( null !== $paid_at ? gmdate( 'Y-m-d H:i:s', $paid_at->getTimestamp() ) : $now );
 			$contract->set_last_attempt_gmt( $now );
 			$this->contracts->update( $contract );

 			/**
 			 * Fires after a renewal cycle is billed and the contract schedule advanced.
 			 *
-			 * @param Contract $contract      The renewed contract.
-			 * @param Cycle    $cycle         The newly-billed cycle.
-			 * @param WC_Order $renewal_order The paid renewal order.
+			 * @param Contract $contract The renewed contract.
+			 * @param Cycle    $cycle    The newly-billed cycle.
+			 * @param WC_Order $order    The paid renewal order.
 			 */
-			do_action( self::RENEWAL_BILLED_ACTION, $contract, $cycle, $renewal_order );
+			do_action( self::RENEWAL_BILLED_ACTION, $contract, $cycle, $order );

 			return;
 		}

-		// Not paid: settle the cycle failed and leave the contract schedule for dunning.
-		$cycle->set_status( CycleStatus::failed() );
-		$cycle->set_reason( 'gateway-charge-not-settled' );
-		$this->contracts->update_cycle( $cycle );
+		if ( $order->has_status( OrderStatus::FAILED ) ) {
+			if ( ! $this->contracts->transition_cycle_status( $cycle_id, $read_status, CycleStatus::FAILED, $order->get_id(), 'gateway-charge-failed' ) ) {
+				return;
+			}
+			$cycle->set_order_id( $order->get_id() );
+			$cycle->set_status( CycleStatus::failed() );
+			$cycle->set_reason( 'gateway-charge-failed' );
+			$cycle->set_claimed_until_gmt( null );
+
+			$contract->set_last_attempt_gmt( $now );
+			$this->contracts->update( $contract );
+
+			return;
+		}
+
+		// Neither paid nor failed: the gateway accepted the charge but has not confirmed it
+		// (an async method). Park the cycle in `processing` until its outcome arrives; the
+		// listener completes it then. Only a pending cycle needs the write - a processing one
+		// re-entering here is already parked.
+		if ( CycleStatus::PENDING === $read_status ) {
+			$this->contracts->transition_cycle_status( $cycle_id, CycleStatus::PENDING, CycleStatus::PROCESSING, $order->get_id() );
+		}

 		$contract->set_last_attempt_gmt( $now );
 		$this->contracts->update( $contract );
 	}

 	/**
-	 * Build the renewal order from the contract's own stored state: its billing / shipping
-	 * addresses and its (recurring) line items - never the origin order, whose cart may have
-	 * carried one-time items that must not ride along onto a renewal. Applies the new cycle's
+	 * Build the renewal order for `$cycle` from the contract's own stored state: its billing /
+	 * shipping addresses and its (recurring) line items - never the origin order, whose cart may
+	 * have carried one-time items that must not ride along onto a renewal. Applies the cycle's
 	 * expected total as ground truth, attaches the contract's payment token, and tags the
 	 * renewal relation meta (contract id + chargeable number) so charge observers and the
-	 * idempotency check can find it. Returns null (logged) when `wc_create_order()` fails.
+	 * order-to-cycle mapping can find it.
+	 *
+	 * Created draft-first: the order starts as `checkout-draft`, is linked onto the claimed
+	 * cycle (`order_id`), and only then becomes `pending`. A crash mid-way therefore leaves
+	 * either a linked draft the resume path promotes, or an unlinked draft that fires no emails
+	 * and is swept by core's stale-draft cleanup - never a live pending order the cycle does not
+	 * know about. Returns null (logged) when `wc_create_order()` fails.
 	 *
-	 * @param Contract $contract       Contract being renewed.
-	 * @param int      $count          The chargeable number this order bills.
-	 * @param string   $expected_total The new cycle's expected total (the price authority).
+	 * @param Contract $contract Contract being renewed.
+	 * @param Cycle    $cycle    The claimed cycle this order bills (count + expected total).
 	 * @return WC_Order|null The saved pending renewal order, or null on failure.
 	 */
-	private function build_renewal_order( Contract $contract, int $count, string $expected_total ): ?WC_Order {
+	private function build_renewal_order( Contract $contract, Cycle $cycle ): ?WC_Order {
+		$count          = (int) $cycle->get_count();
+		$expected_total = $cycle->get_expected_total();
+
 		$renewal_order = wc_create_order(
 			array(
 				'customer_id' => $contract->get_customer_id(),
-				'status'      => 'pending',
+				'status'      => OrderStatus::CHECKOUT_DRAFT,
 				'created_via' => 'woocommerce_subscriptions_engine_renewal',
 			)
 		);
@@ -557,25 +853,52 @@ final class RenewalEngine {
 		// shipping / tax breakdown is a later money-path's job.
 		$renewal_order->set_total( $expected_total );

-		// Tag the renewal relation + chargeable number so the idempotency check can detect a
-		// duplicate fire, and save before attaching the token so a crash between the two leaves
-		// the order findable (no duplicate charge on the retry).
+		// Tag the renewal relation + chargeable number so completion can map the order back to
+		// its cycle, and save (still a draft) before any linking so a crash between the two
+		// leaves the order findable (no duplicate charge on the retry).
 		$renewal_order->update_meta_data( OrderLinkage::META_CONTRACT_ID, (string) $contract->get_id() );
 		$renewal_order->update_meta_data( OrderLinkage::META_RELATION_TYPE, OrderLinkage::RELATION_RENEWAL );
 		$renewal_order->update_meta_data( self::renewal_cycle_meta_key(), (string) $count );
 		$renewal_order->save();

-		$token_id = $instrument->get_token_id();
-		if ( null !== $token_id ) {
-			$token = \WC_Payment_Tokens::get( $token_id );
-			if ( $token instanceof \WC_Payment_Token ) {
-				$renewal_order->add_payment_token( $token );
-			}
-		}
+		// Link the order onto the claimed cycle BEFORE the order goes live: the cycle row is the
+		// idempotency authority, so the link must exist by the time the order can act (emails,
+		// charges). Only then does the draft become a real pending order.
+		$cycle->set_order_id( $renewal_order->get_id() );
+		$this->contracts->update_cycle( $cycle );
+
+		$renewal_order->set_status( OrderStatus::PENDING );
+		$renewal_order->save();
+
+		$this->ensure_payment_token( $renewal_order, $contract );

 		return $renewal_order;
 	}

+	/**
+	 * Attach the contract's stored payment token to `$order` when it carries none. Idempotent:
+	 * a no-op when the order already has a token, so it is safe both on a freshly-built order
+	 * and when resuming a renewal order a crash may have left un-tokenised before its charge.
+	 *
+	 * @param WC_Order $order    The renewal order to tokenise.
+	 * @param Contract $contract The contract whose payment instrument holds the token.
+	 */
+	private function ensure_payment_token( WC_Order $order, Contract $contract ): void {
+		if ( array() !== $order->get_payment_tokens() ) {
+			return;
+		}
+
+		$token_id = $contract->get_payment_instrument()->get_token_id();
+		if ( null === $token_id ) {
+			return;
+		}
+
+		$token = \WC_Payment_Tokens::get( $token_id );
+		if ( $token instanceof \WC_Payment_Token ) {
+			$order->add_payment_token( $token );
+		}
+	}
+
 	/**
 	 * Read a contract-item field as a string, defaulting to empty when absent or non-scalar.
 	 *
@@ -624,10 +947,9 @@ final class RenewalEngine {
 	 * @param Contract $contract      The contract being renewed.
 	 */
 	private function attempt_charge( WC_Order $renewal_order, Contract $contract ): void {
-		$gateway_id = $contract->get_payment_instrument()->get_gateway();
-		if ( null === $gateway_id || '' === $gateway_id ) {
-			return;
-		}
+		// process() pre-flights the gateway (present + declares `recurring`) before any claim,
+		// so the instrument is chargeable by the time the money-path reaches the charge.
+		$gateway_id = (string) $contract->get_payment_instrument()->get_gateway();

 		$amount = (float) $renewal_order->get_total();

@@ -635,7 +957,11 @@ final class RenewalEngine {
 			/**
 			 * Fires to request a recurring charge for a renewal order. The gateway (or its
 			 * adapter) captures against the stored token, then transitions the order via its
-			 * own `payment_complete()` / failure handling.
+			 * own `payment_complete()` / failure handling. The gateway is expected to reach a
+			 * terminal order state for errors it can detect - mark the order failed on a
+			 * decline or an unrecoverable processing error. An order left neither paid nor
+			 * failed is treated as an async charge awaiting confirmation: its cycle parks in
+			 * `processing` until the order settles (or is resolved manually).
 			 *
 			 * @param float    $amount        The amount to charge.
 			 * @param WC_Order $renewal_order The renewal order being charged.
@@ -656,32 +982,48 @@ final class RenewalEngine {
 	}

 	/**
-	 * Whether a renewal order tagged for `$contract_id` at `$cycle` already exists -
-	 * the idempotency check for AS retries.
+	 * The renewal order for `$cycle`, or null when none exists - the reuse lookup the
+	 * post-claim order reconciliation runs for a RECLAIMED cycle only. A freshly appended
+	 * cycle skips it entirely (no order can exist before its claim), which keeps the meta
+	 * scan below off the every-renewal path.
 	 *
-	 * Queries on the contract id via the flat `meta_key` / `meta_value` shortcut, then
-	 * narrows by relation type and cycle in PHP. The flat shortcut is used rather than a
+	 * The cycle's own `order_id` reference resolves directly (we already hold the row, and the
+	 * link is stamped at order creation). The meta search is the fallback for a cycle that was
+	 * claimed but never linked - a crash between creating the order and linking it: it queries
+	 * on the contract id via the flat `meta_key` / `meta_value` shortcut, then narrows by
+	 * relation type and chargeable number in PHP. The flat shortcut is used rather than a
 	 * `meta_query` because the legacy CPT order store rejects `meta_query` with
-	 * `wc_doing_it_wrong`; the shortcut round-trips through both stores.
+	 * `wc_doing_it_wrong`; the shortcut round-trips through both stores. Statuses are passed
+	 * explicitly (not `'any'`) because a crash-abandoned order may still be a `checkout-draft`,
+	 * which `'any'` excludes.
 	 *
-	 * @param int $contract_id Contract id.
-	 * @param int $cycle       The cycle number the renewal would bill.
+	 * @param Cycle $cycle The claimed cycle whose renewal order to resolve.
+	 * @return WC_Order|null The existing renewal order for the cycle, or null when none.
 	 */
-	private function renewal_exists_for_cycle( int $contract_id, int $cycle ): bool {
+	private function find_renewal_order_for_cycle( Cycle $cycle ): ?WC_Order {
+		$linked_id = $cycle->get_order_id();
+		if ( null !== $linked_id ) {
+			$linked = wc_get_order( $linked_id );
+			if ( $linked instanceof WC_Order ) {
+				return $linked;
+			}
+			// The linked order is gone (deleted): fall through to the meta search.
+		}
+
 		$orders = wc_get_orders(
 			array(
 				'limit'      => -1,
-				'status'     => 'any',
+				'status'     => array_keys( wc_get_order_statuses() ),
 				'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
+				'meta_key'   => OrderLinkage::META_CONTRACT_ID,        // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+				'meta_value' => (string) $cycle->get_contract_id(),    // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
 			)
 		);

 		// Unpaginated, so wc_get_orders() returns a plain list. The guard narrows the
 		// declared return type and treats any non-array result as "no matching renewal".
 		if ( ! is_array( $orders ) ) {
-			return false;
+			return null;
 		}

 		foreach ( $orders as $order ) {
@@ -690,18 +1032,55 @@ final class RenewalEngine {
 			}

 			if ( OrderLinkage::RELATION_RENEWAL === $order->get_meta( OrderLinkage::META_RELATION_TYPE )
-				&& (string) $cycle === $order->get_meta( self::renewal_cycle_meta_key() ) ) {
-				return true;
+				&& (string) $cycle->get_count() === $order->get_meta( self::renewal_cycle_meta_key() ) ) {
+				return $order;
 			}
 		}

-		return false;
+		return null;
+	}
+
+	/**
+	 * Park a contract that cannot be auto-renewed by clearing its `next_payment_gmt`, so it
+	 * leaves the due-index and the scan stops revisiting it every tick - which would otherwise
+	 * let a cluster of un-renewable contracts hold the front of the oldest-due-first scan and
+	 * starve healthy renewals. A no-op when the contract is gone. A repair (fixing the
+	 * underlying data and rescheduling) re-arms it.
+	 *
+	 * Best-effort, never throws: parking protects the scan, it is not a correctness requirement,
+	 * and it runs inside the dispatcher's per-contract error handling - a failure here (the row
+	 * 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.
+	 *
+	 * @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 ) {
+				return;
+			}
+
+			$contract->set_next_payment_gmt( null );
+			$this->contracts->update( $contract );
+		} catch ( Throwable $e ) {
+			wc_get_logger()->error(
+				sprintf( 'RenewalEngine::park(): failed to park contract %d - %s', $contract_id, $e->getMessage() ),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => $contract_id,
+				)
+			);
+		}
 	}

 	/**
 	 * Order meta key recording which cycle a renewal order bills.
 	 *
-	 * Used by the per-cycle idempotency check. Internal to the engine.
+	 * Read on both sides of the charge: before it, to find and reuse an existing unsettled order
+	 * for a cycle ({@see self::find_renewal_order_for_cycle()}) rather than build a duplicate;
+	 * after it, to map the settled order back to its cycle ({@see self::complete_from_order()}).
+	 * Internal to the engine.
 	 */
 	private static function renewal_cycle_meta_key(): string {
 		return '_subscription_renewal_cycle';
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalIntent.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalIntent.php
new file mode 100644
index 00000000000..1bc5bbd5d78
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalIntent.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * RenewalIntent - the seam between selection and processing: a resolved decision to bill a
+ * specific cycle of a specific contract. A trigger (the batch {@see RenewalDispatcher} or the
+ * admin `renew_now()` path) builds one from the cycle {@see RenewalSelector} chose;
+ * {@see RenewalEngine::process()} consumes it.
+ *
+ * Carrying the target as an explicit value keeps `process()` free of any "which cycle"
+ * logic - it bills exactly the count it is handed, so a future trigger (an admin retry, a
+ * customer early renewal) is just a different trigger building a different intent over
+ * the same processing path.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * A resolved decision to bill one cycle of one contract.
+ */
+final class RenewalIntent {
+
+	/**
+	 * Contract to bill.
+	 *
+	 * @var int
+	 */
+	private $contract_id;
+
+	/**
+	 * The chargeable cycle count to bill.
+	 *
+	 * @var int
+	 */
+	private $cycle_count;
+
+	/**
+	 * Build an intent to bill `$cycle_count` of `$contract_id`.
+	 *
+	 * @param int $contract_id Contract to bill.
+	 * @param int $cycle_count The chargeable cycle count to bill.
+	 */
+	public function __construct( int $contract_id, int $cycle_count ) {
+		$this->contract_id = $contract_id;
+		$this->cycle_count = $cycle_count;
+	}
+
+	/**
+	 * Contract to bill.
+	 */
+	public function get_contract_id(): int {
+		return $this->contract_id;
+	}
+
+	/**
+	 * The chargeable cycle count to bill.
+	 */
+	public function get_cycle_count(): int {
+		return $this->cycle_count;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalNotProcessable.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalNotProcessable.php
new file mode 100644
index 00000000000..0a336450272
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalNotProcessable.php
@@ -0,0 +1,29 @@
+<?php
+/**
+ * RenewalNotProcessable - a renewal cannot start: a pre-flight impossibility discovered
+ * before any charge (no billing chain to advance, or a billing plan that no longer
+ * resolves). Thrown by {@see RenewalEngine::process()} so the invoking flow decides the
+ * response - the caller (which already holds the contract id) parks the contract, clearing
+ * its next payment so it leaves the due window and cannot re-poison the scan every tick; a
+ * repair re-arms it.
+ *
+ * A marker exception: the message carries the reason, the caller carries the contract.
+ * Distinct from an ordinary skip (an idempotent no-op returns null, no park) and from a
+ * charge that ran and did not settle (that settles the cycle `failed`, not an exception).
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal;
+
+use RuntimeException;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Thrown when a renewal cannot be processed at all (a park-worthy pre-flight failure).
+ */
+final class RenewalNotProcessable extends RuntimeException {
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalScheduler.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalScheduler.php
deleted file mode 100644
index 4233936c96e..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalScheduler.php
+++ /dev/null
@@ -1,111 +0,0 @@
-<?php
-/**
- * RenewalScheduler - the Action Scheduler bridge for renewal dispatch.
- *
- * Owns the AS hook and group conventions and keeps them behind
- * {@see RenewalEngine} so callers never couple to Action Scheduler hook names.
- * If the scheduling substrate ever changes, this is the only file that has to.
- *
- * POC shape (one job per contract): each contract has at most one pending AS
- * row, keyed by contract id. This is deliberately NOT the batch dispatcher the
- * engine targets long-term (a few recurring jobs scanning a due index with
- * lease claims) - that arrives with the cycles/attempts reshape. Until then,
- * one-job-per-contract is the simplest thing that runs the money-path.
- *
- * Integration zone: WordPress-native. Calls Action Scheduler's `as_*()`
- * functions directly.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal;
-
-use DateTimeImmutable;
-use DateTimeZone;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Action Scheduler bridge for renewals.
- */
-final class RenewalScheduler {
-
-	/**
-	 * Action Scheduler hook fired when a contract's renewal is due.
-	 *
-	 * Public so tooling and tests can inspect or cancel pending actions via
-	 * `as_has_scheduled_action()` and friends.
-	 */
-	public const HOOK = 'woocommerce_subscriptions_engine_process_renewal';
-
-	/**
-	 * Action Scheduler group - used for admin filterability (Tools ->
-	 * Scheduled Actions) and bulk teardown.
-	 */
-	public const GROUP = 'woocommerce_subscriptions_engine';
-
-	/**
-	 * Enqueue an AS action for `$contract_id` at `$when`.
-	 *
-	 * Does NOT clear pre-existing pending actions - {@see RenewalEngine::schedule()}
-	 * owns the clear-then-enqueue that keeps the single-row-per-contract
-	 * invariant. Calling this directly without first unscheduling will produce
-	 * duplicate rows.
-	 *
-	 * `$when->getTimestamp()` is a UTC unix timestamp regardless of the
-	 * argument's timezone, so the dispatch moment is unambiguous.
-	 *
-	 * @param int               $contract_id Contract whose renewal is being scheduled.
-	 * @param DateTimeImmutable $when        When the renewal should fire.
-	 */
-	public static function schedule( int $contract_id, DateTimeImmutable $when ): void {
-		as_schedule_single_action(
-			$when->getTimestamp(),
-			self::HOOK,
-			array( $contract_id ),
-			self::GROUP
-		);
-	}
-
-	/**
-	 * Cancel any pending AS action for `$contract_id`.
-	 *
-	 * AS matches on hook + args + group, so passing `[ $contract_id ]` scopes
-	 * the cancel to that one contract. No-op when nothing is pending.
-	 *
-	 * @param int $contract_id Contract whose pending renewal to clear.
-	 */
-	public static function unschedule( int $contract_id ): void {
-		as_unschedule_all_actions( self::HOOK, array( $contract_id ), self::GROUP );
-	}
-
-	/**
-	 * Whether a renewal action is currently pending for `$contract_id`.
-	 *
-	 * @param int $contract_id Contract to query.
-	 */
-	public static function is_scheduled( int $contract_id ): bool {
-		return false !== as_next_scheduled_action( self::HOOK, array( $contract_id ), self::GROUP );
-	}
-
-	/**
-	 * The moment the next pending renewal will fire for `$contract_id`, or null
-	 * if nothing is queued.
-	 *
-	 * AS returns an int unix timestamp (UTC) for a pending action, or
-	 * `false` / `0` when nothing future is queued.
-	 *
-	 * @param int $contract_id Contract to query.
-	 * @return DateTimeImmutable|null UTC moment of the next renewal, or null.
-	 */
-	public static function next_scheduled( int $contract_id ): ?DateTimeImmutable {
-		$timestamp = as_next_scheduled_action( self::HOOK, array( $contract_id ), self::GROUP );
-		if ( ! is_int( $timestamp ) || $timestamp <= 0 ) {
-			return null;
-		}
-
-		return ( new DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new DateTimeZone( 'UTC' ) );
-	}
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalSelector.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalSelector.php
new file mode 100644
index 00000000000..622ac738ff6
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalSelector.php
@@ -0,0 +1,120 @@
+<?php
+/**
+ * RenewalSelector - read-only selection: given a contract's head-cycle fields, decide
+ * which cycle to bill, or nothing. It performs no writes and touches no gateway; it turns
+ * a {@see RenewalCandidate} into the cycle number to bill, or null to skip. The caller
+ * builds the {@see RenewalIntent} the money-path executes.
+ *
+ * It encodes two selection policies over the same `process()` primitive (which bills whatever
+ * cycle it is handed and owns no due policy):
+ *
+ * - scheduled ({@see self::select_scheduled_cycle()}): advance to the next cycle once the current
+ *   period has ended (the due-guard), or retry a still-in-flight head. The guard anchors on the
+ *   head's `ends_at_gmt` - immutable once settled - so it is race-free: an overlapping run that
+ *   reads a just-billed head sees its end still in the future and does not charge ahead.
+ * - admin-triggered ({@see self::select_manual_cycle()}): force the next cycle regardless of the
+ *   due-guard, or retry a failed/stalled head - the admin is deciding, not the schedule.
+ *
+ * WordPress-free by construction: `$now` is passed in.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal;
+
+use DateTimeImmutable;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\RenewalCandidate;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * The renewal cycle selector - scheduled and admin-triggered policies.
+ */
+final class RenewalSelector {
+
+	/**
+	 * Resolve the cycle a scheduled renewal should bill for this candidate, or null to skip.
+	 *
+	 * @param RenewalCandidate  $candidate The candidate (contract + head fields).
+	 * @param DateTimeImmutable $now       The scan moment.
+	 * @return int|null The cycle count to bill, or null when nothing is due.
+	 */
+	public function select_scheduled_cycle( RenewalCandidate $candidate, DateTimeImmutable $now ): ?int {
+		$count = $candidate->get_head_count();
+		if ( null === $count ) {
+			// A countless head is a corrupt chain the scan should not surface; refuse to guess.
+			return null;
+		}
+
+		$status = $candidate->get_head_status();
+
+		// Head settled forward: advance to the next cycle, but only once its period has begun.
+		// The guard is the charge-ahead defence - a just-billed head whose period runs into the
+		// future is not yet due for its successor.
+		if ( CycleStatus::BILLED === $status || CycleStatus::CANCELLED === $status ) {
+			if ( ! self::has_period_ended( $candidate->get_head_ends_at_gmt(), $now ) ) {
+				return null;
+			}
+			return $count + 1;
+		}
+
+		// Head still in flight: retry the same cycle. The money-path reclaims a stalled one
+		// (the scan only surfaces a pending head whose crash-recovery lease has expired).
+		if ( CycleStatus::PENDING === $status ) {
+			return $count;
+		}
+
+		// failed (awaits dunning) / processing (awaits its gateway): not selectable here. The
+		// scan already excludes them; this is a defensive skip.
+		return null;
+	}
+
+	/**
+	 * Resolve the cycle a manual (admin-triggered) renewal should bill for this candidate, or
+	 * null to skip. Unlike the scheduled path this applies no due-guard - the admin is forcing
+	 * the renewal - so a settled head advances to the next cycle even before its period ends,
+	 * while a failed or still-pending head is re-attempted at its own count. A `processing` head
+	 * (awaiting its gateway) or a countless head is not manually renewable.
+	 *
+	 * @param RenewalCandidate $candidate The candidate (contract + head fields).
+	 * @return int|null The cycle count to bill, or null when nothing is renewable.
+	 */
+	public function select_manual_cycle( RenewalCandidate $candidate ): ?int {
+		$count = $candidate->get_head_count();
+		if ( null === $count ) {
+			return null;
+		}
+
+		$status = $candidate->get_head_status();
+
+		// Settled forward: force the next cycle. Its period continues from the previous end, so
+		// the schedule is preserved (a prepay), not reset to now.
+		if ( CycleStatus::BILLED === $status || CycleStatus::CANCELLED === $status ) {
+			return $count + 1;
+		}
+
+		// Failed (retry) or still in flight (reclaim a stalled one): re-attempt the same cycle.
+		if ( CycleStatus::FAILED === $status || CycleStatus::PENDING === $status ) {
+			return $count;
+		}
+
+		// processing: awaiting its gateway - a manual trigger cannot preempt an in-flight charge.
+		return null;
+	}
+
+	/**
+	 * Whether a period ending at `$ends_at_gmt` has ended by `$now` - the scheduled due-guard.
+	 * An unparseable end is treated as not ended (never charge ahead on bad data). Internal to
+	 * selection: `process()` bills whatever cycle it is handed and applies no due policy of its own.
+	 *
+	 * @param string            $ends_at_gmt The head period end (GMT string).
+	 * @param DateTimeImmutable $now         The scan moment.
+	 */
+	private static function has_period_ended( string $ends_at_gmt, DateTimeImmutable $now ): bool {
+		$ends_at = strtotime( $ends_at_gmt . ' UTC' );
+		return false !== $ends_at && $ends_at <= $now->getTimestamp();
+	}
+}
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 a158749ac14..4aa70978438 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
@@ -6,7 +6,7 @@
  * The contract is the live source of truth. A chain is NOT a stored entity: it is
  * the pair `(contract_id, kind)`, with its head and counters derived from the cycle
  * rows. The entity never carries a cycle graph in memory, so cycles are reached
- * through purpose-built reads ({@see self::find_current_cycle()}, {@see self::max_count()},
+ * through purpose-built reads ({@see self::find_chain_head()}, {@see self::max_count()},
  * etc.) and written one at a time ({@see self::append_cycle()}, {@see self::update_cycle()}).
  * There is no whole-graph `save()`. Snapshots are deduped by copy-forward (reuse the
  * previous cycle's snapshot id when plan / items are unchanged), via {@see SnapshotStore}.
@@ -18,7 +18,10 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage;

+use DateTimeImmutable;
+use DateTimeZone;
 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\Support\ScalarCoercion;
@@ -51,6 +54,11 @@ final class ContractRepository {
 		'phone',
 	);

+	/**
+	 * Logger source tag.
+	 */
+	private const LOG_SOURCE = 'woocommerce-subscriptions-engine';
+
 	/**
 	 * The per-contract typed snapshot store.
 	 *
@@ -248,6 +256,95 @@ final class ContractRepository {
 		return $contracts;
 	}

+	/**
+	 * 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).
+	 * @return array<int, RenewalCandidate> Actionable renewal candidates, oldest-due first.
+	 */
+	public function find_due( DateTimeImmutable $now, int $limit ): array {
+		if ( $limit < 1 ) {
+			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' );
+
+		// 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
+		$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
+				FROM {$contracts} c
+				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
+				  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
+			),
+			ARRAY_A
+		);
+		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+		// A failed scan otherwise reads exactly like "nothing due" and renewals stall
+		// store-wide with no signal; the return stays empty either way.
+		if ( '' !== $wpdb->last_error ) {
+			wc_get_logger()->error(
+				sprintf( 'ContractRepository::find_due(): due-index scan failed - renewals may stall until this is fixed. %s', $wpdb->last_error ),
+				array( 'source' => self::LOG_SOURCE )
+			);
+		}
+
+		$result = array();
+		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
+			if ( ! is_array( $row ) ) {
+				continue;
+			}
+			$head_count = $row['head_count'] ?? null;
+			$result[]   = new RenewalCandidate(
+				ScalarCoercion::coerce_int( $row['contract_id'] ?? 0 ),
+				null === $head_count ? null : ScalarCoercion::coerce_int( $head_count ),
+				ScalarCoercion::coerce_string( $row['head_status'] ?? '' ),
+				ScalarCoercion::coerce_string( $row['head_ends_at_gmt'] ?? '' )
+			);
+		}
+
+		return $result;
+	}
+
 	/**
 	 * Whether a contract row exists for `$id`.
 	 *
@@ -302,8 +399,10 @@ final class ContractRepository {
 	 * @param Cycle      $cycle    Cycle to append. Carries its contract id and kind.
 	 * @param Cycle|null $previous The chain's previous cycle, when copy-forward of its
 	 *                             snapshot ids should be considered; null for the first cycle.
-	 * @throws \RuntimeException If a snapshot or cycle write fails (e.g. a duplicate
-	 *                          (contract_id, kind, sequence_no) the UNIQUE index rejects).
+	 * @throws DuplicateCycleException If a chain UNIQUE index rejects the row (the
+	 *                                 position is already claimed - the create-as-claim race).
+	 * @throws \RuntimeException If a snapshot write or the cycle insert fails for any
+	 *                          other reason (the database error is in the message).
 	 */
 	public function append_cycle( Cycle $cycle, ?Cycle $previous = null ): void {
 		$this->resolve_cycle_snapshots( $cycle, $previous );
@@ -343,15 +442,100 @@ final class ContractRepository {
 	}

 	/**
-	 * The chain's most-recent cycle (highest `sequence_no` in `(contract_id, kind)`),
-	 * or null when the chain is empty. Snapshots are decoded into typed value objects
-	 * only for an in-flight cycle (see {@see self::hydrate_cycle()}).
+	 * Atomically reclaim a stalled `pending` cycle whose crash-recovery lease has expired,
+	 * extending the lease by `$lease_ttl_seconds`. The compare-and-set that makes reclaim
+	 * race-safe: the predicate keys on `claimed_until <= now`, so among concurrent workers
+	 * only the first to run the UPDATE matches a row - it writes the future lease, after
+	 * which every other worker's `<=` predicate matches zero rows.
+	 *
+	 * Both the expiry predicate and the fresh lease anchor on the DATABASE clock
+	 * (`UTC_TIMESTAMP()`) - the one clock every worker shares - so a worker whose PHP clock
+	 * runs fast cannot take over a lease that is still live in real terms, and the lease it
+	 * stamps means the same thing to every other worker.
+	 *
+	 * @param int $cycle_id          The stalled cycle's id.
+	 * @param int $lease_ttl_seconds The lease window to stamp, measured from the DB's now.
+	 * @return bool True when this caller won the reclaim (exactly one row updated); false
+	 *              when another worker already reclaimed it (zero rows matched).
+	 */
+	public function reclaim_expired_cycle( int $cycle_id, int $lease_ttl_seconds ): bool {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$result = $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET claimed_until = DATE_ADD( UTC_TIMESTAMP(), INTERVAL %d SECOND ), date_updated_gmt = UTC_TIMESTAMP() WHERE id = %d AND status = %s AND claimed_until IS NOT NULL AND claimed_until <= UTC_TIMESTAMP()", $lease_ttl_seconds, $cycle_id, CycleStatus::PENDING ) );
+
+		// Exactly one row matched means this caller won the CAS; 0 means another worker
+		// already extended the lease (or the cycle settled) between read and write.
+		return false !== $result && 1 === $wpdb->rows_affected;
+	}
+
+	/**
+	 * Re-claim a failed cycle for an admin-triggered retry, as an atomic compare-and-set:
+	 * flip it back to `pending` and stamp a fresh lease only while it is still `failed`.
+	 * Returns true when this caller won (exactly one row matched), false when it was already
+	 * re-claimed or has since moved on.
+	 *
+	 * The fresh lease anchors on the DATABASE clock (`UTC_TIMESTAMP()`), the shared reference
+	 * clock for all lease arbitration.
+	 *
+	 * @param int $cycle_id          The cycle to re-claim.
+	 * @param int $lease_ttl_seconds The lease window to stamp, measured from the DB's now.
+	 */
+	public function reclaim_failed_cycle( int $cycle_id, int $lease_ttl_seconds ): bool {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$result = $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET status = %s, claimed_until = DATE_ADD( UTC_TIMESTAMP(), INTERVAL %d SECOND ), date_updated_gmt = UTC_TIMESTAMP() WHERE id = %d AND status = %s", CycleStatus::PENDING, $lease_ttl_seconds, $cycle_id, CycleStatus::FAILED ) );
+
+		return false !== $result && 1 === $wpdb->rows_affected;
+	}
+
+	/**
+	 * Settle a cycle's outcome as an atomic compare-and-set on its status: move it from
+	 * `$from_status` to `$to_status`, stamp the settling order and reason, and clear the
+	 * crash-recovery lease - all in one UPDATE gated on the row still being in `$from_status`.
+	 * Among concurrent settlers (the post-charge reconciliation and the order-status listener
+	 * 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.
+	 *
+	 * @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 int         $order_id    The renewal order carrying the outcome.
+	 * @param string|null $reason      Failure reason to record, or null to clear.
+	 */
+	public function transition_cycle_status( int $cycle_id, string $from_status, string $to_status, int $order_id, ?string $reason = null ): bool {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+		if ( null === $reason ) {
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+			$result = $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET status = %s, order_id = %d, reason = NULL, claimed_until = NULL, date_updated_gmt = %s WHERE id = %d AND status = %s", $to_status, $order_id, gmdate( 'Y-m-d H:i:s' ), $cycle_id, $from_status ) );
+		} else {
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+			$result = $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET status = %s, order_id = %d, reason = %s, claimed_until = NULL, date_updated_gmt = %s WHERE id = %d AND status = %s", $to_status, $order_id, $reason, gmdate( 'Y-m-d H:i:s' ), $cycle_id, $from_status ) );
+		}
+
+		return false !== $result && 1 === $wpdb->rows_affected;
+	}
+
+	/**
+	 * The chain's head - its most-recent cycle (highest `sequence_no` in `(contract_id,
+	 * kind)`) - or null when the chain is empty. The head is the chain's growth point, not
+	 * necessarily the cycle current by date (a forced early renewal bills a head whose
+	 * period lies ahead). Snapshots are decoded into typed value objects only for an
+	 * in-flight cycle (see {@see self::hydrate_cycle()}).
 	 *
 	 * @param int    $contract_id Contract id.
 	 * @param string $kind        Chain kind. Defaults to billing.
-	 * @return Cycle|null The most-recent cycle, or null if the chain has none.
+	 * @return Cycle|null The head cycle, or null if the chain has none.
 	 */
-	public function find_current_cycle( int $contract_id, string $kind = Cycle::KIND_BILLING ): ?Cycle {
+	public function find_chain_head( int $contract_id, string $kind = Cycle::KIND_BILLING ): ?Cycle {
 		global $wpdb;

 		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
@@ -543,7 +727,8 @@ final class ContractRepository {
 	 * Insert a cycle row and stamp the generated id back onto the entity.
 	 *
 	 * @param Cycle $cycle Cycle to insert. Carries its contract id and kind.
-	 * @throws \RuntimeException If the cycle insert fails.
+	 * @throws DuplicateCycleException If a chain UNIQUE index rejects the row.
+	 * @throws \RuntimeException If the insert fails for any other reason.
 	 */
 	private function insert_cycle( Cycle $cycle ): void {
 		global $wpdb;
@@ -563,7 +748,12 @@ final class ContractRepository {
 		);

 		if ( false === $inserted ) {
-			throw new \RuntimeException( 'Failed to insert cycle.' );
+			$db_error = $wpdb->last_error;
+			if ( false !== stripos( $db_error, 'Duplicate entry' ) ) {
+				// A chain UNIQUE index rejected the row: the position is already claimed.
+				throw new DuplicateCycleException( esc_html( 'Cycle position already exists: ' . $db_error ) );
+			}
+			throw new \RuntimeException( esc_html( 'Failed to insert cycle. ' . $db_error ) );
 		}

 		$cycle->set_id( (int) $wpdb->insert_id );
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/DuplicateCycleException.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/DuplicateCycleException.php
new file mode 100644
index 00000000000..544c6219c4d
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/DuplicateCycleException.php
@@ -0,0 +1,24 @@
+<?php
+/**
+ * DuplicateCycleException - a cycle insert rejected by the chain's UNIQUE indexes
+ * (`(contract_id, kind, count)` / `(contract_id, kind, sequence_no)`): the position is
+ * already taken. This is the expected create-as-claim race signal, distinguished from
+ * any other write failure so callers can treat the collision as benign without masking
+ * real database errors.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage;
+
+use RuntimeException;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Thrown when a cycle insert collides with an existing chain row.
+ */
+final class DuplicateCycleException extends RuntimeException {
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/RenewalCandidate.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/RenewalCandidate.php
new file mode 100644
index 00000000000..1fad9fb5604
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/RenewalCandidate.php
@@ -0,0 +1,117 @@
+<?php
+/**
+ * RenewalCandidate - a contract as a candidate for renewal: its id plus the head-cycle
+ * fields the renewal selector reads to decide what (if anything) to bill. Produced by
+ * {@see ContractRepository::find_due()} - which joins each due contract to its head cycle
+ * so the scan can filter to actionable contracts (head billed and due, or head pending
+ * with an expired lease) in SQL, keeping non-actionable rows out of the batch budget -
+ * and by {@see self::from_cycle()} on the single-contract paths (scheduled or manual),
+ * where the caller has already loaded the head.
+ *
+ * A lean read-model, not the full {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle}:
+ * it carries only the head fields selection reads, so the scan does not hydrate snapshots
+ * for a decision the money-path re-reads anyway.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * A renewal candidate: a contract plus its head-cycle fields.
+ */
+final class RenewalCandidate {
+
+	/**
+	 * Contract id.
+	 *
+	 * @var int
+	 */
+	private $contract_id;
+
+	/**
+	 * Head cycle chargeable count, or null for a countless (corrupt) head.
+	 *
+	 * @var int|null
+	 */
+	private $head_count;
+
+	/**
+	 * Head cycle status string (a {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus} value).
+	 *
+	 * @var string
+	 */
+	private $head_status;
+
+	/**
+	 * Head cycle period end (GMT string) - the moment the next cycle becomes due.
+	 *
+	 * @var string
+	 */
+	private $head_ends_at_gmt;
+
+	/**
+	 * Build a candidate from a contract and its head-cycle fields.
+	 *
+	 * @param int      $contract_id      Contract id.
+	 * @param int|null $head_count       Head chargeable count (null when the head has none).
+	 * @param string   $head_status      Head status string.
+	 * @param string   $head_ends_at_gmt Head period end (GMT string).
+	 */
+	public function __construct( int $contract_id, ?int $head_count, string $head_status, string $head_ends_at_gmt ) {
+		$this->contract_id      = $contract_id;
+		$this->head_count       = $head_count;
+		$this->head_status      = $head_status;
+		$this->head_ends_at_gmt = $head_ends_at_gmt;
+	}
+
+	/**
+	 * Build from a hydrated cycle - the single-contract path, where the caller has already loaded
+	 * the contract's head cycle (rather than joining it in the scan) and maps its fields in. The
+	 * head semantics are the caller's to uphold; this only reads the cycle it is handed.
+	 *
+	 * @param Cycle $cycle The cycle to read (the contract's head, in the single-contract path).
+	 */
+	public static function from_cycle( Cycle $cycle ): self {
+		return new self(
+			$cycle->get_contract_id(),
+			$cycle->get_count(),
+			$cycle->get_status()->get_value(),
+			$cycle->get_ends_at_gmt()
+		);
+	}
+
+	/**
+	 * Contract id.
+	 */
+	public function get_contract_id(): int {
+		return $this->contract_id;
+	}
+
+	/**
+	 * Head chargeable count, or null.
+	 */
+	public function get_head_count(): ?int {
+		return $this->head_count;
+	}
+
+	/**
+	 * Head status string.
+	 */
+	public function get_head_status(): string {
+		return $this->head_status;
+	}
+
+	/**
+	 * Head period end (GMT string).
+	 */
+	public function get_head_ends_at_gmt(): string {
+		return $this->head_ends_at_gmt;
+	}
+}
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 5a8f3b55e5d..036096ab2b0 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
@@ -34,13 +34,16 @@ final class SchemaInstaller {
 	 *         `(contract_id, kind)`; per-contract snapshots deduped by copy-forward.
 	 * 2.1.0 - rename `app_id` to `extension_slug` in plan_groups table.
 	 * 2.1.1 - add `status` and `sort_order` columns to plans table.
+	 * 2.2.0 - dispatcher columns: cycle `claimed_until` (crash-recovery lease) and
+	 *         reserved `retry_at`; a `due_contract (status, next_payment_gmt)` index on
+	 *         contracts for the batch renewal scan.
 	 *
 	 * 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.1.1';
+	private const VERSION = '2.2.0';

 	/**
 	 * Option key tracking the installed schema version.
@@ -234,12 +237,14 @@ 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` index keys the renewal scan off
-		// `next_payment_gmt`. `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 `(contract_id, kind)`. `currency` is first-class
-		// (forward-compat for multi-currency recurring; today the store base currency).
-		// `schedule_source` distinguishes engine-owned renewals from gateway-owned schedules.
+		// 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);
+		// `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
+		// `(contract_id, kind)`. `currency` is first-class (forward-compat for multi-currency
+		// recurring; today the store base currency). `schedule_source` distinguishes
+		// engine-owned renewals from gateway-owned schedules.
 		$contracts_sql = "CREATE TABLE {$contracts} (
   id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
   status VARCHAR(20) NOT NULL,
@@ -269,6 +274,7 @@ 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)
 ) {$collate};";
@@ -324,7 +330,10 @@ final class SchemaInstaller {
 		// The `due` index keys the dispatcher's due-scan in (kind, status, starts_at_gmt)
 		// order, since billing-in-advance fires at `starts_at_gmt`. `order_id` is non-1:1
 		// (an aggregate order may serve many cycles); `contract_kind` serves targeted
-		// per-chain reads (MAX(count), head).
+		// per-chain reads (MAX(count), head). `claimed_until` is the crash-recovery lease:
+		// it is stamped when a `pending` cycle is claimed and a stuck pending cycle past it
+		// is reclaimable; the create-as-claim UNIQUE remains the primary concurrency guard.
+		// `retry_at` is reserved (additive) for a later retry/dunning pass - not wired yet.
 		$cycles_sql = "CREATE TABLE {$cycles} (
   id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
   contract_id BIGINT UNSIGNED NOT NULL,
@@ -341,6 +350,8 @@ final class SchemaInstaller {
   items_snapshot_id BIGINT UNSIGNED NULL,
   order_id BIGINT UNSIGNED NULL,
   extension_slug VARCHAR(64) NULL,
+  claimed_until DATETIME NULL,
+  retry_at DATETIME NULL,
   date_created_gmt DATETIME NOT NULL,
   date_updated_gmt DATETIME NOT NULL,
   PRIMARY KEY  (id),
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
index de289b3331d..27ddf59396c 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
@@ -57,4 +57,31 @@ abstract class EngineIntegrationTestCase extends WP_UnitTestCase {

 		$this->approved_gateways[] = $gateway;
 	}
+
+	/**
+	 * Declare `recurring` for `$gateway` and wire an inline declining handler: it moves the
+	 * renewal order to `failed` (a hard decline the gateway reports synchronously), so the
+	 * money-path settles the cycle `failed` - as opposed to a gateway that leaves the order
+	 * un-paid-and-not-failed, which is treated as an async charge still awaiting confirmation.
+	 * Unhooked automatically on teardown.
+	 *
+	 * @param string $gateway Gateway id to fail charges for.
+	 */
+	protected function fail_charges_for( string $gateway ): void {
+		GatewayCapabilities::declare( $gateway, array( GatewayCapabilities::RECURRING ) );
+
+		add_action(
+			'woocommerce_subscriptions_engine_scheduled_payment_' . $gateway,
+			static function ( $amount, $renewal_order ): void {
+				unset( $amount );
+				if ( $renewal_order instanceof WC_Order ) {
+					$renewal_order->update_status( 'failed', 'Gateway declined the recurring charge.' );
+				}
+			},
+			10,
+			2
+		);
+
+		$this->approved_gateways[] = $gateway;
+	}
 }
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 1bc78f3fadf..a363b35c0da 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
@@ -119,7 +119,7 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
 		$this->assertNotNull( $contract_id );

 		$repo  = new ContractRepository();
-		$cycle = $repo->find_current_cycle( $contract_id );
+		$cycle = $repo->find_chain_head( $contract_id );

 		$this->assertInstanceOf( Cycle::class, $cycle );
 		$this->assertSame( Cycle::KIND_BILLING, $cycle->get_kind() );
@@ -202,7 +202,7 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
 		$this->assertNotNull( $contract_id );

 		// Cycle 1's period end matches that first renewal date.
-		$cycle = ( new ContractRepository() )->find_current_cycle( $contract_id );
+		$cycle = ( new ContractRepository() )->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $cycle );
 		$this->assertSame( '2026-01-29 00:00:00', $cycle->get_ends_at_gmt() );
 	}
@@ -229,7 +229,7 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
 		$contract_id = $contract->get_id();
 		$this->assertNotNull( $contract_id );

-		$cycle = ( new ContractRepository() )->find_current_cycle( $contract_id );
+		$cycle = ( new ContractRepository() )->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $cycle );
 		$this->assertSame( '49.00000000', $cycle->get_expected_total() );
 		$this->assertSame( '2026-12-01 00:00:00', $cycle->get_ends_at_gmt() );
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
new file mode 100644
index 00000000000..9a16e282de1
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php
@@ -0,0 +1,366 @@
+<?php
+/**
+ * Integration tests for the batch RenewalDispatcher.
+ *
+ * @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\Cycle;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanGroup;
+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\Ownership\ConsumerRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanGroupRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher
+ */
+class RenewalDispatcherTest extends EngineIntegrationTestCase {
+
+	/**
+	 * A gateway that always approves the scheduled charge inline (the dummy-gateway shape).
+	 */
+	private const GATEWAY_APPROVING = 'engine_dispatch_gateway_approve';
+
+	/**
+	 * The consumer slug registered to open the processing gate in charging tests.
+	 */
+	private const CONSUMER = 'engine-tests-consumer';
+
+	public function set_up(): void {
+		parent::set_up();
+		GatewayCapabilities::reset();
+		ConsumerRegistry::reset();
+	}
+
+	public function tear_down(): void {
+		// Clear any recurring scan action a scheduling test enqueued so it cannot leak between tests.
+		as_unschedule_all_actions( RenewalDispatcher::HOOK, array(), RenewalDispatcher::GROUP );
+		ConsumerRegistry::reset();
+		GatewayCapabilities::reset();
+		parent::tear_down();
+	}
+
+	/**
+	 * The scan moment used by the tests (cycle 1 ends 2026-02-15, so a renewal is due then).
+	 */
+	private function scan_now(): DateTimeImmutable {
+		return new DateTimeImmutable( '2026-02-15 00:00:00', new DateTimeZone( 'UTC' ) );
+	}
+
+	/**
+	 * Persist a monthly plan and return the entity (the ContractFactory needs the plan).
+	 */
+	private function make_plan_object(): Plan {
+		$group_id = ( new PlanGroupRepository() )->insert( PlanGroup::create( array( 'name' => 'Club' ) ) );
+
+		$plan = Plan::create(
+			$group_id,
+			array(
+				'name'           => 'Monthly',
+				'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
+				'category'       => Plan::DEFAULT_CATEGORY,
+				'extension_slug' => 'engine-tests',
+			)
+		);
+		( new PlanRepository() )->insert( $plan );
+
+		return $plan;
+	}
+
+	/**
+	 * Sign up a contract via the checkout factory so its billing chain holds cycle 1 (billed),
+	 * with its next payment due at the given date.
+	 *
+	 * @param string $gateway          Gateway id stamped on the order/contract.
+	 * @param string $next_payment_gmt The contract's next-payment date.
+	 * @return Contract The persisted contract with cycle 1 billed.
+	 */
+	private function sign_up_contract( string $gateway, string $next_payment_gmt ): Contract {
+		$plan = $this->make_plan_object();
+
+		$order = new WC_Order();
+		$order->set_currency( 'USD' );
+		$order->set_payment_method( $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 );
+
+		// The factory anchors the first renewal off the plan cadence; pin the schedule date
+		// the test reasons about so due/not-due is explicit.
+		$contract->set_next_payment_gmt( $next_payment_gmt );
+		( new ContractRepository() )->update( $contract );
+
+		return $contract;
+	}
+
+	/**
+	 * @testdox run skips the whole scan and charges nothing when no consumer is registered.
+	 */
+	public function test_run_is_gated_when_no_consumer_is_registered(): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING, '2026-02-15 00:00:00' );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		// No consumer registered: the gate is closed.
+		$this->assertTrue( ConsumerRegistry::is_empty() );
+
+		$processed = ( new RenewalDispatcher() )->run_batch( $this->scan_now() );
+
+		$this->assertSame( 0, $processed, 'A gated run processes no contracts.' );
+
+		// The contract was not advanced: still cycle 1, schedule unmoved.
+		$repo = new ContractRepository();
+		$this->assertSame( 1, $repo->max_count( $contract_id ) );
+		$reloaded = $repo->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $reloaded );
+		$this->assertSame( '2026-02-15 00:00:00', $reloaded->get_next_payment_gmt() );
+	}
+
+	/**
+	 * @testdox run renews a due contract once a consumer is registered: cycle 2 billed, schedule advanced.
+	 */
+	public function test_run_renews_a_due_contract_when_a_consumer_is_registered(): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+		ConsumerRegistry::register( self::CONSUMER );
+
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING, '2026-02-15 00:00:00' );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$processed = ( new RenewalDispatcher() )->run_batch( $this->scan_now() );
+		$this->assertSame( 1, $processed );
+
+		$repo  = new ContractRepository();
+		$cycle = $repo->find_chain_head( $contract_id );
+
+		// 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() ) );
+
+		$reloaded = $repo->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $reloaded );
+		$this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
+		$this->assertSame( ContractStatus::ACTIVE, $reloaded->get_status() );
+
+		// A renewal order was created for cycle 2.
+		$this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+	}
+
+	/**
+	 * @testdox run leaves a not-yet-due contract untouched.
+	 */
+	public function test_run_leaves_a_not_yet_due_contract_untouched(): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+		ConsumerRegistry::register( self::CONSUMER );
+
+		// Due far in the future, after the scan moment.
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING, '2026-06-15 00:00:00' );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$processed = ( new RenewalDispatcher() )->run_batch( $this->scan_now() );
+		$this->assertSame( 0, $processed );
+
+		// Untouched: still cycle 1, schedule unmoved, no renewal order.
+		$repo = new ContractRepository();
+		$this->assertSame( 1, $repo->max_count( $contract_id ) );
+		$reloaded = $repo->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $reloaded );
+		$this->assertSame( '2026-06-15 00:00:00', $reloaded->get_next_payment_gmt() );
+		$this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+	}
+
+	/**
+	 * @testdox run honours the batch limit, renewing at most a batch of due contracts per tick.
+	 */
+	public function test_run_honours_the_batch_limit(): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+		ConsumerRegistry::register( self::CONSUMER );
+
+		// Three due contracts, but a batch size of two: the third drains on the next tick.
+		$first  = $this->sign_up_contract( self::GATEWAY_APPROVING, '2026-01-20 00:00:00' );
+		$second = $this->sign_up_contract( self::GATEWAY_APPROVING, '2026-01-25 00:00:00' );
+		$third  = $this->sign_up_contract( self::GATEWAY_APPROVING, '2026-02-01 00:00:00' );
+
+		$repo       = new ContractRepository();
+		$dispatcher = new RenewalDispatcher( $repo, null );
+
+		$processed = $dispatcher->run_batch( $this->scan_now(), 2 );
+		$this->assertSame( 2, $processed, 'A single tick processes at most the batch size.' );
+
+		// The two oldest-due contracts advanced; the third is still at cycle 1.
+		$this->assertSame( 2, $repo->max_count( (int) $first->get_id() ) );
+		$this->assertSame( 2, $repo->max_count( (int) $second->get_id() ) );
+		$this->assertSame( 1, $repo->max_count( (int) $third->get_id() ) );
+
+		// The next tick drains the remaining due contract.
+		$processed_next = $dispatcher->run_batch( $this->scan_now(), 2 );
+		$this->assertSame( 1, $processed_next );
+		$this->assertSame( 2, $repo->max_count( (int) $third->get_id() ) );
+	}
+
+	/**
+	 * @testdox ensure_scheduled enqueues exactly one recurring scan action and is idempotent.
+	 */
+	public function test_ensure_scheduled_is_idempotent(): void {
+		ConsumerRegistry::register( self::CONSUMER );
+
+		// The engine schedules the recurring action (and sets its re-check option) once at
+		// bootstrap, committed before the per-test transaction. Reset both so this test starts
+		// from a clean, unscheduled slate; the rollback restores the bootstrap state afterwards.
+		as_unschedule_all_actions( RenewalDispatcher::HOOK, array(), RenewalDispatcher::GROUP );
+		delete_option( 'woocommerce_subscriptions_engine_dispatch_scheduled_check' );
+		$this->assertFalse( as_next_scheduled_action( RenewalDispatcher::HOOK, array(), RenewalDispatcher::GROUP ), 'No recurring action after the reset.' );
+
+		RenewalDispatcher::ensure_scheduled();
+		$this->assertNotFalse( as_next_scheduled_action( RenewalDispatcher::HOOK, array(), RenewalDispatcher::GROUP ) );
+
+		// A second call must not enqueue a duplicate.
+		RenewalDispatcher::ensure_scheduled();
+
+		$pending = as_get_scheduled_actions(
+			array(
+				'hook'   => RenewalDispatcher::HOOK,
+				'group'  => RenewalDispatcher::GROUP,
+				'status' => 'pending',
+			),
+			'ids'
+		);
+		$this->assertCount( 1, $pending, 'Exactly one recurring scan action is enqueued.' );
+	}
+
+	/**
+	 * @testdox ensure_scheduled enqueues nothing while no consumer is registered.
+	 *
+	 * A store without a consumer extension runs no renewals, so it carries no recurring
+	 * scan action either - the scheduling is gated exactly like the run.
+	 */
+	public function test_ensure_scheduled_skips_without_a_consumer(): void {
+		as_unschedule_all_actions( RenewalDispatcher::HOOK, array(), RenewalDispatcher::GROUP );
+		delete_option( 'woocommerce_subscriptions_engine_dispatch_scheduled_check' );
+
+		RenewalDispatcher::ensure_scheduled();
+
+		$this->assertFalse(
+			as_next_scheduled_action( RenewalDispatcher::HOOK, array(), RenewalDispatcher::GROUP ),
+			'No recurring action is enqueued while the consumer registry is empty.'
+		);
+	}
+
+	/**
+	 * @testdox ensure_scheduled removes the recurring scan once every consumer is gone.
+	 */
+	public function test_ensure_scheduled_removes_the_job_when_consumers_are_gone(): void {
+		as_unschedule_all_actions( RenewalDispatcher::HOOK, array(), RenewalDispatcher::GROUP );
+		delete_option( 'woocommerce_subscriptions_engine_dispatch_scheduled_check' );
+
+		ConsumerRegistry::register( self::CONSUMER );
+		RenewalDispatcher::ensure_scheduled();
+		$this->assertNotFalse( as_next_scheduled_action( RenewalDispatcher::HOOK, array(), RenewalDispatcher::GROUP ) );
+
+		// Every consumer deactivates: the next gated boot removes the recurring scan.
+		ConsumerRegistry::reset();
+		RenewalDispatcher::ensure_scheduled();
+		$this->assertFalse(
+			as_next_scheduled_action( RenewalDispatcher::HOOK, array(), RenewalDispatcher::GROUP ),
+			'The recurring scan does not keep ticking after the last consumer deactivates.'
+		);
+
+		// A returning consumer schedules promptly again.
+		ConsumerRegistry::register( self::CONSUMER );
+		RenewalDispatcher::ensure_scheduled();
+		$this->assertNotFalse( as_next_scheduled_action( RenewalDispatcher::HOOK, array(), RenewalDispatcher::GROUP ) );
+	}
+
+	/**
+	 * @testdox run_batch is a no-op for a non-positive limit.
+	 */
+	public function test_run_batch_returns_zero_for_a_non_positive_limit(): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+		ConsumerRegistry::register( self::CONSUMER );
+
+		$contract = $this->sign_up_contract( self::GATEWAY_APPROVING, '2026-01-20 00:00:00' );
+
+		$dispatcher = new RenewalDispatcher();
+		$this->assertSame( 0, $dispatcher->run_batch( $this->scan_now(), 0 ) );
+		$this->assertSame( 0, $dispatcher->run_batch( $this->scan_now(), -5 ) );
+
+		// Nothing was renewed by the no-op ticks.
+		$this->assertSame( 1, ( new ContractRepository() )->max_count( (int) $contract->get_id() ) );
+	}
+
+	/**
+	 * @testdox handle_tick routes the Action Scheduler dispatch through run(), advancing a due renewal.
+	 */
+	public function test_handle_tick_routes_through_run(): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+		ConsumerRegistry::register( self::CONSUMER );
+
+		// Due in the past relative to the real dispatch moment, so the tick picks it up.
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING, '2026-02-15 00:00:00' );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		// Drive the Action Scheduler entry point (uses the real "now").
+		( new RenewalDispatcher() )->handle_tick();
+
+		// The dispatch reached the money-path: cycle 2 billed.
+		$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() ) );
+	}
+
+	/**
+	 * Renewal orders tagged for a contract at a given chargeable number, narrowed in PHP.
+	 *
+	 * @param int $contract_id Contract id.
+	 * @param int $count       Chargeable number.
+	 * @return array<int, WC_Order>
+	 */
+	private function renewal_orders_for_cycle( int $contract_id, int $count ): array {
+		$orders = wc_get_orders(
+			array(
+				'limit'      => -1,
+				'type'       => 'shop_order',
+				'status'     => 'any',
+				'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
+			)
+		);
+
+		return array_values(
+			array_filter(
+				is_array( $orders ) ? $orders : array(),
+				static function ( $order ) use ( $count ) {
+					return $order instanceof WC_Order
+						&& OrderLinkage::RELATION_RENEWAL === $order->get_meta( OrderLinkage::META_RELATION_TYPE )
+						&& (string) $count === $order->get_meta( '_subscription_renewal_cycle' );
+				}
+			)
+		);
+	}
+}
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 49c882c98eb..7cb95c4d2b7 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
@@ -1,6 +1,6 @@
 <?php
 /**
- * Integration tests for RenewalEngine (and RenewalScheduler).
+ * Integration tests for RenewalEngine.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine
  */
@@ -22,15 +22,16 @@ 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\Ownership\ConsumerRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalScheduler;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalIntent;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanGroupRepository;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalScheduler
  */
 class RenewalEngineTest extends EngineIntegrationTestCase {

@@ -43,21 +44,68 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 	private const GATEWAY_APPROVING = 'engine_test_gateway_approve';

 	/**
-	 * A gateway that declares `recurring` but never completes the charge, so the
-	 * renewal order stays unpaid (the failed-charge path).
+	 * A gateway that declares `recurring` and reports a hard decline synchronously,
+	 * moving the renewal order to `failed` (the failed-charge path).
 	 */
 	private const GATEWAY_DECLINING = 'engine_test_gateway_decline';

+	/**
+	 * A gateway that declares `recurring` but registers no handler, so the renewal order
+	 * stays `pending` (uncharged) - the async-pending shape: neither paid nor failed, which
+	 * the money-path parks in `processing` awaiting a later confirmation.
+	 */
+	private const GATEWAY_PENDING = 'engine_test_gateway_pending';
+
 	public function set_up(): void {
 		parent::set_up();
 		GatewayCapabilities::reset();
+		ConsumerRegistry::reset();
 	}

 	public function tear_down(): void {
+		ConsumerRegistry::reset();
 		GatewayCapabilities::reset();
 		parent::tear_down();
 	}

+	/**
+	 * Drive one scheduled scan tick over the batch dispatcher - the production path for
+	 * scheduled renewals - and report what it did to `$contract_id`: the head cycle's
+	 * renewal order when the tick touched the contract's chain, or null when it did not
+	 * (not due, excluded by the scan, or nothing changed).
+	 *
+	 * @param int                     $contract_id The contract under test.
+	 * @param \DateTimeImmutable|null $now         The scan moment; defaults to now (UTC).
+	 */
+	private function run_scheduled_renewal( int $contract_id, ?\DateTimeImmutable $now = null ): ?WC_Order {
+		ConsumerRegistry::register( 'engine-tests' );
+
+		$repo   = new ContractRepository();
+		$before = $repo->find_chain_head( $contract_id );
+
+		( new RenewalDispatcher() )->run_batch( $now, 50 );
+
+		$after = $repo->find_chain_head( $contract_id );
+		if ( null === $after ) {
+			return null;
+		}
+
+		$untouched = null !== $before
+			&& $before->get_sequence_no() === $after->get_sequence_no()
+			&& $before->get_status()->get_value() === $after->get_status()->get_value()
+			&& $before->get_order_id() === $after->get_order_id()
+			&& $before->get_claimed_until_gmt() === $after->get_claimed_until_gmt();
+
+		if ( $untouched ) {
+			return null;
+		}
+
+		$order_id = $after->get_order_id();
+		$order    = null === $order_id ? false : wc_get_order( $order_id );
+
+		return $order instanceof WC_Order ? $order : null;
+	}
+
 	private function make_plan( ?int $max_cycles = null ): int {
 		return (int) $this->make_plan_object( $max_cycles )->get_id();
 	}
@@ -137,66 +185,16 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox schedule is gated on the gateway's recurring capability.
-	 */
-	public function test_schedule_is_gated_on_recurring_capability(): void {
-		$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 );
-
-		$engine = new RenewalEngine();
-
-		// No capability declared: scheduling is refused.
-		$this->assertFalse( $engine->schedule( $contract ) );
-		$this->assertFalse( RenewalScheduler::is_scheduled( $contract_id ) );
-
-		// Declare it and the schedule sticks.
-		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
-		$this->assertTrue( $engine->schedule( $contract ) );
-		$this->assertTrue( RenewalScheduler::is_scheduled( $contract_id ) );
-	}
-
-	/**
-	 * @testdox schedule replaces any existing pending row (one row per contract).
+	 * @testdox the scheduled scan creates a renewal order tagged for the next chargeable number.
 	 */
-	public function test_schedule_replaces_existing_row(): void {
-		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
-
-		$plan_id     = $this->make_plan();
-		$order       = $this->make_origin_order();
-		$contract    = $this->make_contract( $plan_id, $order->get_id() );
-		$contract_id = $contract->get_id();
-		$this->assertNotNull( $contract_id );
-
-		$engine = new RenewalEngine();
-		$engine->schedule( $contract );
-		$engine->schedule( $contract );
-
-		// Exactly one pending row for the contract.
-		$pending = as_get_scheduled_actions(
-			array(
-				'hook'   => RenewalScheduler::HOOK,
-				'args'   => array( $contract_id ),
-				'status' => \ActionScheduler_Store::STATUS_PENDING,
-			),
-			'ids'
-		);
-		$this->assertCount( 1, $pending );
-	}
-
-	/**
-	 * @testdox process_due creates a renewal order tagged for the next chargeable number.
-	 */
-	public function test_process_due_creates_renewal_order(): void {
+	public function test_scheduled_renewal_creates_renewal_order(): void {
 		$this->approve_charges_for( self::GATEWAY_APPROVING );

 		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
 		$contract_id = $contract->get_id();
 		$this->assertNotNull( $contract_id );

-		$renewal_order = ( new RenewalEngine() )->process_due( $contract_id );
+		$renewal_order = $this->run_scheduled_renewal( $contract_id );

 		// The renewal order is created and tagged with the renewal relation + chargeable number.
 		$this->assertInstanceOf( WC_Order::class, $renewal_order );
@@ -209,39 +207,37 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox process_due skips when a renewal order is already tagged for the cycle.
+	 * @testdox the scheduled scan does not re-select a failed cycle (no second order).
 	 *
-	 * Covers the order-meta pre-check: the first run tags a renewal order for the cycle, so
-	 * the retried due action is suppressed before a second cycle/order is created.
+	 * A declined charge settles cycle 2 `failed`. A `failed` head is not selectable (dunning is
+	 * deferred), so a retried run is a no-op - no second cycle, no second order for the number.
 	 */
-	public function test_process_due_skips_when_a_renewal_order_is_already_tagged(): void {
-		GatewayCapabilities::declare( self::GATEWAY_DECLINING, array( GatewayCapabilities::RECURRING ) );
+	public function test_scheduled_renewal_does_not_reselect_a_failed_cycle(): void {
+		$this->fail_charges_for( self::GATEWAY_DECLINING );

 		$contract    = $this->sign_up_contract( self::GATEWAY_DECLINING );
 		$contract_id = $contract->get_id();
 		$this->assertNotNull( $contract_id );
-		$engine = new RenewalEngine();

-		$first = $engine->process_due( $contract_id );
+		$first = $this->run_scheduled_renewal( $contract_id );
 		$this->assertInstanceOf( WC_Order::class, $first );

-		// The charge did not settle, so the head stays at the same chargeable number; a retried
-		// due action is suppressed by the order-meta pre-check rather than creating a second order.
-		$retry = $engine->process_due( $contract_id );
+		// Cycle 2 settled failed; a retried run does not re-select it (no duplicate order).
+		$retry = $this->run_scheduled_renewal( $contract_id );
 		$this->assertNull( $retry );

 		$this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
 	}

 	/**
-	 * @testdox process_due skips when the cycle is already claimed (create-as-claim UNIQUE).
+	 * @testdox the scheduled scan skips when the head cycle is a live pending claim.
 	 *
-	 * Covers the claim_next_cycle catch path: a pending cycle for the target count exists with
-	 * NO tagged renewal order, so the order-meta pre-check finds nothing and process_due reaches
-	 * the append_cycle insert, which loses the UNIQUE(contract_id, kind, count) race. It returns
-	 * null and creates no duplicate cycle or order.
+	 * A pending cycle for the target count already exists with no lease recorded (a live claim,
+	 * not a crashed one). Selection targets that same count, and the money-path's reclaim step
+	 * finds a non-reclaimable pending head, so it returns null and creates no duplicate cycle or
+	 * order.
 	 */
-	public function test_process_due_skips_when_the_cycle_is_already_claimed(): void {
+	public function test_scheduled_renewal_skips_when_the_cycle_is_already_claimed(): void {
 		$this->approve_charges_for( self::GATEWAY_APPROVING );

 		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
@@ -251,7 +247,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		// Pre-claim cycle 2 pending directly (no tagged renewal order), so the order-meta
 		// pre-check does not fire and the claim collides on the UNIQUE index instead.
 		$repo     = new ContractRepository();
-		$previous = $repo->find_current_cycle( $contract_id );
+		$previous = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $previous );
 		$claimed = Cycle::create(
 			array(
@@ -267,7 +263,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		);
 		$repo->append_cycle( $claimed, $previous );

-		$result = ( new RenewalEngine() )->process_due( $contract_id );
+		$result = $this->run_scheduled_renewal( $contract_id );
 		$this->assertNull( $result );

 		// No renewal order was created for count 2, and only the one pre-claimed cycle exists.
@@ -283,16 +279,107 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox process_due advances the chain: cycle 2 billed, order linked, schedule moved.
+	 * @testdox the scheduled scan reclaims a stalled pending cycle whose crash-recovery lease has expired.
+	 *
+	 * A charge that claimed cycle 2 pending then crashed before settling (no renewal order
+	 * tagged) leaves a stuck pending cycle. Once its `claimed_until` lease has expired, a
+	 * later run reclaims that same cycle (re-stamping the lease), charges it, and settles it
+	 * billed - no duplicate cycle is created.
+	 */
+	public function test_scheduled_renewal_reclaims_a_stalled_cycle_with_an_expired_lease(): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		// Seed a stalled pending cycle 2 whose lease expired a minute before the run (no tagged order).
+		$now      = new \DateTimeImmutable( '2026-02-15 00:05:00', new \DateTimeZone( 'UTC' ) );
+		$repo     = new ContractRepository();
+		$previous = $repo->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $previous );
+		$stalled    = $this->make_pending_cycle_2( $contract_id, $previous, $now->modify( '-60 seconds' )->format( 'Y-m-d H:i:s' ) );
+		$stalled_id = $stalled->get_id();
+		$this->assertNotNull( $stalled_id );
+
+		$renewal_order = $this->run_scheduled_renewal( $contract_id, $now );
+
+		// The reclaimed cycle is billed and resolved through a renewal order.
+		$this->assertInstanceOf( WC_Order::class, $renewal_order );
+		$this->assertTrue( $renewal_order->is_paid() );
+		$this->assertSame( '2', $renewal_order->get_meta( '_subscription_renewal_cycle' ) );
+
+		$head = $repo->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $head );
+		// 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->assertNull( $head->get_claimed_until_gmt() );
+
+		// The schedule advances to the RECLAIMED cycle's own end (2026-03-15), not a
+		// recomputed next period (2026-04-15) - a reclaim must not skip a billing cycle.
+		$reloaded = $repo->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $reloaded );
+		$this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
+		$this->assertNotNull( $reloaded->get_last_payment_gmt() );
+
+		// Exactly one billing cycle for count 2 (no duplicate claimed).
+		$at_count_2 = array_filter(
+			$repo->find_cycle_history( $contract_id ),
+			static function ( Cycle $cycle ): bool {
+				return 2 === $cycle->get_count();
+			}
+		);
+		$this->assertCount( 1, $at_count_2 );
+	}
+
+	/**
+	 * @testdox the scheduled scan leaves a pending cycle alone while its crash-recovery lease is live.
+	 *
+	 * A pending cycle 2 with a still-live `claimed_until` lease is an active claim (a
+	 * concurrent worker), so a second run skips it: no charge, no duplicate, and the live
+	 * lease is left untouched.
 	 */
-	public function test_process_due_advances_the_chain_on_a_successful_charge(): void {
+	public function test_scheduled_renewal_leaves_a_live_leased_cycle_alone(): void {
 		$this->approve_charges_for( self::GATEWAY_APPROVING );

 		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
 		$contract_id = $contract->get_id();
 		$this->assertNotNull( $contract_id );

-		$renewal_order = ( new RenewalEngine() )->process_due( $contract_id );
+		// Seed a pending cycle 2 whose lease is still an hour in the future (a live claim).
+		$now      = new \DateTimeImmutable( '2026-02-15 00:05:00', new \DateTimeZone( 'UTC' ) );
+		$repo     = new ContractRepository();
+		$previous = $repo->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $previous );
+		$live_until = $now->modify( '+1 hour' )->format( 'Y-m-d H:i:s' );
+		$claimed    = $this->make_pending_cycle_2( $contract_id, $previous, $live_until );
+
+		$result = $this->run_scheduled_renewal( $contract_id, $now );
+		$this->assertNull( $result );
+
+		// No renewal order for count 2, and the seeded cycle is untouched (still pending, lease intact).
+		$this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+
+		$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->assertSame( $live_until, $head->get_claimed_until_gmt() );
+	}
+
+	/**
+	 * @testdox the scheduled scan advances the chain: cycle 2 billed, order linked, schedule moved.
+	 */
+	public function test_scheduled_renewal_advances_the_chain_on_a_successful_charge(): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$renewal_order = $this->run_scheduled_renewal( $contract_id );

 		// The renewal order bills the new cycle's expected_total (carried forward from
 		// cycle 1's recurring amount) and is paid by the approving gateway.
@@ -301,7 +388,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$this->assertSame( '2', $renewal_order->get_meta( '_subscription_renewal_cycle' ) );

 		$repo  = new ContractRepository();
-		$cycle = $repo->find_current_cycle( $contract_id );
+		$cycle = $repo->find_chain_head( $contract_id );

 		// Cycle 2 exists, billed, count 2, linked to the renewal order, refs carried forward.
 		$this->assertInstanceOf( Cycle::class, $cycle );
@@ -321,9 +408,9 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox process_due builds the renewal order from the contract's own line items and addresses.
+	 * @testdox the scheduled scan builds the renewal order from the contract's own line items and addresses.
 	 */
-	public function test_process_due_builds_renewal_from_contract_items_and_addresses(): void {
+	public function test_scheduled_renewal_builds_renewal_from_contract_items_and_addresses(): void {
 		$this->approve_charges_for( self::GATEWAY_APPROVING );

 		$product = new \WC_Product_Simple();
@@ -366,7 +453,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$contract_id = $contract->get_id();
 		$this->assertNotNull( $contract_id );

-		$renewal_order = ( new RenewalEngine() )->process_due( $contract_id );
+		$renewal_order = $this->run_scheduled_renewal( $contract_id );
 		$this->assertInstanceOf( WC_Order::class, $renewal_order );

 		// Exactly the contract's recurring line item, carried from the contract.
@@ -385,23 +472,23 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox process_due on a failed charge marks the cycle failed and leaves the schedule.
+	 * @testdox the scheduled scan on a declined charge marks the cycle failed and leaves the schedule.
 	 */
-	public function test_process_due_marks_the_cycle_failed_when_the_charge_does_not_settle(): void {
-		// Declared recurring, but no handler completes the charge: the order stays unpaid.
-		GatewayCapabilities::declare( self::GATEWAY_DECLINING, array( GatewayCapabilities::RECURRING ) );
+	public function test_scheduled_renewal_marks_the_cycle_failed_when_the_gateway_declines(): void {
+		// The gateway reports a hard decline synchronously: the renewal order moves to failed.
+		$this->fail_charges_for( self::GATEWAY_DECLINING );

 		$contract    = $this->sign_up_contract( self::GATEWAY_DECLINING );
 		$contract_id = $contract->get_id();
 		$this->assertNotNull( $contract_id );

-		$renewal_order = ( new RenewalEngine() )->process_due( $contract_id );
+		$renewal_order = $this->run_scheduled_renewal( $contract_id );

 		$this->assertInstanceOf( WC_Order::class, $renewal_order );
 		$this->assertFalse( $renewal_order->is_paid() );

 		$repo  = new ContractRepository();
-		$cycle = $repo->find_current_cycle( $contract_id );
+		$cycle = $repo->find_chain_head( $contract_id );

 		// Cycle 2 exists and failed, but the renewal order is recorded on it even though the
 		// charge did not settle (for dunning + admin visibility).
@@ -422,27 +509,26 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox process_due retry of an unsettled cycle adds no duplicate cycle/order.
+	 * @testdox the scheduled scan retry of a failed cycle adds no duplicate cycle/order.
 	 *
-	 * A failed charge leaves cycle 2 `failed` with its order (and the schedule unchanged).
-	 * Re-firing targets the same count, so the order-meta pre-check makes it an idempotent
-	 * no-op: no second order, no second cycle for count 2. (Forward advancement after a
-	 * SUCCESSFUL bill is a distinct renewal, not a retry.)
+	 * A declined charge leaves cycle 2 `failed` with its order (and the schedule unchanged).
+	 * A `failed` head is not selectable, so re-firing is an idempotent no-op: no second order,
+	 * no second cycle for count 2. (Forward advancement after a SUCCESSFUL bill is a distinct
+	 * renewal, not a retry.)
 	 */
-	public function test_process_due_retry_of_an_unsettled_cycle_is_idempotent(): void {
-		GatewayCapabilities::declare( self::GATEWAY_DECLINING, array( GatewayCapabilities::RECURRING ) );
+	public function test_scheduled_renewal_retry_of_a_failed_cycle_is_idempotent(): void {
+		$this->fail_charges_for( self::GATEWAY_DECLINING );

 		$contract    = $this->sign_up_contract( self::GATEWAY_DECLINING );
 		$contract_id = $contract->get_id();
 		$this->assertNotNull( $contract_id );

-		$engine = new RenewalEngine();
-		$first  = $engine->process_due( $contract_id );
+		$first = $this->run_scheduled_renewal( $contract_id );
 		$this->assertInstanceOf( WC_Order::class, $first );
 		$this->assertFalse( $first->is_paid() );

-		// A retry while cycle 2 is unsettled (failed) creates no duplicate.
-		$second = $engine->process_due( $contract_id );
+		// A retry while cycle 2 is failed creates no duplicate.
+		$second = $this->run_scheduled_renewal( $contract_id );
 		$this->assertNull( $second );

 		$this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
@@ -459,13 +545,13 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox process_due falls back to the live plan cadence when the cycle carries no snapshot.
+	 * @testdox the scheduled scan falls back to the live plan cadence when the cycle carries no snapshot.
 	 *
-	 * After cycle 1 is billed (terminal) find_current_cycle() hydrates it WITHOUT its snapshot
+	 * After cycle 1 is billed (terminal) find_chain_head() hydrates it WITHOUT its snapshot
 	 * value objects, so resolve_plan_snapshot() rebuilds the cadence from the live selling plan.
 	 * The renewal advances normally on that fallback.
 	 */
-	public function test_process_due_falls_back_to_live_plan_when_cycle_has_no_snapshot(): void {
+	public function test_scheduled_renewal_falls_back_to_live_plan_when_cycle_has_no_snapshot(): void {
 		$this->approve_charges_for( self::GATEWAY_APPROVING );

 		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
@@ -475,11 +561,11 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		// Precondition: the billed head cycle carries no in-memory plan snapshot, so the
 		// money-path must use the live-plan fallback to know the cadence.
 		$repo = new ContractRepository();
-		$head = $repo->find_current_cycle( $contract_id );
+		$head = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $head );
 		$this->assertNull( $head->get_plan_snapshot() );

-		$renewal_order = ( new RenewalEngine() )->process_due( $contract_id );
+		$renewal_order = $this->run_scheduled_renewal( $contract_id );
 		$this->assertInstanceOf( WC_Order::class, $renewal_order );

 		// Advanced one monthly cadence from the live plan (cycle 1 ended 2026-02-15).
@@ -487,45 +573,195 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$this->assertInstanceOf( Contract::class, $reloaded );
 		$this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );

-		$cycle = $repo->find_current_cycle( $contract_id );
+		$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() ) );
 	}

 	/**
-	 * @testdox process_due skips a contract that has no billing chain to advance.
+	 * @testdox the scheduled scan never surfaces a contract with no billing chain.
 	 *
 	 * Checkout always creates cycle 1, so a chainless (lean / manual) contract is a case the
-	 * engine does not renew. process_due must skip (return null) rather than silently bill it as
-	 * cycle 1 or throw - a thrown error would make a scheduled action retry forever.
+	 * engine does not renew. The cycle-aware scan excludes it in SQL (no head cycle to join),
+	 * so a tick claims nothing and bills nothing for it and its schedule is left untouched -
+	 * the exclusion, not a park, is what keeps it out of the batch budget.
 	 */
-	public function test_process_due_skips_a_contract_with_no_billing_chain(): void {
+	public function test_scheduled_renewal_ignores_a_contract_with_no_billing_chain(): void {
 		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );

-		// A lean contract is persisted with no cycle chain.
+		// A lean contract is persisted with no cycle chain (but with a due date).
 		$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 );

-		$result = ( new RenewalEngine() )->process_due( $contract_id );
+		$result = $this->run_scheduled_renewal( $contract_id );
 		$this->assertNull( $result );

 		// Nothing was claimed: no cycle, no renewal order.
 		$repo = new ContractRepository();
 		$this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 1 ) );
-		$this->assertNull( $repo->find_current_cycle( $contract_id ) );
+		$this->assertNull( $repo->find_chain_head( $contract_id ) );
+
+		// Not parked - never selected: the schedule stays as it was.
+		$reloaded = $repo->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $reloaded );
+		$this->assertSame( '2026-02-15 00:00:00', $reloaded->get_next_payment_gmt(), 'The scan exclusion leaves the schedule untouched.' );
+	}
+
+	/**
+	 * @testdox the scheduled scan parks a contract whose plan resolves to nothing (no snapshot, deleted live plan).
+	 *
+	 * When neither the contract's plan snapshot nor the live selling plan resolves, the renewal
+	 * cannot bill. The dispatcher catches the pre-flight impossibility and parks the contract
+	 * (clears the schedule) so it leaves the due set instead of holding the front of the scan forever.
+	 */
+	public function test_scheduled_renewal_parks_a_contract_whose_plan_is_unresolvable(): void {
+		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
+
+		$plan_id     = $this->make_plan();
+		$order       = $this->make_origin_order();
+		$contract    = $this->make_contract( $plan_id, $order->get_id() );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		// Give it a billing chain (so it passes the chainless guard) but no plan snapshot...
+		$repo = new ContractRepository();
+		$repo->append_cycle(
+			Cycle::create(
+				array(
+					'contract_id'    => $contract_id,
+					'sequence_no'    => 1,
+					'count'          => 1,
+					'status'         => CycleStatus::billed(),
+					'starts_at_gmt'  => '2026-01-15 00:00:00',
+					'ends_at_gmt'    => '2026-02-15 00:00:00',
+					'expected_total' => '19.99',
+					'currency'       => 'USD',
+				)
+			)
+		);
+
+		// ...then delete the live plan, so neither the snapshot nor the live plan resolves.
+		( new PlanRepository() )->delete( $plan_id );
+
+		$result = $this->run_scheduled_renewal( $contract_id );
+		$this->assertNull( $result );
+
+		// No cycle 2 claimed, and the contract is parked out of the due set.
+		$this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+		$reloaded = $repo->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $reloaded );
+		$this->assertNull( $reloaded->get_next_payment_gmt() );
 	}

 	/**
-	 * @testdox process_due renews from the contract's own plan snapshot even when the live plan is deleted.
+	 * @testdox the scheduled scan resumes a stalled renewal whose order was saved but never charged.
+	 *
+	 * A run that claimed cycle 2 pending and saved its renewal order, then crashed before the
+	 * charge, leaves a stalled pending cycle (expired lease) plus an unpaid, un-tokenised order.
+	 * The idempotency pre-check would once have skipped forever on the existing order; instead the
+	 * run must resume that SAME order - charge it, settle the cycle billed, advance the schedule.
+	 */
+	public function test_scheduled_renewal_resumes_a_stalled_renewal_with_an_unpaid_order(): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$repo     = new ContractRepository();
+		$previous = $repo->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $previous );
+
+		// A crashed attempt: cycle 2 pending with an expired lease, plus its saved-but-uncharged order.
+		$stalled    = $this->make_pending_cycle_2( $contract_id, $previous, gmdate( 'Y-m-d H:i:s', time() - 60 ) );
+		$stalled_id = $stalled->get_id();
+		$this->assertNotNull( $stalled_id );
+		$ghost = $this->make_ghost_renewal_order( $contract_id, 2, false );
+
+		$resumed = $this->run_scheduled_renewal( $contract_id );
+
+		// The SAME order is resumed and charged - no second order for the number.
+		$this->assertInstanceOf( WC_Order::class, $resumed );
+		$this->assertSame( $ghost->get_id(), $resumed->get_id() );
+		$paid_order = wc_get_order( $ghost->get_id() );
+		$this->assertInstanceOf( WC_Order::class, $paid_order );
+		$this->assertTrue( $paid_order->is_paid() );
+		$this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+
+		// The stalled cycle is reclaimed (same row) and settled billed, lease cleared.
+		$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->assertNull( $head->get_claimed_until_gmt() );
+
+		// The schedule advances to the reclaimed cycle's own end - one cadence, not skipped.
+		$reloaded = $repo->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $reloaded );
+		$this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
+	}
+
+	/**
+	 * @testdox the scheduled scan settles a stalled renewal whose order was already paid, without re-charging.
+	 *
+	 * A crash AFTER the gateway was paid but before the cycle settled leaves a paid renewal order
+	 * on a stalled pending cycle. Recovery must settle it from that paid state and never fire a
+	 * second charge, which could double-charge the customer.
+	 */
+	public function test_scheduled_renewal_settles_a_stalled_renewal_with_a_paid_order_without_recharging(): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$repo     = new ContractRepository();
+		$previous = $repo->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $previous );
+
+		$this->make_pending_cycle_2( $contract_id, $previous, gmdate( 'Y-m-d H:i:s', time() - 60 ) );
+		$ghost = $this->make_ghost_renewal_order( $contract_id, 2, true );
+
+		// Spy on the charge hook (before the approving handler): an already-paid order must not be charged.
+		$charge_attempts = 0;
+		add_action(
+			'woocommerce_subscriptions_engine_scheduled_payment_' . self::GATEWAY_APPROVING,
+			static function ( $amount, $order ) use ( &$charge_attempts ): void {
+				unset( $amount, $order );
+				++$charge_attempts;
+			},
+			5,
+			2
+		);
+
+		$resumed = $this->run_scheduled_renewal( $contract_id );
+
+		$this->assertInstanceOf( WC_Order::class, $resumed );
+		$this->assertSame( $ghost->get_id(), $resumed->get_id() );
+		$this->assertSame( 0, $charge_attempts, 'An already-paid order must not be charged again.' );
+
+		// The cycle is settled billed from the paid state and the schedule advances one cadence.
+		$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() ) );
+
+		$reloaded = $repo->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $reloaded );
+		$this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
+	}
+
+	/**
+	 * @testdox the scheduled scan renews from the contract's own plan snapshot even when the live plan is deleted.
 	 *
 	 * The contract's frozen snapshot is the cadence source of truth, so a deleted live selling
 	 * plan no longer blocks the renewal - the chain advances on the snapshot's terms.
 	 */
-	public function test_process_due_renews_from_contract_snapshot_when_live_plan_deleted(): void {
+	public function test_scheduled_renewal_renews_from_contract_snapshot_when_live_plan_deleted(): void {
 		$this->approve_charges_for( self::GATEWAY_APPROVING );

 		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
@@ -535,27 +771,27 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		// Delete the live selling plan; the contract keeps its frozen snapshot.
 		( new PlanRepository() )->delete( $contract->get_selling_plan_id() );

-		$renewal_order = ( new RenewalEngine() )->process_due( $contract_id );
+		$renewal_order = $this->run_scheduled_renewal( $contract_id );
 		$this->assertInstanceOf( WC_Order::class, $renewal_order );

 		// Cycle 2 was billed from the snapshot's cadence.
-		$cycle = ( new ContractRepository() )->find_current_cycle( $contract_id );
+		$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() ) );
 	}

 	/**
-	 * @testdox process_due expires the contract when it hits max cycles.
+	 * @testdox the scheduled scan expires the contract when it hits max cycles.
 	 */
-	public function test_process_due_expires_contract_at_max_cycles(): void {
+	public function test_scheduled_renewal_expires_contract_at_max_cycles(): void {
 		$this->markTestSkipped( 'Max-cycle expiry lands with the dispatcher.' );
 	}

 	/**
-	 * @testdox process_due skips a non-active contract and creates no renewal order.
+	 * @testdox the scheduled scan skips a non-active contract and creates no renewal order.
 	 */
-	public function test_process_due_skips_non_active_contract(): void {
+	public function test_scheduled_renewal_skips_non_active_contract(): void {
 		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );

 		$plan_id     = $this->make_plan();
@@ -566,15 +802,15 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$contract->set_status( ContractStatus::ON_HOLD );
 		( new ContractRepository() )->update( $contract );

-		$this->assertNull( ( new RenewalEngine() )->process_due( $contract_id ) );
+		$this->assertNull( $this->run_scheduled_renewal( $contract_id ) );

 		$this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 1 ) );
 	}

 	/**
-	 * @testdox process_due skips a gateway-scheduled contract and creates no renewal order.
+	 * @testdox the scheduled scan skips a gateway-scheduled contract and creates no renewal order.
 	 */
-	public function test_process_due_skips_gateway_scheduled_contract(): void {
+	public function test_scheduled_renewal_skips_gateway_scheduled_contract(): void {
 		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );

 		$plan_id     = $this->make_plan();
@@ -594,21 +830,24 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$contract_id = ( new ContractRepository() )->insert( $contract );

 		// Active, but the gateway owns the schedule: the primitive path bails.
-		$this->assertNull( ( new RenewalEngine() )->process_due( $contract_id ) );
+		$this->assertNull( $this->run_scheduled_renewal( $contract_id ) );
 		$this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 1 ) );
 	}

 	/**
-	 * @testdox process_due skips an unknown contract.
+	 * @testdox the scheduled scan skips an unknown contract.
 	 */
-	public function test_process_due_skips_unknown_contract(): void {
-		$this->assertNull( ( new RenewalEngine() )->process_due( 999999 ) );
+	public function test_scheduled_renewal_skips_unknown_contract(): void {
+		$this->assertNull( $this->run_scheduled_renewal( 999999 ) );
 	}

 	/**
-	 * @testdox cancel transitions the contract to cancelled and clears its pending row.
+	 * @testdox cancel transitions the contract to cancelled.
+	 *
+	 * The due scan only selects active contracts, so cancellation needs no schedule
+	 * cleanup - the status transition alone removes the contract from renewal.
 	 */
-	public function test_cancel_transitions_and_unschedules(): void {
+	public function test_cancel_transitions_the_contract(): void {
 		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );

 		$plan_id     = $this->make_plan();
@@ -617,16 +856,11 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$contract_id = $contract->get_id();
 		$this->assertNotNull( $contract_id );

-		$engine = new RenewalEngine();
-		$engine->schedule( $contract );
-		$this->assertTrue( RenewalScheduler::is_scheduled( $contract_id ) );
-
 		$this->assertTrue( ( new Cancellation() )->cancel( $contract ) );

 		$reloaded = ( new ContractRepository() )->find( $contract_id );
 		$this->assertInstanceOf( Contract::class, $reloaded );
 		$this->assertSame( ContractStatus::CANCELLED, $reloaded->get_status() );
-		$this->assertFalse( RenewalScheduler::is_scheduled( $contract_id ) );
 	}

 	/**
@@ -639,7 +873,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {

 		// Append a pending cycle 2 (a charge caught mid-flight).
 		$repo     = new ContractRepository();
-		$previous = $repo->find_current_cycle( $contract_id );
+		$previous = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $previous );
 		$pending = Cycle::create(
 			array(
@@ -662,7 +896,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$this->assertInstanceOf( Contract::class, $reloaded );
 		$this->assertSame( ContractStatus::CANCELLED, $reloaded->get_status() );

-		$head = $repo->find_current_cycle( $contract_id );
+		$head = $repo->find_chain_head( $contract_id );
 		$this->assertInstanceOf( Cycle::class, $head );
 		$this->assertTrue( $head->get_status()->equals( CycleStatus::cancelled() ) );
 	}
@@ -678,35 +912,641 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$this->assertTrue( ( new Cancellation() )->cancel( $contract ) );

 		// Cycle 1 stays billed (only a pending head is closed by cancel).
-		$head = ( new ContractRepository() )->find_current_cycle( $contract_id );
+		$head = ( new ContractRepository() )->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $head );
+		$this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+	}
+
+
+	/**
+	 * @testdox the scheduled scan leaves the cycle processing when the charge is neither paid nor failed.
+	 *
+	 * A gateway that accepts the charge but confirms later (async) leaves the renewal order
+	 * neither paid nor failed. The money-path settles the cycle `processing` - awaiting the
+	 * gateway - without advancing the schedule or treating it as a failure. A `processing` head
+	 * is not re-selected, so a later run is a no-op until the order settles.
+	 */
+	public function test_scheduled_renewal_leaves_the_cycle_processing_when_the_charge_is_pending(): void {
+		// Declared recurring, but no handler settles the order: it stays pending (async-accepted shape).
+		GatewayCapabilities::declare( self::GATEWAY_PENDING, array( GatewayCapabilities::RECURRING ) );
+
+		$contract    = $this->sign_up_contract( self::GATEWAY_PENDING );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$engine        = new RenewalEngine();
+		$renewal_order = $this->run_scheduled_renewal( $contract_id );
+
+		$this->assertInstanceOf( WC_Order::class, $renewal_order );
+		$this->assertFalse( $renewal_order->is_paid() );
+
+		$repo  = new ContractRepository();
+		$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() ) );
+		// A processing cycle carries no crash-recovery lease.
+		$this->assertNull( $cycle->get_claimed_until_gmt() );
+
+		// The schedule is not advanced (the charge has not confirmed).
+		$reloaded = $repo->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $reloaded );
+		$this->assertSame( '2026-02-15 00:00:00', $reloaded->get_next_payment_gmt() );
+
+		// A processing head is not re-selected: a later run is a no-op, no duplicate order.
+		$this->assertNull( $this->run_scheduled_renewal( $contract_id ) );
+		$this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+	}
+
+	/**
+	 * @testdox complete_from_order bills a processing cycle once its order is paid, and is idempotent.
+	 *
+	 * The async confirmation path: a cycle left `processing` is settled `billed` (and the schedule
+	 * advanced) when its order becomes paid and completion is driven from that order - the same
+	 * routine the order-status listener runs. Re-driving it is a no-op.
+	 */
+	public function test_complete_from_order_bills_a_processing_cycle_when_paid(): void {
+		GatewayCapabilities::declare( self::GATEWAY_PENDING, array( GatewayCapabilities::RECURRING ) );
+
+		$contract    = $this->sign_up_contract( self::GATEWAY_PENDING );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$engine = new RenewalEngine();
+		$order  = $this->run_scheduled_renewal( $contract_id );
+		$this->assertInstanceOf( WC_Order::class, $order );
+
+		$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() ) );
+
+		// The async confirmation arrives: the order is paid. Drive completion as the listener would.
+		$order->payment_complete();
+		$paid_order = wc_get_order( $order->get_id() );
+		$this->assertInstanceOf( WC_Order::class, $paid_order );
+		$engine->complete_from_order( $paid_order );
+
+		$head = $repo->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $head );
+		$this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+
+		$reloaded = $repo->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $reloaded );
+		$this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
+
+		// Idempotent: re-driving completion on the already-billed cycle changes nothing.
+		$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() ) );
+	}
+
+	/**
+	 * @testdox the scheduled scan does not charge the next cycle ahead of its period (the selection due-guard).
+	 *
+	 * After cycle 2 is billed its period runs to 2026-03-15. A scheduled scan tick at
+	 * a moment before that end must be a no-op - no cycle 3, no order - because selection owns the
+	 * due-guard, anchored on the head cycle's immutable end. process() itself bills whatever cycle
+	 * it is handed, so the "is it due" decision lives in the selector, not the money-path.
+	 */
+	public function test_scheduled_renewal_does_not_charge_ahead_of_the_period(): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		// Bill cycle 2 (its period ends 2026-03-15).
+		$order2 = $this->run_scheduled_renewal( $contract_id, new \DateTimeImmutable( '2026-02-15 00:00:00', new \DateTimeZone( 'UTC' ) ) );
+		$this->assertInstanceOf( WC_Order::class, $order2 );
+
+		$repo = new ContractRepository();
+		$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() ) );
+
+		// 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' ) ) );
+		$this->assertNull( $ahead );
+
+		$this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 3 ) );
+		$still = $repo->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $still );
+		$this->assertSame( 2, $still->get_count() );
+	}
+
+	/**
+	 * @testdox process bills the cycle it is handed, even before that cycle's scheduled due date.
+	 *
+	 * process() owns no due policy: handed the next cycle directly (as a future admin or early
+	 * renewal trigger would), it bills it regardless of the scheduled due-guard - which lives only
+	 * in selection. A scheduled run at this same moment would skip; forcing the intent does not.
+	 */
+	public function test_process_bills_the_handed_cycle_before_its_scheduled_due_date(): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		// Cycle 1's period ends 2026-02-15; force cycle 2 well before then.
+		$order = ( new RenewalEngine() )->process(
+			new RenewalIntent( $contract_id, 2 ),
+			new \DateTimeImmutable( '2026-01-20 00:00:00', new \DateTimeZone( 'UTC' ) )
+		);
+
+		$this->assertInstanceOf( WC_Order::class, $order );
+		$this->assertTrue( $order->is_paid() );
+
+		$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() ) );
 	}

 	/**
-	 * @testdox A gateway-scheduled contract is not scheduled by the engine.
+	 * @testdox renew_now forces the next cycle before its scheduled due date, keeping the schedule.
+	 *
+	 * The scheduled path would defer (the head's period has not ended), but an admin renewal
+	 * bypasses the due-guard. The forced cycle continues from the previous period's end, so the
+	 * schedule is preserved (a prepay), not reset to the moment of the manual renewal.
+	 */
+	public function test_renew_now_forces_the_next_cycle_before_its_due_date(): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		// A moment well before cycle 1's period end (2026-02-15): not yet due on the schedule.
+		$now = new \DateTimeImmutable( '2026-02-01 00:00:00', new \DateTimeZone( 'UTC' ) );
+
+		$renewal_order = ( new RenewalEngine() )->renew_now( $contract_id, $now );
+
+		$this->assertInstanceOf( WC_Order::class, $renewal_order );
+		$this->assertTrue( $renewal_order->is_paid() );
+
+		$repo  = new ContractRepository();
+		$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() ) );
+
+		// 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.
+		$reloaded = $repo->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $reloaded );
+		$this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
+	}
+
+	/**
+	 * @testdox renew_now retries a failed head in place and bills it on success.
+	 *
+	 * A failed renewal (the gateway declined) is not re-selected by the scheduled path, but an
+	 * admin retry re-attempts the SAME cycle: it flips failed -> pending, reuses the failed order,
+	 * and settles billed once the charge succeeds - without advancing to a new cycle.
 	 */
-	public function test_gateway_scheduled_contract_is_not_scheduled(): void {
+	public function test_renew_now_retries_a_failed_head_in_place(): void {
+		$this->fail_charges_for( self::GATEWAY_DECLINING );
+
+		$contract    = $this->sign_up_contract( self::GATEWAY_DECLINING );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$engine = new RenewalEngine();
+
+		// Scheduled run advances to cycle 2, which the gateway declines: a failed head.
+		$this->run_scheduled_renewal( $contract_id );
+
+		$repo   = new ContractRepository();
+		$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() ) );
+
+		// The customer fixes their payment method; the same gateway now approves the retry.
+		remove_all_actions( 'woocommerce_subscriptions_engine_scheduled_payment_' . self::GATEWAY_DECLINING );
+		add_action(
+			'woocommerce_subscriptions_engine_scheduled_payment_' . self::GATEWAY_DECLINING,
+			static function ( $amount, $renewal_order ): void {
+				unset( $amount );
+				if ( $renewal_order instanceof WC_Order && $renewal_order->needs_payment() ) {
+					$renewal_order->payment_complete();
+				}
+			},
+			10,
+			2
+		);
+
+		$retry = $engine->renew_now( $contract_id );
+
+		$this->assertInstanceOf( WC_Order::class, $retry );
+		$this->assertTrue( $retry->is_paid() );
+
+		// The SAME cycle 2 is now billed - retried in place, not advanced to a cycle 3.
+		$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() ) );
+
+		// The failed order was reused, not duplicated.
+		$this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+	}
+
+	/**
+	 * @testdox renew_now returns null for a contract with no billing chain, and does not park it.
+	 *
+	 * Unlike the scheduled path, a manual renewal never clears the schedule when it cannot proceed.
+	 */
+	public function test_renew_now_returns_null_for_a_chainless_contract_without_parking(): void {
 		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );

 		$plan_id     = $this->make_plan();
 		$order       = $this->make_origin_order();
-		$contract    = Contract::create(
+		$contract    = $this->make_contract( $plan_id, $order->get_id() );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$this->assertNull( ( new RenewalEngine() )->renew_now( $contract_id ) );
+
+		// The schedule is untouched (not parked): next_payment remains as set.
+		$reloaded = ( new ContractRepository() )->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $reloaded );
+		$this->assertSame( '2026-02-15 00:00:00', $reloaded->get_next_payment_gmt() );
+	}
+
+	/**
+	 * @testdox the scheduled scan resumes a draft order already linked on the cycle: promotes, charges, settles.
+	 *
+	 * A crash between linking the draft onto the cycle and promoting it to pending leaves a
+	 * linked `checkout-draft`. The resume path must resolve it directly through the cycle's
+	 * order reference, promote it, charge it, and settle - one order, no duplicate.
+	 */
+	public function test_scheduled_renewal_resumes_a_linked_draft_order(): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$repo     = new ContractRepository();
+		$previous = $repo->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $previous );
+
+		$cycle = $this->make_pending_cycle_2( $contract_id, $previous, gmdate( 'Y-m-d H:i:s', time() - 60 ) );
+		$draft = $this->make_ghost_renewal_order( $contract_id, 2, false, 'checkout-draft' );
+		$cycle->set_order_id( $draft->get_id() );
+		$repo->update_cycle( $cycle );
+
+		$resumed = $this->run_scheduled_renewal( $contract_id );
+
+		$this->assertInstanceOf( WC_Order::class, $resumed );
+		$this->assertSame( $draft->get_id(), $resumed->get_id(), 'The linked draft is reused, not duplicated.' );
+		$this->assertTrue( $resumed->is_paid() );
+		$this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+
+		$head = $repo->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $head );
+		$this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+	}
+
+	/**
+	 * @testdox the scheduled scan finds an unlinked draft via the meta fallback and heals the cycle link.
+	 *
+	 * A crash between saving the draft and linking it onto the cycle leaves an unlinked
+	 * `checkout-draft` carrying only the renewal meta. The fallback search must still surface
+	 * it (drafts are excluded from a plain 'any'-status query), heal the link, and resume.
+	 */
+	public function test_scheduled_renewal_reuses_an_unlinked_draft_order(): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$repo     = new ContractRepository();
+		$previous = $repo->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $previous );
+
+		$this->make_pending_cycle_2( $contract_id, $previous, gmdate( 'Y-m-d H:i:s', time() - 60 ) );
+		$draft = $this->make_ghost_renewal_order( $contract_id, 2, false, 'checkout-draft' );
+
+		$resumed = $this->run_scheduled_renewal( $contract_id );
+
+		$this->assertInstanceOf( WC_Order::class, $resumed );
+		$this->assertSame( $draft->get_id(), $resumed->get_id(), 'The abandoned draft is reused, not duplicated.' );
+		$this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+
+		$head = $repo->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $head );
+		$this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertSame( $draft->get_id(), $head->get_order_id(), 'The cycle link is healed from the meta match.' );
+	}
+
+	/**
+	 * @testdox a fresh claim builds its own order and never adopts a pre-tagged stray.
+	 *
+	 * The order lookup runs only for a reclaimed cycle: a cycle appended by this run cannot
+	 * have an order yet, so the every-renewal path skips the meta scan - and an anomalous
+	 * stray order carrying matching renewal meta (no cycle was ever claimed for it) is not
+	 * adopted or charged.
+	 */
+	public function test_a_fresh_claim_builds_its_own_order_and_ignores_a_pre_tagged_stray(): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		// A stray order tagged for the not-yet-claimed cycle 2 (a data anomaly: order work
+		// always follows the claim, so nothing legitimate produces this).
+		$stray = $this->make_ghost_renewal_order( $contract_id, 2, false );
+
+		$renewal_order = $this->run_scheduled_renewal( $contract_id );
+
+		$this->assertInstanceOf( WC_Order::class, $renewal_order );
+		$this->assertNotSame( $stray->get_id(), $renewal_order->get_id(), 'The fresh claim does not adopt the stray.' );
+		$this->assertTrue( $renewal_order->is_paid() );
+
+		$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->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() );
+		$this->assertInstanceOf( WC_Order::class, $stray_after );
+		$this->assertFalse( $stray_after->is_paid(), 'The stray order is never charged.' );
+	}
+
+	/**
+	 * @testdox complete_from_order settles once: repeats move no dates and re-fire no actions.
+	 */
+	public function test_complete_from_order_settles_the_schedule_once_when_invoked_repeatedly(): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+
+		$billed_fired = 0;
+		add_action(
+			RenewalEngine::RENEWAL_BILLED_ACTION,
+			static function () use ( &$billed_fired ): void {
+				++$billed_fired;
+			},
+			10,
+			0
+		);
+
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$engine        = new RenewalEngine();
+		$renewal_order = $this->run_scheduled_renewal( $contract_id );
+		$this->assertInstanceOf( WC_Order::class, $renewal_order );
+		$fired_after_process = $billed_fired;
+
+		$repo  = new ContractRepository();
+		$after = $repo->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $after );
+		$next_payment = $after->get_next_payment_gmt();
+		$last_payment = $after->get_last_payment_gmt();
+
+		// A late duplicate completion (a repeated webhook, a concurrent worker's retry): no-op.
+		$fresh = wc_get_order( $renewal_order->get_id() );
+		$this->assertInstanceOf( WC_Order::class, $fresh );
+		$engine->complete_from_order( $fresh );
+		$fired_after_repeat = $billed_fired;
+
+		$reloaded = $repo->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $reloaded );
+		$this->assertSame( $next_payment, $reloaded->get_next_payment_gmt(), 'The schedule does not move again.' );
+		$this->assertSame( $last_payment, $reloaded->get_last_payment_gmt(), 'The payment record does not move again.' );
+		$this->assertSame( 1, $fired_after_process, 'The billed action fires exactly once for the renewal.' );
+		$this->assertSame( 1, $fired_after_repeat, 'The billed action does not re-fire.' );
+	}
+
+	/**
+	 * @testdox complete_from_order ignores an order the head is not linked to.
+	 *
+	 * The cycle's own order reference is the settlement authority: a rogue paid order carrying
+	 * matching renewal meta must not settle a head that is linked to a different order.
+	 */
+	public function test_complete_from_order_ignores_an_order_the_head_is_not_linked_to(): void {
+		GatewayCapabilities::declare( self::GATEWAY_APPROVING, array( GatewayCapabilities::RECURRING ) );
+
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$repo     = new ContractRepository();
+		$previous = $repo->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $previous );
+
+		// The head is claimed with a live lease and linked to (unpaid) order A.
+		$cycle   = $this->make_pending_cycle_2( $contract_id, $previous, gmdate( 'Y-m-d H:i:s', time() + 3600 ) );
+		$order_a = $this->make_ghost_renewal_order( $contract_id, 2, false );
+		$cycle->set_order_id( $order_a->get_id() );
+		$repo->update_cycle( $cycle );
+
+		// A rogue paid order B carries the same renewal meta but is not the head's order.
+		$order_b = $this->make_ghost_renewal_order( $contract_id, 2, true );
+		( new RenewalEngine() )->complete_from_order( $order_b );
+
+		$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->assertSame( $order_a->get_id(), $head->get_order_id() );
+	}
+
+	/**
+	 * @testdox complete_from_order leaves the cycle in flight when the order cannot be re-read.
+	 *
+	 * Settlement never trusts the stale in-memory order: when the fresh read fails (the order
+	 * was deleted mid-flight) the cycle stays as it was, for a later run to resolve.
+	 */
+	public function test_complete_from_order_leaves_the_cycle_in_flight_when_the_order_is_gone(): void {
+		GatewayCapabilities::declare( self::GATEWAY_APPROVING, array( GatewayCapabilities::RECURRING ) );
+
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$repo     = new ContractRepository();
+		$previous = $repo->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $previous );
+
+		$this->make_pending_cycle_2( $contract_id, $previous, gmdate( 'Y-m-d H:i:s', time() + 3600 ) );
+		$ghost = $this->make_ghost_renewal_order( $contract_id, 2, true );
+		$ghost->delete( true );
+
+		// The in-memory instance still carries the meta, but the stored order is gone.
+		( new RenewalEngine() )->complete_from_order( $ghost );
+
+		$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.' );
+	}
+
+	/**
+	 * @testdox the scheduled scan parks a contract whose gateway cannot charge renewals.
+	 *
+	 * A gateway that does not declare the `recurring` capability makes every attempt futile
+	 * until the payment method is updated: the charge hook would fire into nothing and the
+	 * cycle would stall `processing`. The pre-flight refuses before any claim, and the
+	 * dispatcher parks the contract out of the due set; fixing the payment method plus a
+	 * manual renewal (or a repair) re-arms it.
+	 */
+	public function test_scheduled_renewal_parks_a_contract_whose_gateway_cannot_charge(): void {
+		// Signed up against a gateway that never declares the `recurring` capability.
+		$contract    = $this->sign_up_contract( self::GATEWAY );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$this->assertNull( $this->run_scheduled_renewal( $contract_id ) );
+
+		// Nothing was claimed: the head is still cycle 1 and no renewal order exists.
+		$repo = new ContractRepository();
+		$head = $repo->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $head );
+		$this->assertSame( 1, $head->get_count() );
+		$this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+
+		// Parked: the contract left the due set until the payment method is repaired.
+		$reloaded = $repo->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $reloaded );
+		$this->assertNull( $reloaded->get_next_payment_gmt(), 'An unchargeable contract is parked out of the due set.' );
+	}
+
+	/**
+	 * @testdox renew_now refuses a contract whose gateway cannot charge, without parking it.
+	 */
+	public function test_renew_now_refuses_an_unchargeable_gateway_without_parking(): void {
+		$contract    = $this->sign_up_contract( self::GATEWAY );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$this->assertNull( ( new RenewalEngine() )->renew_now( $contract_id ) );
+
+		// No claim, no order, and the schedule is untouched (a manual action never parks).
+		$repo = new ContractRepository();
+		$head = $repo->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $head );
+		$this->assertSame( 1, $head->get_count() );
+		$this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+
+		$reloaded = $repo->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $reloaded );
+		$this->assertSame( '2026-02-15 00:00:00', $reloaded->get_next_payment_gmt() );
+	}
+
+	/**
+	 * @testdox a manual paid-status change settles a processing cycle (cash-on-delivery shape).
+	 *
+	 * A gateway-less settlement never calls payment_complete(): an admin marks the renewal
+	 * order processing/completed by hand. The paid-status transition listeners must complete
+	 * the cycle from that, or manual methods stay locked in `processing` forever.
+	 */
+	public function test_manual_paid_status_change_settles_the_cycle(): void {
+		GatewayCapabilities::declare( self::GATEWAY_PENDING, array( GatewayCapabilities::RECURRING ) );
+
+		$contract    = $this->sign_up_contract( self::GATEWAY_PENDING );
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		// No charge handler: the charge stays unconfirmed and the cycle parks `processing`.
+		$renewal_order = $this->run_scheduled_renewal( $contract_id );
+		$this->assertInstanceOf( WC_Order::class, $renewal_order );
+
+		$repo = new ContractRepository();
+		$head = $repo->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $head );
+		$this->assertTrue( $head->get_status()->equals( 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.' );
+
+		// The schedule advanced to the billed cycle's own period end.
+		$reloaded = $repo->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $reloaded );
+		$this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
+	}
+
+	/**
+	 * Append a pending cycle 2 (no tagged renewal order) with the given crash-recovery
+	 * lease, so the create-as-claim collides on it and the reclaim-vs-skip path is exercised.
+	 *
+	 * @param int    $contract_id   Contract id.
+	 * @param Cycle  $previous      The chain's current cycle (cycle 1).
+	 * @param string $claimed_until The lease expiry GMT string to stamp on the pending cycle.
+	 * @return Cycle The appended pending cycle 2.
+	 */
+	private function make_pending_cycle_2( int $contract_id, Cycle $previous, string $claimed_until ): Cycle {
+		$cycle = Cycle::create(
 			array(
-				'customer_id'      => 1,
-				'currency'         => 'USD',
-				'selling_plan_id'  => $plan_id,
-				'origin_order_id'  => $order->get_id(),
-				'payment_method'   => self::GATEWAY,
-				'start_gmt'        => '2026-01-15 00:00:00',
-				'next_payment_gmt' => '2026-02-15 00:00:00',
-				'schedule_source'  => Contract::SCHEDULE_SOURCE_GATEWAY,
+				'contract_id'    => $contract_id,
+				'sequence_no'    => $previous->get_sequence_no() + 1,
+				'count'          => 2,
+				'status'         => CycleStatus::pending(),
+				'starts_at_gmt'  => '2026-02-15 00:00:00',
+				'ends_at_gmt'    => '2026-03-15 00:00:00',
+				'expected_total' => '19.99',
+				'currency'       => 'USD',
+				'claimed_until'  => $claimed_until,
 			)
 		);
-		$contract_id = ( new ContractRepository() )->insert( $contract );
+		( new ContractRepository() )->append_cycle( $cycle, $previous );
+
+		return $cycle;
+	}
+
+	/**
+	 * A renewal order left as a crash would leave it: saved with the renewal-relation meta for
+	 * `$count` but never charged (no payment token). Optionally pre-marked paid, to model a crash
+	 * AFTER the gateway was paid but before the cycle settled, or given an explicit status - a
+	 * `checkout-draft` models a crash during the draft-first creation window.
+	 *
+	 * @param int         $contract_id Contract id.
+	 * @param int         $count       Chargeable number the order bills.
+	 * @param bool        $paid        Whether the gateway had already been paid before the crash.
+	 * @param string|null $status      Explicit order status, overriding the paid/pending default.
+	 * @return WC_Order The ghost renewal order.
+	 */
+	private function make_ghost_renewal_order( int $contract_id, int $count, bool $paid, ?string $status = null ): WC_Order {
+		// A ghost models a crash where settlement never ran: silence the order-settled
+		// listeners while seeding it (a paid status transition would settle the cycle
+		// mid-setup), then restore them on a fresh engine.
+		remove_all_actions( 'woocommerce_payment_complete' );
+		remove_all_actions( 'woocommerce_order_status_failed' );
+		foreach ( wc_get_is_paid_statuses() as $paid_status ) {
+			remove_all_actions( 'woocommerce_order_status_' . $paid_status );
+		}

-		$this->assertFalse( ( new RenewalEngine() )->schedule( $contract ) );
-		$this->assertFalse( RenewalScheduler::is_scheduled( $contract_id ) );
+		$order = new WC_Order();
+		$order->set_currency( 'USD' );
+		$order->set_total( '19.99' );
+		$order->update_meta_data( OrderLinkage::META_CONTRACT_ID, (string) $contract_id );
+		$order->update_meta_data( OrderLinkage::META_RELATION_TYPE, OrderLinkage::RELATION_RENEWAL );
+		$order->update_meta_data( '_subscription_renewal_cycle', (string) $count );
+
+		if ( $paid ) {
+			$order->set_status( 'processing' );
+			$order->set_date_paid( '2026-02-15 00:00:00' );
+		} else {
+			$order->set_status( 'pending' );
+		}
+		if ( null !== $status ) {
+			$order->set_status( $status );
+		}
+		$order->save();
+
+		( new RenewalEngine() )->register_hooks();
+
+		return $order;
 	}

 	/**
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 f78303d8290..d04f8a4d35a 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
@@ -17,6 +17,8 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\DuplicateCycleException;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\RenewalCandidate;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;

 /**
@@ -267,7 +269,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$this->assertSame( $cycle->get_items_snapshot_id(), $reloaded->get_items_snapshot_id() );

 		// Cycle 1 is the billed signup, reachable as the chain's most-recent cycle.
-		$current = $this->sut->find_current_cycle( $id );
+		$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() ) );
@@ -412,7 +414,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	/**
 	 * @testdox append_cycle inserts a cycle reachable as the chain's current cycle.
 	 */
-	public function test_append_cycle_and_find_current_cycle(): void {
+	public function test_append_cycle_and_find_chain_head(): void {
 		$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() );
@@ -420,7 +422,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {

 		$this->assertNotNull( $cycle->get_id() );

-		$current = $this->sut->find_current_cycle( $id );
+		$current = $this->sut->find_chain_head( $id );
 		$this->assertInstanceOf( Cycle::class, $current );
 		$this->assertSame( $cycle->get_id(), $current->get_id() );
 		$this->assertSame( 1, $current->get_sequence_no() );
@@ -464,15 +466,15 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		);
 		$this->sut->append_cycle( $cycle );

-		$reloaded = $this->sut->find_current_cycle( $id );
+		$reloaded = $this->sut->find_chain_head( $id );
 		$this->assertInstanceOf( Cycle::class, $reloaded );
 		$this->assertSame( '9.12345678', $reloaded->get_expected_total() );
 	}

 	/**
-	 * @testdox find_current_cycle returns the highest-sequence cycle in the chain.
+	 * @testdox find_chain_head returns the highest-sequence cycle in the chain.
 	 */
-	public function test_find_current_cycle_returns_the_head(): void {
+	public function test_find_chain_head_returns_the_head(): void {
 		$id = $this->sut->insert( $this->make_contract() );

 		$first = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' );
@@ -481,18 +483,18 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$second = $this->make_cycle( $id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00' );
 		$this->sut->append_cycle( $second, $first );

-		$current = $this->sut->find_current_cycle( $id );
+		$current = $this->sut->find_chain_head( $id );
 		$this->assertInstanceOf( Cycle::class, $current );
 		$this->assertSame( 2, $current->get_sequence_no() );
 	}

 	/**
-	 * @testdox find_current_cycle returns null for a chain with no cycles.
+	 * @testdox find_chain_head returns null for a chain with no cycles.
 	 */
-	public function test_find_current_cycle_is_null_when_empty(): void {
+	public function test_find_chain_head_is_null_when_empty(): void {
 		$id = $this->sut->insert( $this->make_contract() );

-		$this->assertNull( $this->sut->find_current_cycle( $id ) );
+		$this->assertNull( $this->sut->find_chain_head( $id ) );
 	}

 	/**
@@ -577,11 +579,374 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$cycle->set_status( CycleStatus::billed() );
 		$this->sut->update_cycle( $cycle );

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

+	/**
+	 * @testdox The cycle crash-recovery lease column round-trips through append and update.
+	 */
+	public function test_cycle_claimed_until_round_trips(): void {
+		$id = $this->sut->insert( $this->make_contract() );
+
+		// Appended with a lease set.
+		$cycle = Cycle::create(
+			array(
+				'contract_id'    => $id,
+				'sequence_no'    => 1,
+				'count'          => 1,
+				'status'         => CycleStatus::pending(),
+				'starts_at_gmt'  => '2026-07-15 00:00:00',
+				'ends_at_gmt'    => '2026-08-15 00:00:00',
+				'expected_total' => '19.99',
+				'currency'       => 'USD',
+				'claimed_until'  => '2026-07-15 00:15:00',
+			)
+		);
+		$this->sut->append_cycle( $cycle );
+
+		$reloaded = $this->sut->find_chain_head( $id );
+		$this->assertInstanceOf( Cycle::class, $reloaded );
+		$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_claimed_until_gmt( null );
+		$this->sut->update_cycle( $reloaded );
+
+		$settled = $this->sut->find_chain_head( $id );
+		$this->assertInstanceOf( Cycle::class, $settled );
+		$this->assertNull( $settled->get_claimed_until_gmt() );
+	}
+
+	/**
+	 * @testdox reclaim_expired_cycle wins the CAS for an expired-lease pending cycle and extends the lease.
+	 *
+	 * The expiry predicate and the fresh lease both anchor on the database clock, so the
+	 * test seeds the lease relative to real time.
+	 */
+	public function test_reclaim_expired_cycle_succeeds_for_an_expired_lease(): void {
+		$id    = $this->sut->insert( $this->make_contract() );
+		$cycle = $this->append_pending_cycle_with_lease( $id, gmdate( 'Y-m-d H:i:s', time() - 60 ) );
+
+		// The lease expired a minute ago per the DB clock: the CAS matches.
+		$won = $this->sut->reclaim_expired_cycle( (int) $cycle->get_id(), 900 );
+		$this->assertTrue( $won, 'The first worker reclaims an expired-lease pending cycle.' );
+
+		$reloaded = $this->sut->find_chain_head( $id );
+		$this->assertInstanceOf( Cycle::class, $reloaded );
+		$this->assertNotNull( $reloaded->get_claimed_until_gmt() );
+		$this->assertGreaterThan( time() + 800, strtotime( $reloaded->get_claimed_until_gmt() . ' UTC' ), 'The lease is extended a TTL into the future.' );
+	}
+
+	/**
+	 * @testdox reclaim_expired_cycle arbitrates the two-worker race: the first wins, the second loses.
+	 *
+	 * The compare-and-set that prevents a double charge. Both workers find the same
+	 * expired-lease cycle; the first CAS matches the row and extends the lease into the
+	 * future, so the second CAS (predicate `claimed_until <= now`) matches zero rows and
+	 * loses. Exactly one worker reclaims, so the cycle is charged at most once.
+	 */
+	public function test_reclaim_expired_cycle_arbitrates_the_race(): void {
+		$id    = $this->sut->insert( $this->make_contract() );
+		$cycle = $this->append_pending_cycle_with_lease( $id, gmdate( 'Y-m-d H:i:s', time() - 60 ) );
+
+		$cycle_id = (int) $cycle->get_id();
+
+		// Worker A: the lease has expired per the DB clock, so the CAS wins and extends it.
+		$first = $this->sut->reclaim_expired_cycle( $cycle_id, 900 );
+		$this->assertTrue( $first, 'The first worker wins the reclaim.' );
+
+		// Worker B: same read, but the lease now sits a TTL in the future - zero rows match.
+		$second = $this->sut->reclaim_expired_cycle( $cycle_id, 900 );
+		$this->assertFalse( $second, 'The second worker loses the race: no double reclaim.' );
+
+		// The lease reflects the winner's extension.
+		$reloaded = $this->sut->find_chain_head( $id );
+		$this->assertInstanceOf( Cycle::class, $reloaded );
+		$this->assertNotNull( $reloaded->get_claimed_until_gmt() );
+		$this->assertGreaterThan( time() + 800, strtotime( $reloaded->get_claimed_until_gmt() . ' UTC' ) );
+	}
+
+	/**
+	 * @testdox reclaim_expired_cycle does not touch a settled (non-pending) cycle.
+	 */
+	public function test_reclaim_expired_cycle_skips_a_settled_cycle(): void {
+		$id    = $this->sut->insert( $this->make_contract() );
+		$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_claimed_until_gmt( null );
+		$this->sut->update_cycle( $cycle );
+
+		$won = $this->sut->reclaim_expired_cycle( (int) $cycle->get_id(), 900 );
+		$this->assertFalse( $won, 'A billed cycle is never reclaimable: the status predicate excludes it.' );
+	}
+
+	/**
+	 * Append a pending cycle 1 carrying a crash-recovery lease, returning it (with its id).
+	 *
+	 * @param int    $contract_id   Contract id.
+	 * @param string $claimed_until The lease expiry GMT string to stamp.
+	 */
+	private function append_pending_cycle_with_lease( int $contract_id, string $claimed_until ): Cycle {
+		$cycle = Cycle::create(
+			array(
+				'contract_id'    => $contract_id,
+				'sequence_no'    => 1,
+				'count'          => 1,
+				'status'         => CycleStatus::pending(),
+				'starts_at_gmt'  => '2026-07-15 00:00:00',
+				'ends_at_gmt'    => '2026-08-15 00:00:00',
+				'expected_total' => '19.99',
+				'currency'       => 'USD',
+				'claimed_until'  => $claimed_until,
+			)
+		);
+		$this->sut->append_cycle( $cycle );
+
+		return $cycle;
+	}
+
+	/**
+	 * @testdox find_due returns only active contracts whose next_payment has arrived, oldest first.
+	 */
+	public function test_find_due_returns_only_due_active_contracts_oldest_first(): void {
+		$now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+
+		$due_old    = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
+		$due_recent = $this->insert_contract_due_at( '2026-07-14 00:00:00', ContractStatus::ACTIVE );
+		$not_yet    = $this->insert_contract_due_at( '2026-08-15 00:00:00', ContractStatus::ACTIVE );
+		$on_hold    = $this->insert_contract_due_at( '2026-06-01 00:00:00', ContractStatus::ON_HOLD );
+
+		$ids = $this->due_ids( $now, 50 );
+
+		// Only the two due+active contracts, oldest-due first; the future and the non-active excluded.
+		$this->assertSame( array( $due_old, $due_recent ), $ids );
+		$this->assertNotContains( $not_yet, $ids );
+		$this->assertNotContains( $on_hold, $ids );
+	}
+
+	/**
+	 * @testdox find_due treats the cutoff as inclusive and excludes a null next_payment.
+	 */
+	public function test_find_due_is_inclusive_and_skips_null_schedule(): void {
+		$now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+
+		$exactly_due = $this->insert_contract_due_at( '2026-07-15 00:00:00', ContractStatus::ACTIVE );
+		$no_schedule = $this->insert_contract_due_at( null, ContractStatus::ACTIVE );
+
+		$ids = $this->due_ids( $now, 50 );
+
+		$this->assertContains( $exactly_due, $ids, 'A contract due exactly at the cutoff is included.' );
+		$this->assertNotContains( $no_schedule, $ids, 'A contract with no next_payment_gmt is never due.' );
+	}
+
+	/**
+	 * @testdox find_due caps the batch at the limit from the oldest-due end.
+	 */
+	public function test_find_due_honours_limit(): void {
+		$now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+
+		$first  = $this->insert_contract_due_at( '2026-05-15 00:00:00', ContractStatus::ACTIVE );
+		$second = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
+		$this->insert_contract_due_at( '2026-07-01 00:00:00', ContractStatus::ACTIVE );
+
+		// The limit caps the batch from the oldest-due end; the newest-due row is left for a later tick.
+		$this->assertSame( array( $first, $second ), $this->due_ids( $now, 2 ) );
+	}
+
+	/**
+	 * @testdox find_due excludes gateway-scheduled contracts (the gateway owns their renewal).
+	 */
+	public function test_find_due_excludes_gateway_scheduled_contracts(): void {
+		$now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+
+		$primitive = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
+		$gateway   = $this->insert_contract_due_at( '2026-06-01 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_GATEWAY );
+
+		$ids = $this->due_ids( $now, 50 );
+
+		$this->assertContains( $primitive, $ids );
+		$this->assertNotContains( $gateway, $ids, 'A gateway-scheduled contract must never enter the primitive scan.' );
+	}
+
+	/**
+	 * @testdox find_due returns the head fields the selector needs.
+	 */
+	public function test_find_due_returns_the_head_fields(): 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 );
+		$candidates = $this->sut->find_due( $now, 50 );
+
+		$this->assertCount( 1, $candidates );
+		$row = $candidates[0];
+		$this->assertInstanceOf( RenewalCandidate::class, $row );
+		$this->assertSame( $id, $row->get_contract_id() );
+		$this->assertSame( 1, $row->get_head_count() );
+		$this->assertSame( CycleStatus::BILLED, $row->get_head_status() );
+		$this->assertSame( '2026-06-15 00:00:00', $row->get_head_ends_at_gmt() );
+	}
+
+	/**
+	 * @testdox find_due excludes a contract whose head is not actionable (failed / processing).
+	 */
+	public function test_find_due_excludes_non_actionable_heads(): void {
+		$now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+
+		$billed     = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::BILLED );
+		$failed     = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::FAILED );
+		$processing = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::PROCESSING );
+
+		$ids = $this->due_ids( $now, 50 );
+
+		$this->assertContains( $billed, $ids );
+		$this->assertNotContains( $failed, $ids, 'A failed head awaits dunning, not the scan.' );
+		$this->assertNotContains( $processing, $ids, 'A processing head awaits its gateway, not the scan.' );
+	}
+
+	/**
+	 * @testdox find_due excludes a billed head whose period has not ended (the advance due-guard).
+	 */
+	public function test_find_due_excludes_a_billed_head_not_yet_ended(): void {
+		$now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+
+		// next_payment has arrived (the coarse index would pick it), but the head's own period
+		// runs into the future - so the successor is not yet due and the scan must exclude it.
+		$not_ended = $this->insert_contract_due_at( '2026-07-01 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::BILLED, null, '2026-08-15 00:00:00' );
+
+		$this->assertNotContains( $not_ended, $this->due_ids( $now, 50 ) );
+	}
+
+	/**
+	 * @testdox find_due includes a reclaim-ready pending head but not a live-lease one.
+	 */
+	public function test_find_due_includes_reclaim_ready_pending_but_not_live_lease(): void {
+		$now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+
+		$reclaimable = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::PENDING, '2026-07-14 00:00:00' );
+		$live_lease  = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::PENDING, '2026-08-15 00:00:00' );
+
+		$ids = $this->due_ids( $now, 50 );
+
+		$this->assertContains( $reclaimable, $ids, 'A pending head whose lease has expired is reclaim-ready.' );
+		$this->assertNotContains( $live_lease, $ids, 'A pending head with a live lease is a concurrent claim, not for the scan.' );
+	}
+
+	/**
+	 * @testdox find_due returns no rows for a non-positive limit.
+	 */
+	public function test_find_due_returns_no_rows_for_a_non_positive_limit(): 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, 0 ) );
+		$this->assertSame( array(), $this->sut->find_due( $now, -1 ) );
+	}
+
+	/**
+	 * @testdox transition_cycle_status settles atomically: one caller wins, repeats lose.
+	 */
+	public function test_transition_cycle_status_is_an_atomic_compare_and_set(): void {
+		$contract_id = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::PENDING, '2026-07-14 00:00:00' );
+
+		$head = $this->sut->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $head );
+		$cycle_id = (int) $head->get_id();
+
+		// The winning settle: status flips, order stamped, lease cleared, reason NULL - one write.
+		$this->assertTrue( $this->sut->transition_cycle_status( $cycle_id, CycleStatus::PENDING, CycleStatus::BILLED, 4242 ) );
+
+		$settled = $this->sut->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $settled );
+		$this->assertSame( CycleStatus::BILLED, $settled->get_status()->get_value() );
+		$this->assertSame( 4242, $settled->get_order_id() );
+		$this->assertNull( $settled->get_claimed_until_gmt() );
+		$this->assertNull( $settled->get_reason() );
+
+		// A racing settler that read the old status matches zero rows and loses.
+		$this->assertFalse( $this->sut->transition_cycle_status( $cycle_id, CycleStatus::PENDING, CycleStatus::FAILED, 9999, 'gateway-charge-failed' ) );
+
+		$after = $this->sut->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $after );
+		$this->assertSame( CycleStatus::BILLED, $after->get_status()->get_value(), 'The losing transition writes nothing.' );
+		$this->assertSame( 4242, $after->get_order_id() );
+	}
+
+	/**
+	 * The contract ids of the due scan at `$now`, in scan order.
+	 *
+	 * @param \DateTimeImmutable $now   The cutoff moment.
+	 * @param int                $limit The batch size.
+	 * @return array<int, int>
+	 */
+	private function due_ids( \DateTimeImmutable $now, int $limit ): array {
+		return array_map(
+			static function ( RenewalCandidate $candidate ): int {
+				return $candidate->get_contract_id();
+			},
+			$this->sut->find_due( $now, $limit )
+		);
+	}
+
+	/**
+	 * Insert a contract with the given schedule date and status, plus a head cycle so the
+	 * cycle-aware scan can join it. The head is billed and ends when the next payment is due
+	 * (advance-ready) unless overridden. Returns the contract id.
+	 *
+	 * @param string|null $next_payment_gmt The next-payment date, or null for no schedule/head.
+	 * @param string      $status           The contract status (a ContractStatus value).
+	 * @param string      $schedule_source  Who owns the schedule (primitive or gateway).
+	 * @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`.
+	 */
+	private function insert_contract_due_at(
+		?string $next_payment_gmt,
+		string $status,
+		string $schedule_source = Contract::SCHEDULE_SOURCE_PRIMITIVE,
+		string $head_status = CycleStatus::BILLED,
+		?string $claimed_until = null,
+		?string $head_ends_at = null
+	): int {
+		$contract = Contract::create(
+			array(
+				'customer_id'      => 1,
+				'currency'         => 'USD',
+				'selling_plan_id'  => 7,
+				'origin_order_id'  => 1001,
+				'start_gmt'        => '2026-01-15 00:00:00',
+				'next_payment_gmt' => $next_payment_gmt,
+				'status'           => $status,
+				'schedule_source'  => $schedule_source,
+			)
+		);
+		$id       = $this->sut->insert( $contract );
+
+		if ( null !== $next_payment_gmt ) {
+			$this->sut->append_cycle(
+				Cycle::create(
+					array(
+						'contract_id'    => $id,
+						'sequence_no'    => 1,
+						'count'          => 1,
+						'status'         => CycleStatus::from( $head_status ),
+						'starts_at_gmt'  => '2026-01-15 00:00:00',
+						'ends_at_gmt'    => $head_ends_at ?? $next_payment_gmt,
+						'expected_total' => '19.99',
+						'currency'       => 'USD',
+						'claimed_until'  => $claimed_until,
+					)
+				)
+			);
+		}
+
+		return $id;
+	}
+
 	/**
 	 * @testdox Consecutive cycles with an unchanged plan/items share one snapshot row each.
 	 */
@@ -705,6 +1070,20 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$this->assertFalse( $inserted, 'A duplicate (contract_id, kind, count) must be rejected by the UNIQUE index.' );
 	}

+	/**
+	 * @testdox append_cycle surfaces a UNIQUE collision as DuplicateCycleException.
+	 *
+	 * The create-as-claim race signal must be distinguishable from any other write failure,
+	 * so the money-path can treat the collision as benign without masking real errors.
+	 */
+	public function test_append_cycle_throws_duplicate_cycle_exception_on_collision(): void {
+		$id = $this->sut->insert( $this->make_contract() );
+		$this->sut->append_cycle( $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' ) );
+
+		$this->expectException( DuplicateCycleException::class );
+		$this->sut->append_cycle( $this->make_cycle( $id, 2, 1, '2026-08-15 00:00:00', '2026-09-15 00:00:00' ) );
+	}
+
 	/**
 	 * @testdox Multiple non-counting cycles (count = null) coexist in one chain.
 	 */
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 43b54a7bcb9..33fe09f1c08 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,6 +234,21 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
 		);
 	}

+	/**
+	 * @testdox The due_contract index keys the dispatcher scan as (status, next_payment_gmt).
+	 */
+	public function test_contracts_due_contract_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 ) );
+		$this->assertSame(
+			array( 'status', 'next_payment_gmt' ),
+			$this->index_columns( $table, 'due_contract' )
+		);
+	}
+
 	public function test_cycles_table_has_expected_columns(): void {
 		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );

@@ -253,6 +268,8 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
 			'items_snapshot_id',
 			'order_id',
 			'extension_slug',
+			'claimed_until',
+			'retry_at',
 		);

 		foreach ( $columns as $column ) {
@@ -260,6 +277,25 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
 		}
 	}

+	/**
+	 * @testdox The cycle dispatcher columns (claimed_until lease, reserved retry_at) are nullable.
+	 */
+	public function test_cycles_table_dispatcher_columns_are_nullable(): void {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+		foreach ( array( 'claimed_until', 'retry_at' ) as $column ) {
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+			$row = $wpdb->get_row( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", $column ), ARRAY_A );
+
+			$this->assertIsArray( $row, "Expected a cycles.{$column} column." );
+			$this->assertArrayHasKey( 'Null', $row );
+			$this->assertIsString( $row['Null'] );
+			$this->assertSame( 'YES', $row['Null'], "Expected cycles.{$column} to be NULLable." );
+		}
+	}
+
 	/**
 	 * @testdox A chain is (contract_id, kind), so cycles carry no chain_id column.
 	 */
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 b4a8ac085a2..87ba8a67499 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
@@ -22,6 +22,7 @@ class CycleStatusTest extends TestCase {
 		$this->assertSame(
 			array(
 				CycleStatus::PENDING,
+				CycleStatus::PROCESSING,
 				CycleStatus::BILLED,
 				CycleStatus::FAILED,
 				CycleStatus::CANCELLED,
@@ -32,22 +33,33 @@ class CycleStatusTest extends TestCase {

 	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_billed_failed_and_cancelled(): void {
+	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_failed_can_only_be_cancelled(): void {
-		// Retry support (failed -> pending re-queue) is deferred; for now a failed cycle can only be 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::PENDING ) );
 		$this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::FAILED, CycleStatus::BILLED ) );
 	}

@@ -61,8 +73,9 @@ class CycleStatusTest extends TestCase {
 		}
 	}

-	public function test_pending_and_failed_are_not_terminal(): void {
+	public function test_pending_processing_and_failed_are_not_terminal(): void {
 		$this->assertFalse( CycleStatus::is_terminal( CycleStatus::PENDING ) );
+		$this->assertFalse( CycleStatus::is_terminal( CycleStatus::PROCESSING ) );
 		$this->assertFalse( CycleStatus::is_terminal( CycleStatus::FAILED ) );
 	}

@@ -107,6 +120,7 @@ class CycleStatusTest extends TestCase {

 	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() );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Ownership/ConsumerRegistryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Ownership/ConsumerRegistryTest.php
new file mode 100644
index 00000000000..ad8cd4d2b9e
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Ownership/ConsumerRegistryTest.php
@@ -0,0 +1,99 @@
+<?php
+/**
+ * Unit tests for the ConsumerRegistry (the set of engine-consuming extensions).
+ *
+ * The registry is WordPress-free (only the file-access guard), so it loads and
+ * runs in the autoloader-only unit bootstrap.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Integration\Ownership;
+
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry
+ */
+class ConsumerRegistryTest extends TestCase {
+
+	protected function setUp(): void {
+		parent::setUp();
+		ConsumerRegistry::reset();
+	}
+
+	protected function tearDown(): void {
+		ConsumerRegistry::reset();
+		parent::tearDown();
+	}
+
+	public function test_is_empty_when_nothing_is_registered(): void {
+		$this->assertTrue( ConsumerRegistry::is_empty() );
+		$this->assertSame( array(), ConsumerRegistry::all() );
+	}
+
+	public function test_register_records_a_consumer(): void {
+		ConsumerRegistry::register( 'lite' );
+
+		$this->assertFalse( ConsumerRegistry::is_empty() );
+		$this->assertSame( array( 'lite' ), ConsumerRegistry::all() );
+	}
+
+	public function test_register_is_idempotent_for_the_same_slug(): void {
+		ConsumerRegistry::register( 'lite' );
+		ConsumerRegistry::register( 'lite' );
+
+		$this->assertSame( array( 'lite' ), ConsumerRegistry::all() );
+	}
+
+	public function test_register_records_multiple_consumers(): void {
+		ConsumerRegistry::register( 'lite' );
+		ConsumerRegistry::register( 'premium' );
+
+		$this->assertFalse( ConsumerRegistry::is_empty() );
+
+		$all = ConsumerRegistry::all();
+		sort( $all );
+		$this->assertSame( array( 'lite', 'premium' ), $all );
+	}
+
+	public function test_register_ignores_an_empty_slug(): void {
+		ConsumerRegistry::register( '' );
+
+		$this->assertTrue( ConsumerRegistry::is_empty(), 'A blank slug must not flip the gate open.' );
+		$this->assertSame( array(), ConsumerRegistry::all() );
+	}
+
+	public function test_register_ignores_a_whitespace_only_slug_and_trims(): void {
+		ConsumerRegistry::register( "  \t\n" );
+		$this->assertTrue( ConsumerRegistry::is_empty(), 'A whitespace-only slug must not open the gate.' );
+
+		ConsumerRegistry::register( '  woocommerce-subscriptions-lite  ' );
+		$this->assertSame( array( 'woocommerce-subscriptions-lite' ), ConsumerRegistry::all(), 'The slug is stored trimmed.' );
+	}
+
+	public function test_unregister_removes_only_the_named_consumer(): void {
+		ConsumerRegistry::register( 'lite' );
+		ConsumerRegistry::register( 'premium' );
+
+		ConsumerRegistry::unregister( 'lite' );
+		$this->assertSame( array( 'premium' ), ConsumerRegistry::all(), 'Only the named consumer is removed.' );
+
+		ConsumerRegistry::unregister( ' premium ' );
+		$this->assertTrue( ConsumerRegistry::is_empty(), 'The slug is trimmed, matching register().' );
+
+		ConsumerRegistry::unregister( 'unknown' );
+		$this->assertTrue( ConsumerRegistry::is_empty(), 'An unknown slug is a no-op.' );
+	}
+
+	public function test_reset_clears_every_registration(): void {
+		ConsumerRegistry::register( 'lite' );
+		ConsumerRegistry::reset();
+
+		$this->assertTrue( ConsumerRegistry::is_empty() );
+		$this->assertSame( array(), ConsumerRegistry::all() );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Renewal/RenewalSelectorTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Renewal/RenewalSelectorTest.php
new file mode 100644
index 00000000000..f770783e326
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Renewal/RenewalSelectorTest.php
@@ -0,0 +1,117 @@
+<?php
+/**
+ * Unit tests for the read-only renewal cycle selector.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Integration\Renewal;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalSelector;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\RenewalCandidate;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalSelector
+ */
+class RenewalSelectorTest extends TestCase {
+
+	/**
+	 * The scan moment the selector decides against.
+	 *
+	 * @var DateTimeImmutable
+	 */
+	private $now;
+
+	/**
+	 * The selector under test.
+	 *
+	 * @var RenewalSelector
+	 */
+	private $selector;
+
+	protected function setUp(): void {
+		parent::setUp();
+		$this->now      = new DateTimeImmutable( '2026-03-01 00:00:00', new DateTimeZone( 'UTC' ) );
+		$this->selector = new RenewalSelector();
+	}
+
+	/**
+	 * @param int    $count   Head chargeable count.
+	 * @param string $status  Head status.
+	 * @param string $ends_at Head period end (GMT string).
+	 */
+	private function candidate( int $count, string $status, string $ends_at ): RenewalCandidate {
+		return new RenewalCandidate( 42, $count, $status, $ends_at );
+	}
+
+	public function test_advances_to_the_next_cycle_when_a_billed_head_period_has_ended(): void {
+		$this->assertSame( 2, $this->selector->select_scheduled_cycle( $this->candidate( 1, CycleStatus::BILLED, '2026-02-01 00:00:00' ), $this->now ) );
+	}
+
+	public function test_does_not_advance_when_the_billed_head_period_has_not_ended(): void {
+		// The charge-ahead guard: a just-billed head whose period runs into the future is not
+		// yet due for its successor.
+		$this->assertNull( $this->selector->select_scheduled_cycle( $this->candidate( 1, CycleStatus::BILLED, '2026-04-01 00:00:00' ), $this->now ) );
+	}
+
+	public function test_advances_on_the_exact_period_boundary(): void {
+		// ends_at == now: the period has ended, so the successor is due.
+		$this->assertSame( 4, $this->selector->select_scheduled_cycle( $this->candidate( 3, CycleStatus::BILLED, '2026-03-01 00:00:00' ), $this->now ) );
+	}
+
+	public function test_advances_past_a_cancelled_head_that_has_ended(): void {
+		$this->assertSame( 6, $this->selector->select_scheduled_cycle( $this->candidate( 5, CycleStatus::CANCELLED, '2026-02-01 00:00:00' ), $this->now ) );
+	}
+
+	public function test_retries_the_same_cycle_when_the_head_is_still_pending(): void {
+		// A pending head only reaches the selector via the scan once its lease has expired; the
+		// money-path reclaims it. Selection targets the same count, not the next.
+		$this->assertSame( 7, $this->selector->select_scheduled_cycle( $this->candidate( 7, CycleStatus::PENDING, '2026-02-01 00:00:00' ), $this->now ) );
+	}
+
+	public function test_skips_a_failed_head(): void {
+		$this->assertNull( $this->selector->select_scheduled_cycle( $this->candidate( 2, CycleStatus::FAILED, '2026-02-01 00:00:00' ), $this->now ) );
+	}
+
+	public function test_skips_a_processing_head(): void {
+		$this->assertNull( $this->selector->select_scheduled_cycle( $this->candidate( 2, CycleStatus::PROCESSING, '2026-02-01 00:00:00' ), $this->now ) );
+	}
+
+	public function test_skips_a_countless_head(): void {
+		$this->assertNull( $this->selector->select_scheduled_cycle( new RenewalCandidate( 42, null, CycleStatus::BILLED, '2026-02-01 00:00:00' ), $this->now ) );
+	}
+
+	public function test_manual_forces_the_next_cycle_regardless_of_the_due_date(): void {
+		// A billed head whose period runs into the future would be skipped by the scheduled path,
+		// but the admin path forces the next cycle anyway (no due-guard).
+		$this->assertSame( 2, $this->selector->select_manual_cycle( $this->candidate( 1, CycleStatus::BILLED, '2026-04-01 00:00:00' ) ) );
+	}
+
+	public function test_manual_forces_the_next_cycle_past_a_cancelled_head(): void {
+		$this->assertSame( 6, $this->selector->select_manual_cycle( $this->candidate( 5, CycleStatus::CANCELLED, '2026-04-01 00:00:00' ) ) );
+	}
+
+	public function test_manual_retries_a_failed_head(): void {
+		// The scheduled path skips a failed head (awaits dunning); the admin path retries it.
+		$this->assertSame( 2, $this->selector->select_manual_cycle( $this->candidate( 2, CycleStatus::FAILED, '2026-02-01 00:00:00' ) ) );
+	}
+
+	public function test_manual_retries_a_pending_head(): void {
+		$this->assertSame( 7, $this->selector->select_manual_cycle( $this->candidate( 7, CycleStatus::PENDING, '2026-02-01 00:00:00' ) ) );
+	}
+
+	public function test_manual_skips_a_processing_head(): void {
+		// A manual trigger cannot preempt an in-flight async charge.
+		$this->assertNull( $this->selector->select_manual_cycle( $this->candidate( 2, CycleStatus::PROCESSING, '2026-02-01 00:00:00' ) ) );
+	}
+
+	public function test_manual_skips_a_countless_head(): void {
+		$this->assertNull( $this->selector->select_manual_cycle( new RenewalCandidate( 42, null, CycleStatus::BILLED, '2026-02-01 00:00:00' ) ) );
+	}
+}