Commit 15cbfa4d9b4 for woocommerce

commit 15cbfa4d9b4ec34a0ee2431b017b73cdbbc138cc
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date:   Wed Jun 24 16:41:30 2026 +0200

    Add subscriptions engine cycle-chain schema (#65915)

    - Make the contract the live source of truth (schedule, latest snapshot refs, totals, stamps); record each billing as an immutable cycle on a `(contract_id, kind)` chain - no chains table, counters derived from the rows
    - Dedupe plan and items snapshots by copy-forward into a per-contract typed table
    - Add the Core entities (Contract, Cycle, CycleStatus, snapshot value objects) and the Integration layer (SchemaInstaller, ContractRepository, ContractFactory), with DECIMAL(26,8) money and composite-UNIQUE charge idempotency
    - Cover with Core unit and wp-env integration tests; PHPStan level 9 clean

diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-cycle-chain-schema b/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-cycle-chain-schema
new file mode 100644
index 00000000000..276471a3c1a
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-cycle-chain-schema
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Add the cycle-chain data model: a live contract as the source of truth holding its latest snapshot references, immutable per-billing cycles on a chain as (contract_id, kind), and per-contract snapshots with copy-forward reuse, persisted through a contract repository with targeted cycle access.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
index 8924701baf5..db38fb2d872 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
@@ -1,12 +1,21 @@
 <?php
 /**
- * Contract - the stable, customer-facing identity of a subscription. Manages
- * core data for the subscription and enforces lifecycle transitions through
- * {@see ContractStatus}.
+ * Contract - the stable identity of a subscription and the live source of truth
+ * for its current state. Enforces lifecycle transitions through {@see ContractStatus}.
  *
- * Money totals are kept as decimal-safe strings; timestamps are GMT strings
- * (`Y-m-d H:i:s`). The payment instrument is exposed as an {@see InstrumentRef}
- * rather than a live payment token.
+ * Being the live source of truth (mutable), it holds the live schedule
+ * (`next_payment_gmt`), the latest snapshot references (`plan_snapshot_id` /
+ * `items_snapshot_id`), and the live config values (the `*_total` totals and the
+ * `*_gmt` stamps). These are live values, not caches of cycles: sync flows one way
+ * down - a live change repoints the contract's snapshot, and a billing cycle freezes
+ * whatever the contract points at now - never cycle -> contract.
+ *
+ * It holds no cycle graph in memory (cycles are fetched on demand), and a chain is
+ * just the pair `(contract_id, kind)` with its counters derived from the cycle rows.
+ * `origin_order_id` is nullable (a manual contract has none; for a checkout contract
+ * it equals cycle 1's `order_id`). Timestamps are GMT strings; money totals are
+ * decimal-safe strings on the storage scale; the payment instrument is exposed as an
+ * {@see InstrumentRef}.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
  */
@@ -16,8 +25,9 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;

 use DomainException;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\InstrumentRef;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\MoneyScale;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\InstrumentRef;

 defined( 'ABSPATH' ) || exit;

@@ -30,6 +40,7 @@ defined( 'ABSPATH' ) || exit;
 final class Contract {

 	use ScalarCoercion;
+	use MoneyScale;

 	const SCHEDULE_SOURCE_PRIMITIVE = 'primitive';
 	const SCHEDULE_SOURCE_GATEWAY   = 'gateway';
@@ -73,14 +84,15 @@ final class Contract {
 	private $selling_plan_id;

 	/**
-	 * Foreign key to the order that triggered this contract.
+	 * Origin order id, or null for a manual contract. Equals cycle 1's `order_id`
+	 * for a checkout contract.
 	 *
-	 * @var int
+	 * @var int|null
 	 */
 	private $origin_order_id;

 	/**
-	 * Owning extension slug, or null until owner semantics are assigned.
+	 * Owning extension slug, or null.
 	 *
 	 * @var string|null
 	 */
@@ -108,82 +120,90 @@ final class Contract {
 	private $payment_token_id;

 	/**
-	 * Recurring total per cycle (decimal-safe string).
+	 * When the contract goes (or went) active. GMT string.
 	 *
 	 * @var string
 	 */
-	private $billing_total;
+	private $start_gmt;

 	/**
-	 * Recurring discount per cycle (decimal-safe string).
+	 * Live schedule: when the next renewal fires, or null. GMT string. The due scan
+	 * keys on this; a billing cycle freezes its period from it.
 	 *
-	 * @var string
+	 * @var string|null
 	 */
-	private $discount_total;
+	private $next_payment_gmt;

 	/**
-	 * Recurring shipping per cycle (decimal-safe string).
+	 * Latest/live plan snapshot row id, or null.
+	 *
+	 * @var int|null
+	 */
+	private $plan_snapshot_id;
+
+	/**
+	 * Latest/live items snapshot row id, or null until one is recorded.
+	 *
+	 * @var int|null
+	 */
+	private $items_snapshot_id;
+
+	/**
+	 * Live billing total (the recurring amount), a decimal-safe string.
 	 *
 	 * @var string
 	 */
-	private $shipping_total;
+	private $billing_total;

 	/**
-	 * Recurring tax per cycle (decimal-safe string).
+	 * Live discount total, a decimal-safe string.
 	 *
 	 * @var string
 	 */
-	private $tax_total;
+	private $discount_total;

 	/**
-	 * When the contract goes (or went) active. GMT string.
+	 * Live shipping total, a decimal-safe string.
 	 *
 	 * @var string
 	 */
-	private $start_gmt;
+	private $shipping_total;

 	/**
-	 * Next renewal attempt, or null. GMT string.
+	 * Live tax total, a decimal-safe string.
 	 *
-	 * @var string|null
+	 * @var string
 	 */
-	private $next_payment_gmt;
+	private $tax_total;

 	/**
-	 * Last successful renewal payment, or null. GMT string.
+	 * When the contract last billed successfully, or null. GMT string.
 	 *
 	 * @var string|null
 	 */
 	private $last_payment_gmt;

 	/**
-	 * Last attempted renewal cycle regardless of outcome, or null. GMT string.
+	 * When the contract last attempted a charge, or null. GMT string.
 	 *
 	 * @var string|null
 	 */
 	private $last_attempt_gmt;

 	/**
-	 * End of trial window, or null. GMT string.
+	 * When the contract's trial ends (or ended), or null. GMT string.
 	 *
 	 * @var string|null
 	 */
 	private $trial_end_gmt;

 	/**
-	 * Hard end (cancelled / expired / max_cycles reached), or null. GMT string.
+	 * When the contract ends (or ended), or null. GMT string.
 	 *
 	 * @var string|null
 	 */
 	private $end_gmt;

-	/**
-	 * Count of successfully-paid renewal cycles.
-	 *
-	 * @var int
-	 */
-	private $cycle_count;
-
 	/**
 	 * Who runs renewals: 'primitive' (this engine) or 'gateway'.
 	 *
@@ -213,182 +233,61 @@ final class Contract {
 	private $meta;

 	/**
-	 * Use {@see self::create()} or {@see self::from_storage()}.
-	 *
-	 * @param array<string, mixed> $fields Internal field map.
-	 */
-	private function __construct( array $fields ) {
-		$this->id                   = self::coerce_nullable_int( $fields['id'] ?? null );
-		$this->status               = self::coerce_string( $fields['status'] ?? null );
-		$this->customer_id          = self::coerce_int( $fields['customer_id'] ?? null );
-		$this->currency             = self::coerce_string( $fields['currency'] ?? null );
-		$this->selling_plan_id      = self::coerce_int( $fields['selling_plan_id'] ?? null );
-		$this->origin_order_id      = self::coerce_int( $fields['origin_order_id'] ?? null );
-		$this->extension_slug       = self::coerce_nullable_string( $fields['extension_slug'] ?? null );
-		$this->payment_method       = self::coerce_nullable_string( $fields['payment_method'] ?? null );
-		$this->payment_method_title = self::coerce_nullable_string( $fields['payment_method_title'] ?? null );
-		$this->payment_token_id     = self::coerce_nullable_int( $fields['payment_token_id'] ?? null );
-		$this->billing_total        = self::coerce_string( $fields['billing_total'] ?? null, '0' );
-		$this->discount_total       = self::coerce_string( $fields['discount_total'] ?? null, '0' );
-		$this->shipping_total       = self::coerce_string( $fields['shipping_total'] ?? null, '0' );
-		$this->tax_total            = self::coerce_string( $fields['tax_total'] ?? null, '0' );
-		$this->start_gmt            = self::coerce_string( $fields['start_gmt'] ?? null );
-		$this->next_payment_gmt     = self::coerce_nullable_string( $fields['next_payment_gmt'] ?? null );
-		$this->last_payment_gmt     = self::coerce_nullable_string( $fields['last_payment_gmt'] ?? null );
-		$this->last_attempt_gmt     = self::coerce_nullable_string( $fields['last_attempt_gmt'] ?? null );
-		$this->trial_end_gmt        = self::coerce_nullable_string( $fields['trial_end_gmt'] ?? null );
-		$this->end_gmt              = self::coerce_nullable_string( $fields['end_gmt'] ?? null );
-		$this->cycle_count          = self::coerce_int( $fields['cycle_count'] ?? null );
-		$this->schedule_source      = self::coerce_string( $fields['schedule_source'] ?? null );
-		$this->items                = self::coerce_items( $fields['items'] ?? null );
-		$this->addresses            = self::coerce_addresses( $fields['addresses'] ?? null );
-		$this->meta                 = self::coerce_meta( $fields['meta'] ?? null );
-	}
-
-	/**
-	 * Filter raw items to retain only array-valued entries.
-	 *
-	 * Individual item field structure is not normalized here; each kept entry is
-	 * passed through as-is.
-	 *
-	 * @param mixed $value Raw items value.
-	 * @return array<int, array<string, mixed>>
-	 */
-	private static function coerce_items( $value ): array {
-		$items = array();
-		if ( is_array( $value ) ) {
-			foreach ( $value as $item ) {
-				if ( is_array( $item ) ) {
-					$items[] = $item;
-				}
-			}
-		}
-		return $items;
-	}
-
-	/**
-	 * Coerce raw address rows to a type => fields map.
-	 *
-	 * A flat passthrough keyed by address type: only the types actually present
-	 * (and array-valued) are kept, so a contract with no addresses round-trips to
-	 * an empty map rather than two empty billing/shipping rows.
-	 *
-	 * @param mixed $value Raw addresses value.
-	 * @return array<string, array<string, mixed>>
-	 */
-	private static function coerce_addresses( $value ): array {
-		$addresses = array();
-		if ( is_array( $value ) ) {
-			foreach ( $value as $type => $address ) {
-				if ( is_array( $address ) ) {
-					$addresses[ (string) $type ] = $address;
-				}
-			}
-		}
-		return $addresses;
-	}
-
-	/**
-	 * Coerce raw meta to a string => string map.
-	 *
-	 * @param mixed $value Raw meta value.
-	 * @return array<string, string>
-	 */
-	private static function coerce_meta( $value ): array {
-		$meta = array();
-		if ( is_array( $value ) ) {
-			foreach ( $value as $key => $val ) {
-				$meta[ (string) $key ] = is_scalar( $val ) ? (string) $val : '';
-			}
-		}
-		return $meta;
+	 * Use {@see self::create()} or {@see self::from_storage()}. Coerces each attribute
+	 * to its property type; unknown keys are ignored, missing keys take the default.
+	 *
+	 * @param array<string, mixed> $data Raw attributes keyed by property name.
+	 */
+	private function __construct( array $data ) {
+		$this->id                   = self::coerce_nullable_int( $data['id'] ?? null );
+		$this->status               = self::coerce_string( $data['status'] ?? null, ContractStatus::ACTIVE );
+		$this->customer_id          = self::coerce_int( $data['customer_id'] ?? null );
+		$this->currency             = self::coerce_string( $data['currency'] ?? null );
+		$this->selling_plan_id      = self::coerce_int( $data['selling_plan_id'] ?? null );
+		$this->origin_order_id      = self::coerce_nullable_int( $data['origin_order_id'] ?? null );
+		$this->extension_slug       = self::coerce_nullable_string( $data['extension_slug'] ?? null );
+		$this->payment_method       = self::coerce_nullable_string( $data['payment_method'] ?? null );
+		$this->payment_method_title = self::coerce_nullable_string( $data['payment_method_title'] ?? null );
+		$this->payment_token_id     = self::coerce_nullable_int( $data['payment_token_id'] ?? null );
+		$this->start_gmt            = self::coerce_string( $data['start_gmt'] ?? null );
+		$this->next_payment_gmt     = self::coerce_nullable_string( $data['next_payment_gmt'] ?? null );
+		$this->plan_snapshot_id     = self::coerce_nullable_int( $data['plan_snapshot_id'] ?? null );
+		$this->items_snapshot_id    = self::coerce_nullable_int( $data['items_snapshot_id'] ?? null );
+		$this->billing_total        = self::normalize_money( $data['billing_total'] ?? '0' );
+		$this->discount_total       = self::normalize_money( $data['discount_total'] ?? '0' );
+		$this->shipping_total       = self::normalize_money( $data['shipping_total'] ?? '0' );
+		$this->tax_total            = self::normalize_money( $data['tax_total'] ?? '0' );
+		$this->last_payment_gmt     = self::coerce_nullable_string( $data['last_payment_gmt'] ?? null );
+		$this->last_attempt_gmt     = self::coerce_nullable_string( $data['last_attempt_gmt'] ?? null );
+		$this->trial_end_gmt        = self::coerce_nullable_string( $data['trial_end_gmt'] ?? null );
+		$this->end_gmt              = self::coerce_nullable_string( $data['end_gmt'] ?? null );
+		$this->schedule_source      = self::coerce_string( $data['schedule_source'] ?? null, self::SCHEDULE_SOURCE_PRIMITIVE );
+		$this->items                = self::coerce_item_rows( $data['items'] ?? null );
+		$this->addresses            = self::coerce_address_map( $data['addresses'] ?? null );
+		$this->meta                 = self::coerce_meta_map( $data['meta'] ?? null );
 	}

 	/**
 	 * Build a new, unsaved contract.
 	 *
-	 * Required: `selling_plan_id` and `origin_order_id` (positive integers),
-	 * `customer_id` (a non-negative integer; 0 is a guest), and `currency`,
-	 * `start_gmt` (non-empty strings). These are validated up front so an omitted
-	 * field fails loud here rather than coercing to a silent `0`/`''` and producing
-	 * an observably invalid contract. All other fields are optional and default in
-	 * the constructor.
-	 *
 	 * @param array<string, mixed> $args Contract attributes.
-	 * @throws DomainException If a required attribute is missing or invalid.
+	 * @throws DomainException If the contract attributes are not valid.
 	 */
 	public static function create( array $args ): self {
-		// customer_id 0 is a valid guest contract, so it is non-negative rather
-		// than strictly positive; an absent key is still rejected (null is not numeric).
-		$customer_id = $args['customer_id'] ?? null;
-		if ( ! is_numeric( $customer_id ) || (int) $customer_id < 0 ) {
-			throw new DomainException(
-				'Contract: customer_id is required and must be a non-negative integer.'
-			);
-		}
+		// A new contract is always unsaved; never adopt a caller-supplied id.
+		unset( $args['id'] );

-		foreach ( array( 'selling_plan_id', 'origin_order_id' ) as $required_id ) {
-			$value = $args[ $required_id ] ?? null;
-			if ( ! is_numeric( $value ) || (int) $value <= 0 ) {
-				throw new DomainException(
-					sprintf( 'Contract: %s is required and must be a positive integer.', $required_id )
-				);
-			}
-		}
-
-		foreach ( array( 'currency', 'start_gmt' ) as $required_string ) {
-			$value = $args[ $required_string ] ?? null;
-			if ( ! is_scalar( $value ) || '' === (string) $value ) {
-				throw new DomainException(
-					sprintf( 'Contract: %s is required and must be a non-empty string.', $required_string )
-				);
-			}
-		}
+		$contract = new self( $args );

-		$status = self::coerce_string( $args['status'] ?? null, ContractStatus::ACTIVE );
-		if ( ! ContractStatus::is_valid( $status ) ) {
-			throw new DomainException(
-				sprintf( 'Contract: invalid status "%s".', $status )
-			);
+		if ( ! ContractStatus::is_valid( $contract->status ) ) {
+			throw new DomainException( sprintf( 'Contract: invalid status "%s".', $contract->status ) );
 		}

-		$schedule_source = self::coerce_string( $args['schedule_source'] ?? null, self::SCHEDULE_SOURCE_PRIMITIVE );
-		if ( ! in_array( $schedule_source, array( self::SCHEDULE_SOURCE_PRIMITIVE, self::SCHEDULE_SOURCE_GATEWAY ), true ) ) {
-			throw new DomainException(
-				sprintf( 'Contract: invalid schedule source "%s".', $schedule_source )
-			);
+		if ( ! in_array( $contract->schedule_source, array( self::SCHEDULE_SOURCE_PRIMITIVE, self::SCHEDULE_SOURCE_GATEWAY ), true ) ) {
+			throw new DomainException( sprintf( 'Contract: invalid schedule source "%s".', $contract->schedule_source ) );
 		}

-		// The constructor is the single coercion boundary; pass raw values through.
-		return new self(
-			array(
-				'id'                   => null,
-				'status'               => $status,
-				'customer_id'          => $args['customer_id'] ?? null,
-				'currency'             => $args['currency'] ?? null,
-				'selling_plan_id'      => $args['selling_plan_id'] ?? null,
-				'origin_order_id'      => $args['origin_order_id'] ?? null,
-				'extension_slug'       => $args['extension_slug'] ?? null,
-				'payment_method'       => $args['payment_method'] ?? null,
-				'payment_method_title' => $args['payment_method_title'] ?? null,
-				'payment_token_id'     => $args['payment_token_id'] ?? null,
-				'billing_total'        => $args['billing_total'] ?? null,
-				'discount_total'       => $args['discount_total'] ?? null,
-				'shipping_total'       => $args['shipping_total'] ?? null,
-				'tax_total'            => $args['tax_total'] ?? null,
-				'start_gmt'            => $args['start_gmt'] ?? null,
-				'next_payment_gmt'     => $args['next_payment_gmt'] ?? null,
-				'last_payment_gmt'     => null,
-				'last_attempt_gmt'     => null,
-				'trial_end_gmt'        => $args['trial_end_gmt'] ?? null,
-				'end_gmt'              => null,
-				'cycle_count'          => 0,
-				'schedule_source'      => $schedule_source,
-				'items'                => $args['items'] ?? null,
-				'addresses'            => $args['addresses'] ?? null,
-				'meta'                 => $args['meta'] ?? null,
-			)
-		);
+		return $contract;
 	}

 	/**
@@ -398,60 +297,16 @@ final class Contract {
 	 * @param array<int, array<string, mixed>>    $items     Item rows.
 	 * @param array<string, array<string, mixed>> $addresses Address rows keyed by type.
 	 * @param array<string, string>               $meta      Meta as key => value.
-	 * @throws DomainException If the stored cycle_count or schedule_source is invalid.
 	 */
 	public static function from_storage( array $row, array $items = array(), array $addresses = array(), array $meta = array() ): self {
-		// Hydration is a trust boundary: a WordPress database can be mutated outside
-		// this engine's flows, and these fields drive money and scheduling math. A
-		// corrupted cycle_count or schedule_source is rejected loudly here rather
-		// than silently mis-charging a renewal or mis-routing the schedule.
-		$cycle_count = self::coerce_nullable_int( $row['cycle_count'] ?? null );
-		if ( null === $cycle_count ) {
-			throw new DomainException(
-				sprintf( 'Contract: stored cycle_count must be an integer, got %s.', gettype( $row['cycle_count'] ?? null ) )
-			);
-		}
-		if ( $cycle_count < 0 ) {
-			throw new DomainException(
-				sprintf( 'Contract: stored cycle_count must be 0 or greater, got %d.', $cycle_count )
-			);
-		}
-
-		$schedule_source = self::coerce_string( $row['schedule_source'] ?? null, self::SCHEDULE_SOURCE_PRIMITIVE );
-		if ( ! in_array( $schedule_source, array( self::SCHEDULE_SOURCE_PRIMITIVE, self::SCHEDULE_SOURCE_GATEWAY ), true ) ) {
-			throw new DomainException(
-				sprintf( 'Contract: stored schedule source must be primitive or gateway, got "%s".', $schedule_source )
-			);
-		}
-
-		// The constructor is the single coercion boundary; pass raw row values through.
 		return new self(
-			array(
-				'id'                   => $row['id'] ?? null,
-				'status'               => $row['status'] ?? null,
-				'customer_id'          => $row['customer_id'] ?? null,
-				'currency'             => $row['currency'] ?? null,
-				'selling_plan_id'      => $row['selling_plan_id'] ?? null,
-				'origin_order_id'      => $row['origin_order_id'] ?? null,
-				'extension_slug'       => $row['extension_slug'] ?? null,
-				'payment_method'       => $row['payment_method'] ?? null,
-				'payment_method_title' => $row['payment_method_title'] ?? null,
-				'payment_token_id'     => $row['payment_token_id'] ?? null,
-				'billing_total'        => $row['billing_total'] ?? null,
-				'discount_total'       => $row['discount_total'] ?? null,
-				'shipping_total'       => $row['shipping_total'] ?? null,
-				'tax_total'            => $row['tax_total'] ?? null,
-				'start_gmt'            => $row['start_gmt'] ?? null,
-				'next_payment_gmt'     => $row['next_payment_gmt'] ?? null,
-				'last_payment_gmt'     => $row['last_payment_gmt'] ?? null,
-				'last_attempt_gmt'     => $row['last_attempt_gmt'] ?? null,
-				'trial_end_gmt'        => $row['trial_end_gmt'] ?? null,
-				'end_gmt'              => $row['end_gmt'] ?? null,
-				'cycle_count'          => $cycle_count,
-				'schedule_source'      => $schedule_source,
-				'items'                => $items,
-				'addresses'            => $addresses,
-				'meta'                 => $meta,
+			array_merge(
+				$row,
+				array(
+					'items'     => $items,
+					'addresses' => $addresses,
+					'meta'      => $meta,
+				)
 			)
 		);
 	}
@@ -517,9 +372,9 @@ final class Contract {
 	}

 	/**
-	 * Foreign key to the origin order.
+	 * Foreign key to the origin order, or null for a manual/admin contract.
 	 */
-	public function get_origin_order_id(): int {
+	public function get_origin_order_id(): ?int {
 		return $this->origin_order_id;
 	}

@@ -549,58 +404,126 @@ final class Contract {
 	}

 	/**
-	 * Recurring total per cycle (decimal-safe string).
+	 * Next renewal attempt, or null.
+	 */
+	public function get_next_payment_gmt(): ?string {
+		return $this->next_payment_gmt;
+	}
+
+	/**
+	 * Set the live schedule (when the next renewal fires).
+	 *
+	 * @param string|null $next_payment_gmt GMT string or null.
+	 */
+	public function set_next_payment_gmt( ?string $next_payment_gmt ): void {
+		$this->next_payment_gmt = $next_payment_gmt;
+	}
+
+	/**
+	 * Latest/live plan snapshot row id, or null.
+	 */
+	public function get_plan_snapshot_id(): ?int {
+		return $this->plan_snapshot_id;
+	}
+
+	/**
+	 * Set the latest/live plan snapshot row id.
+	 *
+	 * @param int|null $plan_snapshot_id Snapshot row id, or null.
+	 */
+	public function set_plan_snapshot_id( ?int $plan_snapshot_id ): void {
+		$this->plan_snapshot_id = $plan_snapshot_id;
+	}
+
+	/**
+	 * Latest/live items snapshot row id, or null.
+	 */
+	public function get_items_snapshot_id(): ?int {
+		return $this->items_snapshot_id;
+	}
+
+	/**
+	 * Set the latest/live items snapshot row id.
+	 *
+	 * @param int|null $items_snapshot_id Snapshot row id, or null.
+	 */
+	public function set_items_snapshot_id( ?int $items_snapshot_id ): void {
+		$this->items_snapshot_id = $items_snapshot_id;
+	}
+
+	/**
+	 * Live billing total (decimal-safe string).
 	 */
 	public function get_billing_total(): string {
 		return $this->billing_total;
 	}

 	/**
-	 * Recurring discount per cycle (decimal-safe string).
+	 * Set the live billing total, normalized to the storage scale.
+	 *
+	 * @param string $billing_total Money value (decimal string or number).
+	 */
+	public function set_billing_total( string $billing_total ): void {
+		$this->billing_total = self::normalize_money( $billing_total );
+	}
+
+	/**
+	 * Live discount total (decimal-safe string).
 	 */
 	public function get_discount_total(): string {
 		return $this->discount_total;
 	}

 	/**
-	 * Recurring shipping per cycle (decimal-safe string).
+	 * Set the live discount total, normalized to the storage scale.
+	 *
+	 * @param string $discount_total Money value (decimal string or number).
+	 */
+	public function set_discount_total( string $discount_total ): void {
+		$this->discount_total = self::normalize_money( $discount_total );
+	}
+
+	/**
+	 * Live shipping total (decimal-safe string).
 	 */
 	public function get_shipping_total(): string {
 		return $this->shipping_total;
 	}

 	/**
-	 * Recurring tax per cycle (decimal-safe string).
+	 * Set the live shipping total, normalized to the storage scale.
+	 *
+	 * @param string $shipping_total Money value (decimal string or number).
 	 */
-	public function get_tax_total(): string {
-		return $this->tax_total;
+	public function set_shipping_total( string $shipping_total ): void {
+		$this->shipping_total = self::normalize_money( $shipping_total );
 	}

 	/**
-	 * Next renewal attempt, or null.
+	 * Live tax total (decimal-safe string).
 	 */
-	public function get_next_payment_gmt(): ?string {
-		return $this->next_payment_gmt;
+	public function get_tax_total(): string {
+		return $this->tax_total;
 	}

 	/**
-	 * Set the next renewal attempt timestamp.
+	 * Set the live tax total, normalized to the storage scale.
 	 *
-	 * @param string|null $next_payment_gmt GMT string or null.
+	 * @param string $tax_total Money value (decimal string or number).
 	 */
-	public function set_next_payment_gmt( ?string $next_payment_gmt ): void {
-		$this->next_payment_gmt = $next_payment_gmt;
+	public function set_tax_total( string $tax_total ): void {
+		$this->tax_total = self::normalize_money( $tax_total );
 	}

 	/**
-	 * Last successful renewal payment, or null. GMT string.
+	 * When the contract last billed successfully, or null. GMT string.
 	 */
 	public function get_last_payment_gmt(): ?string {
 		return $this->last_payment_gmt;
 	}

 	/**
-	 * Set the last successful renewal payment timestamp.
+	 * Set when the contract last billed successfully.
 	 *
 	 * @param string|null $last_payment_gmt GMT string or null.
 	 */
@@ -609,39 +532,58 @@ final class Contract {
 	}

 	/**
-	 * Start timestamp (GMT string).
+	 * When the contract last attempted a charge, or null. GMT string.
 	 */
-	public function get_start_gmt(): string {
-		return $this->start_gmt;
+	public function get_last_attempt_gmt(): ?string {
+		return $this->last_attempt_gmt;
+	}
+
+	/**
+	 * Set when the contract last attempted a charge.
+	 *
+	 * @param string|null $last_attempt_gmt GMT string or null.
+	 */
+	public function set_last_attempt_gmt( ?string $last_attempt_gmt ): void {
+		$this->last_attempt_gmt = $last_attempt_gmt;
 	}

 	/**
-	 * Count of successfully-paid renewal cycles.
+	 * When the contract's trial ends (or ended), or null. GMT string.
 	 */
-	public function get_cycle_count(): int {
-		return $this->cycle_count;
+	public function get_trial_end_gmt(): ?string {
+		return $this->trial_end_gmt;
 	}

 	/**
-	 * Set the count of successfully-paid renewal cycles.
+	 * Set when the contract's trial ends.
 	 *
-	 * The renewal money-path advances this under a per-cycle idempotency guard,
-	 * so the read-modify-write happens once per cycle and a plain setter is
-	 * safe. An atomic, server-side increment becomes necessary once renewal
-	 * accounting is driven by concurrent gateway webhooks (the cycles/attempts
-	 * reshape); until then this is the simpler shape.
+	 * @param string|null $trial_end_gmt GMT string or null.
+	 */
+	public function set_trial_end_gmt( ?string $trial_end_gmt ): void {
+		$this->trial_end_gmt = $trial_end_gmt;
+	}
+
+	/**
+	 * When the contract ends (or ended), or null. GMT string.
+	 */
+	public function get_end_gmt(): ?string {
+		return $this->end_gmt;
+	}
+
+	/**
+	 * Set when the contract ends.
 	 *
-	 * @param int $cycle_count New cycle count.
-	 * @throws DomainException If `$cycle_count` is negative.
+	 * @param string|null $end_gmt GMT string or null.
 	 */
-	public function set_cycle_count( int $cycle_count ): void {
-		if ( $cycle_count < 0 ) {
-			throw new DomainException(
-				sprintf( 'Contract: cycle_count must be 0 or greater, got %d.', $cycle_count )
-			);
-		}
+	public function set_end_gmt( ?string $end_gmt ): void {
+		$this->end_gmt = $end_gmt;
+	}

-		$this->cycle_count = $cycle_count;
+	/**
+	 * Start timestamp (GMT string).
+	 */
+	public function get_start_gmt(): string {
+		return $this->start_gmt;
 	}

 	/**
@@ -694,18 +636,99 @@ final class Contract {
 			'payment_method'       => $this->payment_method,
 			'payment_method_title' => $this->payment_method_title,
 			'payment_token_id'     => $this->payment_token_id,
+			'start_gmt'            => $this->start_gmt,
+			'next_payment_gmt'     => $this->next_payment_gmt,
+			'plan_snapshot_id'     => $this->plan_snapshot_id,
+			'items_snapshot_id'    => $this->items_snapshot_id,
 			'billing_total'        => $this->billing_total,
 			'discount_total'       => $this->discount_total,
 			'shipping_total'       => $this->shipping_total,
 			'tax_total'            => $this->tax_total,
-			'start_gmt'            => $this->start_gmt,
-			'next_payment_gmt'     => $this->next_payment_gmt,
 			'last_payment_gmt'     => $this->last_payment_gmt,
 			'last_attempt_gmt'     => $this->last_attempt_gmt,
 			'trial_end_gmt'        => $this->trial_end_gmt,
 			'end_gmt'              => $this->end_gmt,
-			'cycle_count'          => $this->cycle_count,
 			'schedule_source'      => $this->schedule_source,
 		);
 	}
+
+	/**
+	 * Shape a caller-supplied value into the line-item row list. A non-array yields
+	 * no items; non-array elements are skipped.
+	 *
+	 * @param mixed $value Caller-supplied items.
+	 * @return array<int, array<string, mixed>>
+	 */
+	private static function coerce_item_rows( $value ): array {
+		if ( ! is_array( $value ) ) {
+			return array();
+		}
+
+		$rows = array();
+		foreach ( $value as $row ) {
+			if ( is_array( $row ) ) {
+				$rows[] = self::coerce_string_keyed( $row );
+			}
+		}
+
+		return $rows;
+	}
+
+	/**
+	 * Shape a caller-supplied value into the addresses map keyed by type. A non-array
+	 * yields an empty map; non-array elements are skipped.
+	 *
+	 * @param mixed $value Caller-supplied addresses.
+	 * @return array<string, array<string, mixed>>
+	 */
+	private static function coerce_address_map( $value ): array {
+		if ( ! is_array( $value ) ) {
+			return array();
+		}
+
+		$map = array();
+		foreach ( $value as $type => $address ) {
+			if ( is_array( $address ) ) {
+				$map[ (string) $type ] = self::coerce_string_keyed( $address );
+			}
+		}
+
+		return $map;
+	}
+
+	/**
+	 * Shape a caller-supplied value into the meta map (string => string). A non-array
+	 * yields an empty map.
+	 *
+	 * @param mixed $value Caller-supplied meta.
+	 * @return array<string, string>
+	 */
+	private static function coerce_meta_map( $value ): array {
+		if ( ! is_array( $value ) ) {
+			return array();
+		}
+
+		$map = array();
+		foreach ( $value as $key => $meta_value ) {
+			$map[ (string) $key ] = self::coerce_string( $meta_value );
+		}
+
+		return $map;
+	}
+
+	/**
+	 * Re-key an array as a string-keyed map, recovering the `array<string, mixed>`
+	 * row shape from an otherwise `int|string`-keyed array.
+	 *
+	 * @param array<int|string, mixed> $value Array to re-key.
+	 * @return array<string, mixed>
+	 */
+	private static function coerce_string_keyed( array $value ): array {
+		$result = array();
+		foreach ( $value as $key => $entry ) {
+			$result[ (string) $key ] = $entry;
+		}
+
+		return $result;
+	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
new file mode 100644
index 00000000000..27e6782449a
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
@@ -0,0 +1,578 @@
+<?php
+/**
+ * Cycle - one period in a chain `(contract_id, kind)`, where a chain is the pair
+ * `(contract_id, kind)` with counters derived from its rows (not a stored entity).
+ *
+ * An immutable billing record frozen at billing (snapshot references, period
+ * boundaries, `expected_total`); only `status`, `order_id`, and `reason` may change
+ * afterwards. The `count` is the chargeable number and idempotency anchor, nullable
+ * for non-counting cycles (e.g. a future trial). Money is a decimal-safe string;
+ * timestamps are GMT strings (`Y-m-d H:i:s`).
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
+
+use DomainException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\MoneyScale;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Cycle entity.
+ *
+ * Construct via {@see self::create()} for a new (unsaved) cycle or
+ * {@see self::from_storage()} when hydrating a stored row.
+ */
+final class Cycle {
+
+	use ScalarCoercion;
+	use MoneyScale;
+
+	const KIND_BILLING = 'billing';
+
+	/**
+	 * Cycle id, or null before it is persisted.
+	 *
+	 * @var int|null
+	 */
+	private $id;
+
+	/**
+	 * Owning contract id.
+	 *
+	 * @var int
+	 */
+	private $contract_id;
+
+	/**
+	 * Position within the chain, monotonic from 1.
+	 *
+	 * @var int
+	 */
+	private $sequence_no;
+
+	/**
+	 * Chargeable number and per-charge idempotency anchor, or null for a
+	 * non-counting cycle.
+	 *
+	 * @var int|null
+	 */
+	private $count;
+
+	/**
+	 * Chain kind. Known-but-extensible; only the billing kind is written for now.
+	 *
+	 * @var string
+	 */
+	private $kind;
+
+	/**
+	 * Lifecycle status. See {@see CycleStatus}.
+	 *
+	 * @var CycleStatus
+	 */
+	private $status;
+
+	/**
+	 * Open-ended annotation, or null.
+	 *
+	 * @var string|null
+	 */
+	private $reason;
+
+	/**
+	 * Period start (and, billing-in-advance, the charge date). GMT string.
+	 *
+	 * @var string
+	 */
+	private $starts_at_gmt;
+
+	/**
+	 * Period end. GMT string.
+	 *
+	 * @var string
+	 */
+	private $ends_at_gmt;
+
+	/**
+	 * Amount expected to be billed for this cycle (decimal-safe string).
+	 *
+	 * @var string
+	 */
+	private $expected_total;
+
+	/**
+	 * ISO-4217 currency code.
+	 *
+	 * @var string
+	 */
+	private $currency;
+
+	/**
+	 * Plan snapshot row id, or null until the snapshot is stored.
+	 *
+	 * @var int|null
+	 */
+	private $plan_snapshot_id;
+
+	/**
+	 * Items snapshot row id, or null until the snapshot is stored.
+	 *
+	 * @var int|null
+	 */
+	private $items_snapshot_id;
+
+	/**
+	 * Linked order id (billing or shipping), or null. Not 1:1 - an aggregate order
+	 * may serve many cycles.
+	 *
+	 * @var int|null
+	 */
+	private $order_id;
+
+	/**
+	 * Owning extension slug, or null.
+	 *
+	 * @var string|null
+	 */
+	private $extension_slug;
+
+	/**
+	 * Typed plan snapshot held in memory, or null.
+	 *
+	 * @var PlanSnapshot|null
+	 */
+	private $plan_snapshot;
+
+	/**
+	 * Typed items snapshot held in memory, or null.
+	 *
+	 * @var ItemsSnapshot|null
+	 */
+	private $items_snapshot;
+
+	/**
+	 * Use {@see self::create()} or {@see self::from_storage()}. Coerces each attribute
+	 * to its property type; unknown keys are ignored, missing keys take the default.
+	 *
+	 * @param array<string, mixed> $data Raw attributes keyed by property name.
+	 */
+	private function __construct( array $data ) {
+		$this->id                = self::coerce_nullable_int( $data['id'] ?? null );
+		$this->contract_id       = self::coerce_int( $data['contract_id'] ?? null );
+		$this->sequence_no       = self::coerce_int( $data['sequence_no'] ?? null );
+		$this->count             = isset( $data['count'] ) ? self::coerce_int( $data['count'] ) : null;
+		$this->kind              = self::coerce_string( $data['kind'] ?? null, self::KIND_BILLING );
+		$this->status            = self::coerce_status( $data['status'] ?? null );
+		$this->reason            = self::coerce_nullable_string( $data['reason'] ?? null );
+		$this->starts_at_gmt     = self::coerce_string( $data['starts_at_gmt'] ?? null );
+		$this->ends_at_gmt       = self::coerce_string( $data['ends_at_gmt'] ?? null );
+		$this->expected_total    = self::normalize_money( $data['expected_total'] ?? '0' );
+		$this->currency          = self::coerce_string( $data['currency'] ?? null );
+		$this->plan_snapshot_id  = self::coerce_nullable_int( $data['plan_snapshot_id'] ?? null );
+		$this->items_snapshot_id = self::coerce_nullable_int( $data['items_snapshot_id'] ?? null );
+		$this->order_id          = self::coerce_nullable_int( $data['order_id'] ?? null );
+		$this->extension_slug    = self::coerce_nullable_string( $data['extension_slug'] ?? 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;
+	}
+
+	/**
+	 * Build a new, unsaved cycle.
+	 *
+	 * Required keys: `contract_id`, `sequence_no`, `starts_at_gmt`, `ends_at_gmt`,
+	 * `expected_total`, `currency`. Optional: `count` (defaults to 1; pass null for
+	 * a non-counting cycle), `status` (defaults to `pending`; a checkout signup
+	 * cycle is created directly `billed`), `kind` (defaults to billing), `reason`,
+	 * `order_id`, `extension_slug`, `plan_snapshot_id`, `items_snapshot_id`,
+	 * `plan_snapshot`, `items_snapshot`.
+	 *
+	 * @param array<string, mixed> $args Cycle attributes.
+	 * @throws DomainException If the attributes are not valid.
+	 */
+	public static function create( array $args ): self {
+		if ( ! isset( $args['contract_id'] ) ) {
+			throw new DomainException( 'Cycle: contract_id is required.' );
+		}
+
+		// A new cycle is always unsaved; never adopt a caller-supplied id.
+		unset( $args['id'] );
+
+		// Absent count defaults to 1 (counting); explicit null means non-counting. `?? 1` would conflate them.
+		$args['count'] = self::normalize_count( array_key_exists( 'count', $args ) ? $args['count'] : 1 );
+
+		$cycle = new self( $args );
+
+		self::assert_valid_kind( $cycle->kind );
+		self::assert_valid_sequence_no( $cycle->sequence_no );
+
+		return $cycle;
+	}
+
+	/**
+	 * Hydrate from a stored row.
+	 *
+	 * @param array<string, mixed> $row Cycle row.
+	 * @throws DomainException If the stored status, kind, or sequence_no is invalid.
+	 */
+	public static function from_storage( array $row ): self {
+		$kind = self::coerce_string( $row['kind'] ?? null, self::KIND_BILLING );
+		self::assert_valid_kind( $kind );
+
+		$sequence_no = self::coerce_int( $row['sequence_no'] ?? null );
+		self::assert_valid_sequence_no( $sequence_no );
+
+		// The typed snapshot value objects are attached on load, never hydrated here.
+		unset( $row['plan_snapshot'], $row['items_snapshot'] );
+
+		$row['count'] = array_key_exists( 'count', $row ) ? self::normalize_count( $row['count'] ) : null;
+
+		return new self( $row );
+	}
+
+	/**
+	 * Cycle id, or null before save.
+	 */
+	public function get_id(): ?int {
+		return $this->id;
+	}
+
+	/**
+	 * Assign the id after a successful insert.
+	 *
+	 * @param int $id Cycle id.
+	 */
+	public function set_id( int $id ): void {
+		$this->id = $id;
+	}
+
+	/**
+	 * Owning contract id.
+	 */
+	public function get_contract_id(): int {
+		return $this->contract_id;
+	}
+
+	/**
+	 * Set the owning contract id. A cycle may be built with a placeholder id (0)
+	 * before its contract is persisted; the repository stamps the real id later.
+	 *
+	 * @param int $contract_id Owning contract id.
+	 */
+	public function set_contract_id( int $contract_id ): void {
+		$this->contract_id = $contract_id;
+	}
+
+	/**
+	 * Position within the chain.
+	 */
+	public function get_sequence_no(): int {
+		return $this->sequence_no;
+	}
+
+	/**
+	 * Set the position within the chain (assigned by the append path).
+	 *
+	 * @param int $sequence_no Position, monotonic from 1.
+	 * @throws DomainException If `$sequence_no` is not positive.
+	 */
+	public function set_sequence_no( int $sequence_no ): void {
+		self::assert_valid_sequence_no( $sequence_no );
+
+		$this->sequence_no = $sequence_no;
+	}
+
+	/**
+	 * Chargeable number within the chain, or null for a non-counting cycle.
+	 */
+	public function get_count(): ?int {
+		return $this->count;
+	}
+
+	/**
+	 * Cycle kind.
+	 */
+	public function get_kind(): string {
+		return $this->kind;
+	}
+
+	/**
+	 * Lifecycle status.
+	 */
+	public function get_status(): CycleStatus {
+		return $this->status;
+	}
+
+	/**
+	 * Transition the cycle to a new status.
+	 *
+	 * @param CycleStatus $status Target status.
+	 * @throws DomainException If the transition is not allowed by CycleStatus.
+	 */
+	public function set_status( CycleStatus $status ): void {
+		if ( $this->status->equals( $status ) ) {
+			return;
+		}
+
+		$this->status = $this->status->transition_to( $status );
+	}
+
+	/**
+	 * Open-ended annotation, or null.
+	 */
+	public function get_reason(): ?string {
+		return $this->reason;
+	}
+
+	/**
+	 * Annotate the cycle with an open-ended reason. Always writable.
+	 *
+	 * @param string|null $reason Reason text, or null to clear.
+	 */
+	public function set_reason( ?string $reason ): void {
+		$this->reason = $reason;
+	}
+
+	/**
+	 * Period start (GMT string). Frozen at construction.
+	 */
+	public function get_starts_at_gmt(): string {
+		return $this->starts_at_gmt;
+	}
+
+	/**
+	 * Period end (GMT string). Frozen at construction.
+	 */
+	public function get_ends_at_gmt(): string {
+		return $this->ends_at_gmt;
+	}
+
+	/**
+	 * Amount expected to be billed (decimal-safe string).
+	 */
+	public function get_expected_total(): string {
+		return $this->expected_total;
+	}
+
+	/**
+	 * ISO-4217 currency code.
+	 */
+	public function get_currency(): string {
+		return $this->currency;
+	}
+
+	/**
+	 * Plan snapshot row id, or null.
+	 */
+	public function get_plan_snapshot_id(): ?int {
+		return $this->plan_snapshot_id;
+	}
+
+	/**
+	 * Record the plan snapshot row id once it is stored. Write-once: only fills an
+	 * unset id, never re-points the frozen reference.
+	 *
+	 * @param int $plan_snapshot_id Snapshot row id.
+	 * @throws DomainException If a plan snapshot id is already recorded.
+	 */
+	public function set_plan_snapshot_id( int $plan_snapshot_id ): void {
+		$this->assert_snapshot_ref_unset( 'plan_snapshot_id', $this->plan_snapshot_id );
+		$this->plan_snapshot_id = $plan_snapshot_id;
+	}
+
+	/**
+	 * Items snapshot row id, or null.
+	 */
+	public function get_items_snapshot_id(): ?int {
+		return $this->items_snapshot_id;
+	}
+
+	/**
+	 * Record the items snapshot row id once it is stored. Write-once companion to
+	 * {@see self::set_plan_snapshot_id()}.
+	 *
+	 * @param int $items_snapshot_id Snapshot row id.
+	 * @throws DomainException If an items snapshot id is already recorded.
+	 */
+	public function set_items_snapshot_id( int $items_snapshot_id ): void {
+		$this->assert_snapshot_ref_unset( 'items_snapshot_id', $this->items_snapshot_id );
+		$this->items_snapshot_id = $items_snapshot_id;
+	}
+
+	/**
+	 * Order id linked to this cycle, or null.
+	 */
+	public function get_order_id(): ?int {
+		return $this->order_id;
+	}
+
+	/**
+	 * Owning extension slug, or null.
+	 */
+	public function get_extension_slug(): ?string {
+		return $this->extension_slug;
+	}
+
+	/**
+	 * Typed plan snapshot held in memory, or null.
+	 */
+	public function get_plan_snapshot(): ?PlanSnapshot {
+		return $this->plan_snapshot;
+	}
+
+	/**
+	 * Attach the typed plan snapshot value object once. Write-once: only fills an
+	 * unset value object, never swaps the frozen snapshot.
+	 *
+	 * @param PlanSnapshot $plan_snapshot Snapshot value object.
+	 * @throws DomainException If a plan snapshot value object is already attached.
+	 */
+	public function set_plan_snapshot( PlanSnapshot $plan_snapshot ): void {
+		if ( null !== $this->plan_snapshot ) {
+			throw new DomainException( 'Cycle: the plan snapshot is frozen and cannot be replaced.' );
+		}
+		$this->plan_snapshot = $plan_snapshot;
+	}
+
+	/**
+	 * Typed items snapshot held in memory, or null.
+	 */
+	public function get_items_snapshot(): ?ItemsSnapshot {
+		return $this->items_snapshot;
+	}
+
+	/**
+	 * Attach the typed items snapshot value object once. Write-once companion to
+	 * {@see self::set_plan_snapshot()}.
+	 *
+	 * @param ItemsSnapshot $items_snapshot Snapshot value object.
+	 * @throws DomainException If an items snapshot value object is already attached.
+	 */
+	public function set_items_snapshot( ItemsSnapshot $items_snapshot ): void {
+		if ( null !== $this->items_snapshot ) {
+			throw new DomainException( 'Cycle: the items snapshot is frozen and cannot be replaced.' );
+		}
+		$this->items_snapshot = $items_snapshot;
+	}
+
+	/**
+	 * Serialize the cycle row (excluding the generated id/timestamps). Status is
+	 * stored as its plain string value.
+	 *
+	 * @return array<string, mixed>
+	 */
+	public function to_storage(): array {
+		return array(
+			'contract_id'       => $this->contract_id,
+			'sequence_no'       => $this->sequence_no,
+			'count'             => $this->count,
+			'kind'              => $this->kind,
+			'status'            => $this->status->get_value(),
+			'reason'            => $this->reason,
+			'starts_at_gmt'     => $this->starts_at_gmt,
+			'ends_at_gmt'       => $this->ends_at_gmt,
+			'expected_total'    => $this->expected_total,
+			'currency'          => $this->currency,
+			'plan_snapshot_id'  => $this->plan_snapshot_id,
+			'items_snapshot_id' => $this->items_snapshot_id,
+			'order_id'          => $this->order_id,
+			'extension_slug'    => $this->extension_slug,
+		);
+	}
+
+	/**
+	 * Guard a snapshot row-id stamp so it is write-once; a second stamp would
+	 * re-point a frozen reference.
+	 *
+	 * @param string   $field   Field being stamped, for the error message.
+	 * @param int|null $current The currently recorded id (must be null to stamp).
+	 * @throws DomainException If the id is already recorded.
+	 */
+	private function assert_snapshot_ref_unset( string $field, ?int $current ): void {
+		if ( null !== $current ) {
+			throw new DomainException(
+				sprintf( 'Cycle: "%s" is frozen and cannot be re-pointed.', $field )
+			);
+		}
+	}
+
+	/**
+	 * Validate a cycle kind. Known-but-extensible (deliberately not a sealed enum):
+	 * any non-empty kind is accepted so a third party may introduce its own.
+	 *
+	 * @param string $kind Kind to validate.
+	 * @throws DomainException If `$kind` is empty.
+	 */
+	private static function assert_valid_kind( string $kind ): void {
+		if ( '' === $kind ) {
+			throw new DomainException( 'Cycle: kind must not be empty.' );
+		}
+	}
+
+	/**
+	 * Validate a sequence number.
+	 *
+	 * @param int $sequence_no Sequence number to validate.
+	 * @throws DomainException If `$sequence_no` is not positive.
+	 */
+	private static function assert_valid_sequence_no( int $sequence_no ): void {
+		if ( $sequence_no < 1 ) {
+			throw new DomainException(
+				sprintf( 'Cycle: sequence_no must be 1 or greater, got %d.', $sequence_no )
+			);
+		}
+	}
+
+	/**
+	 * Resolve a status input into a typed {@see CycleStatus}. A `CycleStatus` passes
+	 * through; null defaults to `pending`; a string is validated via
+	 * {@see CycleStatus::from()}.
+	 *
+	 * @param mixed $status Raw status value (a CycleStatus, null, or a status string).
+	 * @return CycleStatus
+	 * @throws DomainException If a status string is not a known status.
+	 */
+	private static function coerce_status( $status ): CycleStatus {
+		if ( $status instanceof CycleStatus ) {
+			return $status;
+		}
+
+		if ( null === $status ) {
+			return CycleStatus::pending();
+		}
+
+		return CycleStatus::from( self::coerce_string( $status ) );
+	}
+
+	/**
+	 * Normalize and validate a chargeable count.
+	 *
+	 * Null passes through (a non-counting cycle); a present value must be a
+	 * positive integer.
+	 *
+	 * @param mixed $count Raw count value (null, or coercible to int).
+	 * @return int|null
+	 * @throws DomainException If a present count is not positive.
+	 */
+	private static function normalize_count( $count ): ?int {
+		if ( null === $count ) {
+			return null;
+		}
+
+		$count = self::coerce_int( $count );
+		if ( $count < 1 ) {
+			throw new DomainException(
+				sprintf( 'Cycle: count must be 1 or greater when set, got %d.', $count )
+			);
+		}
+
+		return $count;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
new file mode 100644
index 00000000000..672d7f7ff9e
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
@@ -0,0 +1,222 @@
+<?php
+/**
+ * CycleStatus - the cycle lifecycle state machine, as an immutable value object.
+ * Owns the valid statuses and allowed transitions so an invalid state cannot be
+ * represented. Mirrors {@see ContractStatus}.
+ *
+ * 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.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
+
+use DomainException;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * CycleStatus value object.
+ *
+ * Immutable. Construct via a named factory ({@see self::pending()} etc.) or
+ * {@see self::from()}.
+ */
+final class CycleStatus {
+
+	const PENDING   = 'pending';
+	const BILLED    = 'billed';
+	const FAILED    = 'failed';
+	const CANCELLED = 'cancelled';
+
+	/**
+	 * The status string this value wraps.
+	 *
+	 * @var string
+	 */
+	private $value;
+
+	/**
+	 * Use a named factory ({@see self::pending()} etc.) or {@see self::from()}.
+	 *
+	 * @param string $value A known status string.
+	 */
+	private function __construct( string $value ) {
+		$this->value = $value;
+	}
+
+	/**
+	 * Build a status value from a known status string.
+	 *
+	 * @param string $value Status string.
+	 * @throws DomainException If `$value` is not a known status.
+	 */
+	public static function from( string $value ): self {
+		if ( ! self::is_valid( $value ) ) {
+			throw new DomainException(
+				sprintf( 'CycleStatus: "%s" is not a known status.', $value )
+			);
+		}
+
+		return new self( $value );
+	}
+
+	/**
+	 * The `pending` status (charge in flight; values locked at creation).
+	 */
+	public static function pending(): self {
+		return new self( self::PENDING );
+	}
+
+	/**
+	 * The `billed` status (settled after a successful charge; terminal).
+	 */
+	public static function billed(): self {
+		return new self( self::BILLED );
+	}
+
+	/**
+	 * The `failed` status (charge declined; non-terminal).
+	 */
+	public static function failed(): self {
+		return new self( self::FAILED );
+	}
+
+	/**
+	 * The `cancelled` status (closed; terminal).
+	 */
+	public static function cancelled(): self {
+		return new self( self::CANCELLED );
+	}
+
+	/**
+	 * The wrapped status string (the value stored on the cycle row).
+	 */
+	public function get_value(): string {
+		return $this->value;
+	}
+
+	/**
+	 * Whether this status is the same as `$other`.
+	 *
+	 * @param CycleStatus $other Status to compare against.
+	 */
+	public function equals( CycleStatus $other ): bool {
+		return $this->value === $other->value;
+	}
+
+	/**
+	 * Whether this status may move to `$target`.
+	 *
+	 * @param CycleStatus $target Target status.
+	 */
+	public function can_transition_to( CycleStatus $target ): bool {
+		return self::is_transition_allowed( $this->value, $target->value );
+	}
+
+	/**
+	 * Move to `$target`, returning the new status value.
+	 *
+	 * @param CycleStatus $target Target status.
+	 * @throws DomainException If the transition is not allowed.
+	 */
+	public function transition_to( CycleStatus $target ): self {
+		self::assert_transition_allowed( $this->value, $target->value );
+
+		return $target;
+	}
+
+	/**
+	 * All known statuses, in lifecycle order.
+	 *
+	 * @return array<int, string>
+	 */
+	public static function all(): array {
+		return array(
+			self::PENDING,
+			self::BILLED,
+			self::FAILED,
+			self::CANCELLED,
+		);
+	}
+
+	/**
+	 * Whether `$status` is a known status.
+	 *
+	 * @param string $status Status to check.
+	 */
+	public static function is_valid( string $status ): bool {
+		return in_array( $status, self::all(), true );
+	}
+
+	/**
+	 * Whether `$status` is terminal (no transitions out).
+	 *
+	 * @param string $status Status to check.
+	 */
+	public static function is_terminal( string $status ): bool {
+		return self::is_valid( $status ) && array() === self::transitions()[ $status ];
+	}
+
+	/**
+	 * Whether a cycle may move from `$from` to `$to`. Unknown statuses report false.
+	 * Same-status calls also report false; {@see Cycle::set_status()} short-circuits
+	 * no-ops before consulting this table.
+	 *
+	 * @param string $from Current status.
+	 * @param string $to   Target status.
+	 */
+	public static function is_transition_allowed( string $from, string $to ): bool {
+		if ( ! self::is_valid( $from ) || ! self::is_valid( $to ) ) {
+			return false;
+		}
+
+		return in_array( $to, self::transitions()[ $from ], true );
+	}
+
+	/**
+	 * Whether a cycle may move from `$from` to `$to`.
+	 *
+	 * Alias of {@see self::is_transition_allowed()}.
+	 *
+	 * @param string $from Current status.
+	 * @param string $to   Target status.
+	 */
+	public static function can_transition( string $from, string $to ): bool {
+		return self::is_transition_allowed( $from, $to );
+	}
+
+	/**
+	 * Throw if `$from` -> `$to` is not an allowed transition. The canonical
+	 * enforcement entry point every status change flows through.
+	 *
+	 * @param string $from Current status.
+	 * @param string $to   Target status.
+	 * @throws DomainException When the transition is rejected by {@see self::is_transition_allowed()}.
+	 */
+	public static function assert_transition_allowed( string $from, string $to ): void {
+		if ( ! self::is_transition_allowed( $from, $to ) ) {
+			throw new DomainException(
+				sprintf( 'CycleStatus: illegal status transition from "%s" to "%s".', $from, $to )
+			);
+		}
+	}
+
+	/**
+	 * Allowed transitions: current status => list of reachable statuses.
+	 *
+	 * @return array<string, array<int, string>>
+	 */
+	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(),
+		);
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/MoneyScale.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/MoneyScale.php
new file mode 100644
index 00000000000..3f4b4a5f651
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/MoneyScale.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ * MoneyScale - normalize money values to the storage decimal scale.
+ *
+ * Money is stored as DECIMAL(26,8), mirroring WooCommerce's order tables: HPOS
+ * stores `total_amount` / `tax_amount` / `shipping_total_amount` /
+ * `discount_total_amount` as `decimal(26,8)`. That is a storage scale (precision
+ * headroom), not a display precision - amounts are still shown via
+ * `wc_get_price_decimals()`. Normalizing on the way in keeps a value stable across
+ * a save/load round-trip - exact within double precision (~15 significant digits,
+ * which covers every realistic amount), the same float path as core's
+ * `wc_format_decimal()`. Shared by the cycle's `expected_total` and the contract's
+ * live totals. WordPress-free Core zone.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Support
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Support;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Money-scale normalization helper.
+ */
+trait MoneyScale {
+
+	use ScalarCoercion;
+
+	/**
+	 * Normalize a money value to the storage scale (8 decimals).
+	 *
+	 * @param mixed $value Money value (decimal string or number).
+	 */
+	private static function normalize_money( $value ): string {
+		return number_format( self::coerce_float( $value ?? '0' ), 8, '.', '' );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ScalarCoercion.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ScalarCoercion.php
index 23e2c92802f..818db9f5098 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ScalarCoercion.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ScalarCoercion.php
@@ -1,15 +1,9 @@
 <?php
 /**
- * ScalarCoercion - shared helpers for coercing untyped (mixed) values read from
- * decoded storage rows or caller-supplied argument maps into the scalar types the
- * entities and value objects declare.
- *
- * Hydration boundaries receive mixed data (JSON-decoded columns, $wpdb string
- * rows, loosely-typed $args). A blind (int)/(string) cast on such a value is
- * unsafe - an array or object would warn or fatal. These helpers guard first,
- * then cast, returning a documented default when the value is not coercible.
- *
- * Lives in the WordPress-free Core zone.
+ * ScalarCoercion - shared helpers coercing untyped (mixed) values from storage rows
+ * or argument maps into declared scalar types. Each guards before casting (a blind
+ * cast on an array/object would warn or fatal) and returns a default when the value
+ * is not coercible. WordPress-free Core zone.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Support
  */
@@ -45,11 +39,9 @@ trait ScalarCoercion {
 	}

 	/**
-	 * Coerce a value to an int, falling back to a default when it is not an integer.
-	 *
-	 * Only genuine integers and integer-valued strings are accepted; fractional or
-	 * exponent forms (`1.5`, `1e2`) are rejected rather than silently truncated, so
-	 * a corrupted identifier or counter falls back instead of changing value.
+	 * Coerce a value to an int, falling back when it is not an integer. Only genuine
+	 * integers and integer-valued strings pass; fractional/exponent forms (`1.5`,
+	 * `1e2`) fall back rather than being silently truncated.
 	 *
 	 * @param mixed $value    The raw value.
 	 * @param int   $fallback Returned when $value is not an integer.
@@ -81,4 +73,16 @@ trait ScalarCoercion {

 		return false !== $validated ? $validated : null;
 	}
+
+	/**
+	 * Coerce a value to a float, falling back when it is not numeric. The
+	 * money/decimal coercion: numbers and numeric strings (a DECIMAL column reads
+	 * back as one) pass; a non-numeric value falls back rather than casting to 0.0.
+	 *
+	 * @param mixed $value    The raw value.
+	 * @param float $fallback Returned when $value is not numeric.
+	 */
+	private static function coerce_float( $value, float $fallback = 0.0 ): float {
+		return is_numeric( $value ) ? (float) $value : $fallback;
+	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/ItemsSnapshot.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/ItemsSnapshot.php
new file mode 100644
index 00000000000..1c96d8841d4
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/ItemsSnapshot.php
@@ -0,0 +1,110 @@
+<?php
+/**
+ * ItemsSnapshot - an immutable, point-in-time copy of the line items a cycle was
+ * billed for. Companion to {@see PlanSnapshot}, referenced by cycles by id.
+ *
+ * NOT content-addressed: identical consecutive item sets are shared by copy-forward,
+ * so this VO carries no canonicalization or hashing. The item list is ordered and
+ * kept as given. `schema_version` is the payload-FORMAT version (how to parse/upcast),
+ * not a content version. WordPress-free Core zone.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
+
+use DomainException;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * ItemsSnapshot value object.
+ *
+ * Immutable. Construct via {@see self::from_items()} (typed data) or
+ * {@see self::from_payload()} (a serialized payload).
+ */
+final class ItemsSnapshot {
+
+	/**
+	 * The ordered line items, each a plain associative array.
+	 *
+	 * @var array<int, array<string, mixed>>
+	 */
+	private $items;
+
+	/**
+	 * Payload-format version, recorded on the snapshot row. NOT a content version.
+	 *
+	 * @var int
+	 */
+	private $schema_version;
+
+	/**
+	 * Use {@see self::from_items()} or {@see self::from_payload()}.
+	 *
+	 * @param array<int, array<string, mixed>> $items          Ordered line items.
+	 * @param int                              $schema_version Payload-format version.
+	 * @throws DomainException If the schema version is not positive.
+	 */
+	private function __construct( array $items, int $schema_version ) {
+		if ( $schema_version < 1 ) {
+			throw new DomainException(
+				sprintf( 'ItemsSnapshot: schema_version must be 1 or greater, got %d.', $schema_version )
+			);
+		}
+
+		$this->items          = array_values( $items );
+		$this->schema_version = $schema_version;
+	}
+
+	/**
+	 * Build an items snapshot from an ordered list of items.
+	 *
+	 * @param array<int, array<string, mixed>> $items          Ordered line items.
+	 * @param int                              $schema_version Payload-format version. Defaults to 1.
+	 * @throws DomainException If the schema version is not positive.
+	 */
+	public static function from_items( array $items, int $schema_version = 1 ): self {
+		return new self( $items, $schema_version );
+	}
+
+	/**
+	 * Reconstruct an items snapshot from a serialized payload. Companion to
+	 * {@see self::to_payload()}.
+	 *
+	 * @param array<int, array<string, mixed>> $payload        Serialized ordered line items.
+	 * @param int                              $schema_version Payload-format version the payload was written in.
+	 * @throws DomainException If the schema version is not positive.
+	 */
+	public static function from_payload( array $payload, int $schema_version = 1 ): self {
+		return new self( $payload, $schema_version );
+	}
+
+	/**
+	 * Payload-format version (NOT a content version).
+	 */
+	public function get_schema_version(): int {
+		return $this->schema_version;
+	}
+
+	/**
+	 * The line items, in their original order.
+	 *
+	 * @return array<int, array<string, mixed>>
+	 */
+	public function get_items(): array {
+		return $this->items;
+	}
+
+	/**
+	 * The serialized payload for storage: the ordered line items, no
+	 * canonicalization (dedup is by copy-forward, not a content hash).
+	 *
+	 * @return array<int, array<string, mixed>>
+	 */
+	public function to_payload(): array {
+		return $this->items;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
new file mode 100644
index 00000000000..ed1e64bc5a0
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
@@ -0,0 +1,123 @@
+<?php
+/**
+ * PlanSnapshot - an immutable, point-in-time copy of the plan terms a cycle was
+ * billed under. Companion to {@see ItemsSnapshot}, referenced by cycles by id.
+ *
+ * NOT content-addressed: identical consecutive plan terms are shared by copy-forward,
+ * so this VO carries no canonicalization or hashing. The typed in-memory form; the
+ * Integration binding serializes via {@see self::to_payload()} and reconstructs via
+ * {@see self::from_payload()}. `schema_version` is the payload-FORMAT version (how to
+ * parse/upcast), not the plan's content version. WordPress-free Core zone.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
+
+use DomainException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * PlanSnapshot value object.
+ *
+ * Immutable. Construct via {@see self::from_array()} (typed data) or
+ * {@see self::from_payload()} (a serialized payload).
+ */
+final class PlanSnapshot {
+
+	use ScalarCoercion;
+
+	/**
+	 * The plan terms payload, as stored on the snapshot row.
+	 *
+	 * @var array<string, mixed>
+	 */
+	private $data;
+
+	/**
+	 * Payload-format version, recorded on the snapshot row. NOT the plan's content
+	 * version.
+	 *
+	 * @var int
+	 */
+	private $schema_version;
+
+	/**
+	 * Use {@see self::from_array()} or {@see self::from_payload()}.
+	 *
+	 * @param array<string, mixed> $data           Plan terms payload.
+	 * @param int                  $schema_version Payload-format version.
+	 * @throws DomainException If the schema version is not positive.
+	 */
+	private function __construct( array $data, int $schema_version ) {
+		if ( $schema_version < 1 ) {
+			throw new DomainException(
+				sprintf( 'PlanSnapshot: schema_version must be 1 or greater, got %d.', $schema_version )
+			);
+		}
+
+		$this->data           = $data;
+		$this->schema_version = $schema_version;
+	}
+
+	/**
+	 * Build a plan snapshot from a payload array.
+	 *
+	 * @param array<string, mixed> $data           Plan terms payload.
+	 * @param int                  $schema_version Payload-format version. Defaults to 1.
+	 * @throws DomainException If the schema version is not positive.
+	 */
+	public static function from_array( array $data, int $schema_version = 1 ): self {
+		return new self( $data, $schema_version );
+	}
+
+	/**
+	 * Reconstruct a plan snapshot from a serialized payload. Companion to
+	 * {@see self::to_payload()}.
+	 *
+	 * @param array<string, mixed> $payload        Serialized plan terms payload.
+	 * @param int                  $schema_version Payload-format version the payload was written in.
+	 * @throws DomainException If the schema version is not positive.
+	 */
+	public static function from_payload( array $payload, int $schema_version = 1 ): self {
+		return new self( $payload, $schema_version );
+	}
+
+	/**
+	 * Payload-format version (NOT the plan's content version).
+	 */
+	public function get_schema_version(): int {
+		return $this->schema_version;
+	}
+
+	/**
+	 * The id of the plan these terms were snapshotted from, or null when absent.
+	 * A weak link back to the source plan; a missing key surfaces here as null.
+	 */
+	public function get_selling_plan_id(): ?int {
+		return isset( $this->data['selling_plan_id'] ) ? self::coerce_int( $this->data['selling_plan_id'] ) : null;
+	}
+
+	/**
+	 * The plan terms payload, in its original key order.
+	 *
+	 * @return array<string, mixed>
+	 */
+	public function to_array(): array {
+		return $this->data;
+	}
+
+	/**
+	 * The serialized payload for storage: the plan terms as given, no
+	 * canonicalization (dedup is by copy-forward, not a content hash).
+	 *
+	 * @return array<string, mixed>
+	 */
+	public function to_payload(): array {
+		return $this->data;
+	}
+}
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 19f909b65dc..f0baf5a90a0 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
@@ -1,24 +1,8 @@
 <?php
 /**
- * ContractFactory - builds and persists a Contract from a completed order.
- *
- * This is the entry point a consumer's checkout handler calls once an order
- * that contains a subscription product is paid: hand it the order plus the
- * selling {@see Plan} the customer chose, and it
- *
- *   1. builds a Core {@see Contract} from the order's customer / currency /
- *      payment / totals / line items / addresses,
- *   2. seeds the first renewal date from the plan's {@see BillingPolicy}
- *      (honouring a native trial),
- *   3. persists it via {@see ContractRepository::insert()}, and
- *   4. links order <-> contract in both directions (the contract row's
- *      `origin_order_id` column plus the order-side {@see OrderLinkage} meta).
- *
- * Returns the persisted Contract. Scheduling the first renewal is a separate
- * step the caller drives through {@see RenewalEngine::schedule()} - the factory
- * builds the contract and sets `next_payment_gmt`; it does not enqueue the
- * Action Scheduler row, so a caller can create a contract without arming the
- * money-path (e.g. a gateway-scheduled contract).
+ * 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()}.
  *
  * Integration zone: WordPress-native. Reads a live `WC_Order`; the order never
  * crosses into Core - only the snapshot values pulled off it do.
@@ -36,7 +20,12 @@ use Throwable;
 use WC_Order;
 use WC_Order_Item_Product;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+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\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;

 defined( 'ABSPATH' ) || exit;
@@ -46,6 +35,8 @@ defined( 'ABSPATH' ) || exit;
  */
 final class ContractFactory {

+	use ScalarCoercion;
+
 	/**
 	 * The repository the factory persists through.
 	 *
@@ -67,23 +58,19 @@ final class ContractFactory {
 	/**
 	 * Build, persist, and link a contract for `$order` on `$plan`.
 	 *
-	 * Recurring totals are seeded from the order's totals on the assumption
-	 * that the first cycle's price equals the recurring price; a caller that
-	 * already knows the recurring totals (sign-up fees, first-cycle discounts)
-	 * passes them via `$overrides`, which are merged over the order-derived
-	 * defaults before the contract is built.
-	 *
-	 * The first `next_payment_gmt` comes from
-	 * {@see BillingPolicy::compute_first_renewal_from()} anchored on the order's
-	 * paid time (`date_paid`, falling back to "now"), so a native trial delays
-	 * the first bill correctly. A caller can override it via
-	 * `$overrides['next_payment_gmt']`.
+	 * The live totals (`billing_total` = cycle 1's `expected_total`, plus discount /
+	 * shipping / tax) are seeded from the order on the assumption the first recurring
+	 * bill equals the order's recurring price; the first renewal date is computed from
+	 * the plan's billing policy anchored on the paid time (so a native trial delays it).
+	 * Any of these, and any other `Contract::create()` field, can be replaced via `$overrides`.
 	 *
 	 * @param WC_Order             $order     The paid checkout order.
 	 * @param Plan                 $plan      The selling plan the customer chose. Must be persisted (have an id).
-	 * @param array<string, mixed> $overrides Optional explicit values for any Contract::create() field.
+	 * @param array<string, mixed> $overrides Optional explicit values: any Contract::create() field, plus
+	 *                                        `billing_total` (cycle 1's expected_total) and
+	 *                                        `next_payment_gmt` (the first renewal date / cycle 1's period end).
 	 * @return Contract The persisted contract, with its id assigned.
-	 * @throws \RuntimeException If the plan or order has no id, or the insert fails.
+	 * @throws \RuntimeException If the plan or order has no id, or a write fails.
 	 */
 	public function create_from_order( WC_Order $order, Plan $plan, array $overrides = array() ): Contract {
 		$plan_id = $plan->get_id();
@@ -91,59 +78,124 @@ final class ContractFactory {
 			throw new \RuntimeException( 'ContractFactory::create_from_order(): the selling plan must be persisted (have an id) before a contract can reference it.' );
 		}

-		// An unsaved order reports id 0; persisting origin_order_id => 0 would link
-		// the contract to a non-existent order. Require a saved order up front.
+		// An unsaved order reports id 0, which would link the contract to a
+		// non-existent order. Require a saved order up front.
 		if ( ! $order->get_id() ) {
 			throw new \RuntimeException( 'ContractFactory::create_from_order(): the order must be persisted (have an id) before a contract can link to it.' );
 		}

-		$now       = gmdate( 'Y-m-d H:i:s' );
 		$paid_date = $order->get_date_paid();
 		$anchor    = null !== $paid_date
 			? new DateTimeImmutable( '@' . $paid_date->getTimestamp() )
-			: new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) );
+			: new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
+
+		// Start from the paid time (not processing time) so the contract start, cycle 1's
+		// period start, and the renewal-measurement anchor all agree.
+		$period_start = $anchor->format( 'Y-m-d H:i:s' );
+
+		// First renewal date: cycle 1's period end and the contract's next-bill cache.
+		$next_payment = isset( $overrides['next_payment_gmt'] )
+			? self::coerce_string( $overrides['next_payment_gmt'] )
+			: $plan->get_billing_policy()->compute_first_renewal_from( $anchor )->format( 'Y-m-d H:i:s' );

-		$next_payment = $plan->get_billing_policy()
-			->compute_first_renewal_from( $anchor )
-			->format( 'Y-m-d H:i:s' );
+		$expected_total = isset( $overrides['billing_total'] ) ? self::coerce_string( $overrides['billing_total'] ) : (string) $order->get_total();
+		$currency       = $order->get_currency();

-		$defaults = array(
+		$contract_defaults = array(
 			'customer_id'          => $order->get_customer_id(),
-			'currency'             => $order->get_currency(),
+			'currency'             => $currency,
 			'selling_plan_id'      => $plan_id,
 			'origin_order_id'      => $order->get_id(),
 			'extension_slug'       => $plan->get_extension_slug(),
 			'payment_method'       => '' !== $order->get_payment_method() ? $order->get_payment_method() : null,
 			'payment_method_title' => '' !== $order->get_payment_method_title() ? $order->get_payment_method_title() : null,
 			'payment_token_id'     => $this->extract_payment_token_id( $order ),
-			'billing_total'        => (string) $order->get_total(),
+			'start_gmt'            => $period_start,
+			'next_payment_gmt'     => $next_payment,
+			// Live recurring totals the contract bills going forward, seeded from the order.
+			'billing_total'        => $expected_total,
 			'discount_total'       => (string) $order->get_total_discount(),
 			'shipping_total'       => (string) $order->get_shipping_total(),
 			'tax_total'            => (string) $order->get_total_tax(),
-			'start_gmt'            => $now,
-			'next_payment_gmt'     => $next_payment,
 			'items'                => $this->map_items( $order ),
 			'addresses'            => $this->map_addresses( $order ),
 			'meta'                 => array(),
 		);

-		$contract = Contract::create( array_merge( $defaults, $overrides ) );
+		$contract = Contract::create( array_merge( $contract_defaults, $overrides ) );

-		$id = $this->contracts->insert( $contract );
+		$origin_cycle = $this->build_origin_cycle( $order, $plan, $period_start, $next_payment, $expected_total, $currency );

-		$this->tag_origin_order( $order, $id );
+		$contract_id = $this->contracts->insert_with_origin_cycle( $contract, $origin_cycle );
+
+		$this->tag_origin_order( $order, $contract_id );

 		return $contract;
 	}

+	/**
+	 * Build the billing chain's cycle 1 - the immutable signup record.
+	 *
+	 * Created directly `billed` (the origin order is already paid), with `count` 1 and
+	 * `contract_id` a placeholder (0) the repository stamps once the contract row has an id.
+	 *
+	 * @param WC_Order $order          The paid checkout order (the items / order-id source).
+	 * @param Plan     $plan           The selling plan (the plan-snapshot / owner source).
+	 * @param string   $starts_at      Cycle 1's period start (the signup time, GMT string).
+	 * @param string   $ends_at        Cycle 1's period end (the first renewal date, GMT string).
+	 * @param string   $expected_total The amount cycle 1 billed (decimal-safe string).
+	 * @param string   $currency       ISO-4217 currency code.
+	 * @return Cycle The unsaved signup cycle, created `billed`.
+	 */
+	private function build_origin_cycle( WC_Order $order, Plan $plan, string $starts_at, string $ends_at, string $expected_total, string $currency ): Cycle {
+		return Cycle::create(
+			array(
+				'contract_id'    => 0,
+				'sequence_no'    => 1,
+				'count'          => 1,
+				'status'         => CycleStatus::billed(),
+				'order_id'       => $order->get_id(),
+				'extension_slug' => $plan->get_extension_slug(),
+				'starts_at_gmt'  => $starts_at,
+				'ends_at_gmt'    => $ends_at,
+				'expected_total' => $expected_total,
+				'currency'       => $currency,
+				'plan_snapshot'  => $this->build_plan_snapshot( $plan ),
+				'items_snapshot' => $this->build_items_snapshot( $order ),
+			)
+		);
+	}
+
+	/**
+	 * Build the typed plan snapshot for the origin cycle.
+	 *
+	 * @param Plan $plan The plan whose terms to snapshot.
+	 */
+	private function build_plan_snapshot( Plan $plan ): PlanSnapshot {
+		return PlanSnapshot::from_array(
+			array(
+				'selling_plan_id' => $plan->get_id(),
+				'name'            => $plan->get_name(),
+				'category'        => $plan->get_category(),
+				'billing_policy'  => $plan->get_billing_policy()->to_array(),
+			)
+		);
+	}
+
+	/**
+	 * Build the typed items snapshot for the origin cycle from the order.
+	 *
+	 * @param WC_Order $order The order whose line items to snapshot.
+	 */
+	private function build_items_snapshot( WC_Order $order ): ItemsSnapshot {
+		return ItemsSnapshot::from_items( $this->map_items( $order ) );
+	}
+
 	/**
 	 * Tag `$order` with the parent-relation meta for `$contract_id`.
 	 *
-	 * Best-effort: the contract row is already persisted and carries the
-	 * `origin_order_id` FK, so a failure here (a save listener throwing, a DB
-	 * hiccup) is logged and swallowed rather than failing contract creation -
-	 * the reverse lookup can be healed from the FK later. Mirrors the order
-	 * tagging the renewal engine applies to renewal orders.
+	 * Best-effort: the contract already carries the `origin_order_id` FK, so a failure
+	 * here is logged and swallowed (the order-side link can be rebuilt from the FK later).
 	 *
 	 * @param WC_Order $order       Order to tag.
 	 * @param int      $contract_id Contract id to write into the order meta.
@@ -173,11 +225,9 @@ final class ContractFactory {
 	/**
 	 * Map the order's line items to the contract item-row shape.
 	 *
-	 * Only `line_item` rows are carried onto the contract today (fees /
-	 * shipping / tax are reconstructed from the contract totals at renewal
-	 * time). The renewal-order builder clones the origin order's items, so the
-	 * contract items are a snapshot for inspection rather than the renewal
-	 * source of truth.
+	 * Only `line_item` rows are carried (fees / shipping / tax are reconstructed from
+	 * the contract totals at renewal). These are a snapshot for inspection, not the
+	 * renewal source of truth - the renewal-order builder clones the origin order's items.
 	 *
 	 * @param WC_Order $order The order to read items from.
 	 * @return array<int, array<string, mixed>>
@@ -221,13 +271,10 @@ final class ContractFactory {
 	/**
 	 * Best-effort extraction of the payment-token id from `$order`.
 	 *
-	 * Reads WooCommerce's per-order payment tokens, populated when a gateway
-	 * calls `$order->add_payment_token()` at checkout. WooCommerce's typical
-	 * pattern is one token per order with the most recently attached entry
-	 * being the one charged, so we read the last entry. Returns null when no
-	 * token is resolvable (manual gateways, or gateways that store their token
-	 * reference elsewhere) - the contract is then created without a token and a
-	 * later payment-method-change flow can attach one.
+	 * Reads WooCommerce's per-order payment tokens (populated when a gateway calls
+	 * `$order->add_payment_token()`); the last entry is the one charged. Returns null
+	 * when none is resolvable (manual gateways, or token stored elsewhere) - the contract
+	 * is then created without a token and a later payment-method change can attach one.
 	 *
 	 * @param WC_Order $order Order to read the token from.
 	 * @return int|null Token id, or null when none is resolvable.
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 87ceaee9c68..c1003e13d2e 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php
@@ -1,21 +1,12 @@
 <?php
 /**
- * OrderLinkage - the order-side meta keys that link orders to contracts.
- *
- * The order <-> contract relationship is recorded on order meta so it is
- * queryable from the order side without loading the contract:
- *
- *  - `_subscription_contract_id`   (int)    - which contract this order belongs to.
- *  - `_subscription_relation_type` (string) - `parent | renewal | switch | resubscribe`.
- *
- * The contract row carries the reverse direction (`origin_order_id`); these
- * order-meta keys make the relationship symmetric. The engine owns these keys;
- * consumers that need to detect "is this a renewal order?" read them through
+ * Order-side meta keys linking orders to contracts, making the relationship
+ * queryable from the order side (the contract row carries the reverse
+ * `origin_order_id`). The engine owns these keys; consumers read them through
  * this class rather than hard-coding the strings.
  *
- * Integration zone: WordPress-native. The keys are written to WooCommerce
- * order meta (`WC_Order::update_meta_data()`), which works under both HPOS and
- * the legacy CPT order store.
+ * Integration zone: WordPress-native. Written to WooCommerce order meta, which
+ * works under both HPOS and the legacy CPT order store.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout
  */
@@ -36,8 +27,7 @@ final class OrderLinkage {
 	/**
 	 * Order meta key holding the contract id this order belongs to.
 	 *
-	 * Stored as a stringified integer (order meta is a flat string table). Read
-	 * via `(int) $order->get_meta( OrderLinkage::META_CONTRACT_ID )`.
+	 * Stored as a stringified integer (order meta is a flat string table).
 	 */
 	const META_CONTRACT_ID = '_subscription_contract_id';

@@ -47,9 +37,7 @@ final class OrderLinkage {
 	const META_RELATION_TYPE = '_subscription_relation_type';

 	/**
-	 * The order whose checkout created the contract - the contract row's
-	 * `origin_order_id`. Tagged on the order side too so the relationship is
-	 * queryable from either direction.
+	 * The order whose checkout created the contract (the contract's `origin_order_id`).
 	 */
 	const RELATION_PARENT = 'parent';

@@ -59,13 +47,12 @@ final class OrderLinkage {
 	const RELATION_RENEWAL = 'renewal';

 	/**
-	 * A switch order - customer moved between plans (later milestone).
+	 * A switch order - customer moved between plans.
 	 */
 	const RELATION_SWITCH = 'switch';

 	/**
-	 * A resubscribe order - customer restarted a previously-cancelled contract
-	 * (later milestone).
+	 * A resubscribe order - customer restarted a previously-cancelled contract.
 	 */
 	const RELATION_RESUBSCRIBE = 'resubscribe';

@@ -84,10 +71,8 @@ final class OrderLinkage {
 	}

 	/**
-	 * Throw if `$relation` is not one of the known relation types.
-	 *
-	 * Centralising the check keeps callers from querying for a typoed relation
-	 * (`'renewals'`, `'parent_order'`) and silently getting an empty result.
+	 * Throw if `$relation` is not one of the known relation types, so a typoed
+	 * relation fails loudly rather than silently querying to an empty result.
 	 *
 	 * @param string $relation Candidate relation type.
 	 * @throws InvalidArgumentException If `$relation` is not recognized.
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 e764542ecb1..acaf96ec851 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
@@ -1,23 +1,14 @@
 <?php
 /**
- * RenewalEngine - the seam between consumers and the renewal money-path.
+ * The seam between consumers and the renewal money-path: `schedule()` enqueues a
+ * contract's next renewal, `process_due()` runs it when Action Scheduler fires.
+ * 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}.
  *
- * `schedule()` is what a consumer (or the checkout factory's caller) invokes
- * when a contract's next-payment date is set or moved; `process_due()` is what
- * Action Scheduler calls back into when the scheduled moment arrives. Action
- * Scheduler is the wrong thing to expose directly - hook names, group
- * conventions, and dedup behaviour are implementation choices the engine should
- * be free to change - so this class wraps them and adds the contract-aware
- * semantics (capability gating, the advance, the renewal order, the charge).
- *
- * The AS coupling lives in {@see RenewalScheduler}; this class delegates to it.
- * The pure date math lives in {@see RenewalCalculator} (Core); this class reads
- * the contract row, calls Core for the decision, then writes the result back.
- *
- * POC shape: one AS job per contract, advance-on-fire. The long-term batch
- * dispatcher and the split of attempt-vs-success accounting arrive with the
- * cycles/attempts reshape - see the package architecture notes. Until then this
- * advances the contract clock at fire time so the chain keeps moving.
+ * Advancing the chain at fire time (appending the next cycle, recording the outcome,
+ * advancing `next_payment_gmt`, re-arming the next due moment) is the dispatcher
+ * slice's money-path and is not built here, so this unit does not drive a live loop.
  *
  * Integration zone: WordPress-native. Action Scheduler, WC orders, gateways.
  *
@@ -35,11 +26,9 @@ use WC_Order;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal\RenewalCalculator;
 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\PlanRepository;

 defined( 'ABSPATH' ) || exit;

@@ -65,36 +54,24 @@ final class RenewalEngine {
 	const LOG_SOURCE = 'woocommerce-subscriptions-engine';

 	/**
-	 * Repository for loading and persisting contracts.
+	 * Repository for loading and persisting contracts, and targeted cycle access.
 	 *
 	 * @var ContractRepository
 	 */
 	private $contracts;

 	/**
-	 * Repository for loading plans (the BillingPolicy source).
-	 *
-	 * @var PlanRepository
-	 */
-	private $plans;
-
-	/**
-	 * Build a renewal engine over the given repositories.
+	 * Build a renewal engine over the given contract repository.
 	 *
 	 * @param ContractRepository|null $contracts Contract repository; default instance when omitted.
-	 * @param PlanRepository|null     $plans     Plan repository; default instance when omitted.
 	 */
-	public function __construct( ?ContractRepository $contracts = null, ?PlanRepository $plans = null ) {
+	public function __construct( ?ContractRepository $contracts = null ) {
 		$this->contracts = $contracts ?? new ContractRepository();
-		$this->plans     = $plans ?? new PlanRepository();
 	}

 	/**
-	 * 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::process_due()}. Wired from
-	 * {@see \Automattic\WooCommerce\SubscriptionsEngine\Integration\Bootstrap}.
+	 * 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()}.
 	 */
 	public static function register_hooks(): void {
 		add_action( RenewalScheduler::HOOK, array( __CLASS__, 'handle_due_action' ), 10, 1 );
@@ -103,9 +80,8 @@ final class RenewalEngine {
 	/**
 	 * Action Scheduler dispatch entry point - fires when a renewal is due.
 	 *
-	 * Static so it can be registered as a plain callback; constructs an engine
-	 * with default repositories and routes through the instance `process_due()`
-	 * so the dispatch path and any synchronous test driver share one code path.
+	 * 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.
 	 *
 	 * @param int $contract_id Contract whose renewal is firing.
 	 */
@@ -114,24 +90,15 @@ final class RenewalEngine {
 	}

 	/**
-	 * Schedule (or re-schedule) the next renewal for `$contract` at its
-	 * `next_payment_gmt`.
-	 *
-	 * Re-scheduling replaces: at most one pending AS row exists per contract.
-	 * The clear-then-enqueue is unconditional, so callers moving the date
-	 * forward just call `schedule()` again.
-	 *
-	 * **Capability gate (the schedule-time guard).** A primitive-scheduled
-	 * contract is only enqueued when its gateway declares the `recurring`
-	 * capability via {@see CapabilityRegistry::supports()}. An incapable gateway
-	 * would create renewals nothing can charge, so we refuse at the boundary
-	 * (log + no enqueue) rather than failing later on a customer-facing order.
-	 * Gateway-scheduled contracts (`schedule_source = gateway`) are never
-	 * enqueued here - the gateway runs its own schedule; any stale primitive row
-	 * is cleared.
+	 * Schedule (or re-schedule) the next renewal for `$contract` at its `next_payment_gmt`.
 	 *
-	 * Does nothing when the contract has no `next_payment_gmt` (nothing to
-	 * anchor on) - any stale row is cleared.
+	 * 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
@@ -143,8 +110,7 @@ final class RenewalEngine {
 			return false;
 		}

-		// Gateway-scheduled: the gateway owns the schedule. Clear any stale
-		// primitive row and bail.
+		// 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;
@@ -177,9 +143,8 @@ final class RenewalEngine {

 		$when = new DateTimeImmutable( $next_payment_gmt, new DateTimeZone( 'UTC' ) );

-		// Clear-then-enqueue keeps the single-row-per-contract invariant: AS
-		// does not dedup on hook+args, so without the clear a re-schedule would
-		// leave two rows and fire twice.
+		// 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 );

@@ -191,22 +156,12 @@ final class RenewalEngine {
 	/**
 	 * Run the renewal due for `$contract_id`. Fired by the AS hook.
 	 *
-	 * Steps:
-	 *  1. Load the contract; bail (log) if it is gone - a stale AS row firing
-	 *     against a deleted contract is not worth throwing over (AS would retry
-	 *     a permanent failure forever).
-	 *  2. Skip non-active contracts (on-hold / pending-cancellation / terminal).
-	 *     The lifecycle path should have cleared the AS row, but a row can slip
-	 *     through (migration, manual SQL); skipping is the safe default. Skip
-	 *     gateway-scheduled contracts the same way - the gateway owns the charge.
-	 *  3. Idempotency guard: if a renewal order for this contract's *current*
-	 *     cycle already exists, do not create a second one. Tolerates AS
-	 *     retries without double-charging or double-advancing.
-	 *  4. Build the renewal order, attempt the gateway charge, advance the
-	 *     contract clock (cycle_count + next_payment_gmt, or terminal on
-	 *     max_cycles), persist, and re-schedule the next cycle.
-	 *
-	 * Returns the renewal order, or null when the renewal was skipped.
+	 * 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 guards idempotency - if a renewal order for the next chargeable number in
+	 * the billing chain already exists, no second one is created (tolerating AS retries) -
+	 * and builds the renewal order and attempts the gateway charge. Advancing the chain
+	 * is the dispatcher slice's money-path, so this does not drive a live renewal loop.
 	 *
 	 * @param int $contract_id Contract whose renewal cycle is firing.
 	 * @return WC_Order|null The created renewal order, or null when skipped.
@@ -247,12 +202,13 @@ final class RenewalEngine {
 			return null;
 		}

-		// Idempotency: a renewal order already tagged for this contract's
-		// current cycle means this action already ran (AS retry, double-fire).
-		// Bail without creating a second order or advancing again.
-		if ( $this->renewal_exists_for_cycle( $contract_id, $contract->get_cycle_count() + 1 ) ) {
+		$next_count = $this->next_chargeable_count( $contract_id );
+
+		// Idempotency: a renewal order already tagged for this number means the action
+		// already ran (AS retry, double-fire). Bail without creating a second order.
+		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, $contract->get_cycle_count() + 1 ),
+				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,
@@ -261,27 +217,18 @@ final class RenewalEngine {
 			return null;
 		}

-		$renewal_order = $this->build_renewal_order( $contract );
+		$renewal_order = $this->build_renewal_order( $contract, $next_count );
 		if ( null === $renewal_order ) {
-			// build_renewal_order logged the reason; do not advance a contract
-			// whose renewal we could not create.
+			// build_renewal_order logged the reason.
 			return null;
 		}

-		$this->advance_contract( $contract );
-		$this->contracts->update( $contract );
-
 		do_action( self::RENEWAL_ORDER_CREATED_ACTION, $renewal_order, $contract );

 		$this->attempt_charge( $renewal_order, $contract );

-		// Re-arm the next cycle when the contract is still active (i.e. it did
-		// not just hit max_cycles).
-		if ( ContractStatus::ACTIVE === $contract->get_status() ) {
-			$this->schedule( $contract );
-		} else {
-			RenewalScheduler::unschedule( $contract_id );
-		}
+		// Advancing the chain and re-arming the next due moment is the dispatcher
+		// slice's money-path - deferred, so the loop is not driven from here.

 		return $renewal_order;
 	}
@@ -290,9 +237,7 @@ final class RenewalEngine {
 	 * Cancel `$contract`: transition to cancelled and clear its pending renewal.
 	 *
 	 * Status moves through the Core state machine ({@see Contract::set_status()}),
-	 * which raises a `DomainException` on an illegal transition (for example
-	 * cancelling an already-expired contract). On success the contract is
-	 * persisted and its AS row cleared.
+	 * which raises a `DomainException` on an illegal transition.
 	 *
 	 * @param Contract $contract Contract to cancel. Must have an id.
 	 * @return bool True when the contract was cancelled and persisted.
@@ -305,6 +250,8 @@ final class RenewalEngine {
 		}

 		$contract->set_status( ContractStatus::CANCELLED );
+		// update() persists the contract-row cache/status ONLY. Closing the scheduled
+		// cycle and any chain/cycle head transition is the dispatcher money-path.
 		$this->contracts->update( $contract );

 		RenewalScheduler::unschedule( $id );
@@ -313,75 +260,49 @@ final class RenewalEngine {
 	}

 	/**
-	 * Advance `$contract` one cycle: bump the paid-cycle count and either
-	 * compute the next bill date or end the contract on max_cycles.
-	 *
-	 * The decision is delegated to the pure Core {@see RenewalCalculator} with
-	 * an explicit `$now`. When the plan is missing (deleted out from under the
-	 * contract) the clock cannot advance; the cycle count still moves and the
-	 * next date is cleared, leaving a visible "active, no next date" state.
+	 * The next chargeable number in the contract's billing chain - one past the
+	 * per-chain `MAX(count)`, or 1 for a chain with no counting cycle yet. This is
+	 * the idempotency anchor the renewal order is tagged with.
 	 *
-	 * @param Contract $contract Contract to advance in place.
+	 * @param int $contract_id Contract id.
+	 * @return int The next chargeable number.
 	 */
-	private function advance_contract( Contract $contract ): void {
-		$now       = new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
-		$new_count = $contract->get_cycle_count() + 1;
-		$plan      = $this->plans->find( $contract->get_selling_plan_id() );
+	private function next_chargeable_count( int $contract_id ): int {
+		$max = $this->contracts->max_count( $contract_id );

-		$contract->set_cycle_count( $new_count );
-		$contract->set_last_payment_gmt( $now->format( 'Y-m-d H:i:s' ) );
+		return null === $max ? 1 : $max + 1;
+	}

-		if ( null === $plan ) {
-			wc_get_logger()->warning(
-				sprintf( 'RenewalEngine::advance_contract(): contract %d references a missing plan %d - cannot compute the next bill date. Cycle advanced; next_payment_gmt cleared.', (int) $contract->get_id(), $contract->get_selling_plan_id() ),
+	/**
+	 * Build a renewal order cloned from the contract's origin order: clones
+	 * line/fee/shipping/tax/coupon items and addresses, applies the current 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 the origin
+	 * order cannot be loaded or `wc_create_order()` fails.
+	 *
+	 * @param Contract $contract Contract being renewed.
+	 * @param int      $count    The chargeable number this order bills.
+	 * @return WC_Order|null The saved pending renewal order, or null on failure.
+	 */
+	private function build_renewal_order( Contract $contract, int $count ): ?WC_Order {
+		$origin_order_id = $contract->get_origin_order_id();
+		if ( null === $origin_order_id ) {
+			// A manual/admin contract has no origin order to clone from - not supported yet.
+			wc_get_logger()->error(
+				sprintf( 'RenewalEngine: cannot build renewal for contract %d - it has no origin order to clone.', (int) $contract->get_id() ),
 				array(
 					'source'      => self::LOG_SOURCE,
 					'contract_id' => (int) $contract->get_id(),
 				)
 			);
-			$contract->set_next_payment_gmt( null );
-			return;
-		}
-
-		$policy = $plan->get_billing_policy();
-
-		if ( RenewalCalculator::has_reached_max_cycles( $policy, $new_count ) ) {
-			$contract->set_next_payment_gmt( null );
-			$contract->set_status( ContractStatus::EXPIRED );
-			return;
+			return null;
 		}

-		// Anchor the next cycle on the cycle that just billed. Using the
-		// contract's prior next_payment_gmt as the anchor (not `now`) keeps the
-		// cadence on its calendar grid even if the job fired late.
-		$anchor_gmt = $contract->get_next_payment_gmt();
-		$anchor     = null !== $anchor_gmt
-			? new DateTimeImmutable( $anchor_gmt, new DateTimeZone( 'UTC' ) )
-			: $now;
-
-		$next = RenewalCalculator::next_bill_date( $policy, $anchor );
-		$contract->set_next_payment_gmt( $next->format( 'Y-m-d H:i:s' ) );
-	}
-
-	/**
-	 * Build a renewal order cloned from the contract's origin order.
-	 *
-	 * Clones line/fee/shipping/tax/coupon items and addresses from the origin,
-	 * applies the contract's stored recurring totals as ground truth, attaches
-	 * the contract's payment token, and tags the order with the renewal
-	 * relation meta (contract id + current cycle) so charge observers and the
-	 * idempotency check can find it. Returns null (logged) when the origin order
-	 * cannot be loaded or `wc_create_order()` fails - the caller then skips the
-	 * advance.
-	 *
-	 * @param Contract $contract Contract being renewed.
-	 * @return WC_Order|null The saved pending renewal order, or null on failure.
-	 */
-	private function build_renewal_order( Contract $contract ): ?WC_Order {
-		$origin = wc_get_order( $contract->get_origin_order_id() );
+		$origin = wc_get_order( $origin_order_id );
 		if ( ! $origin instanceof WC_Order ) {
 			wc_get_logger()->error(
-				sprintf( 'RenewalEngine: cannot build renewal for contract %d - origin order %d not found.', (int) $contract->get_id(), $contract->get_origin_order_id() ),
+				sprintf( 'RenewalEngine: cannot build renewal for contract %d - origin order %d not found.', (int) $contract->get_id(), $origin_order_id ),
 				array(
 					'source'      => self::LOG_SOURCE,
 					'contract_id' => (int) $contract->get_id(),
@@ -422,21 +343,18 @@ final class RenewalEngine {
 		$renewal_order->set_address( $origin->get_address( 'billing' ), 'billing' );
 		$renewal_order->set_address( $origin->get_address( 'shipping' ), 'shipping' );

-		// Clone every relevant line type. `set_id( 0 )` turns each clone into a
-		// fresh row attached to the renewal order rather than UPDATE-ing the
-		// origin's row.
+		// `set_id( 0 )` turns each clone into a fresh row on the renewal order rather
+		// than UPDATE-ing the origin's row.
 		foreach ( $origin->get_items( array( 'line_item', 'fee', 'shipping', 'tax', 'coupon' ) ) as $item ) {
 			$clone = clone $item;
 			$clone->set_id( 0 );
 			$renewal_order->add_item( $clone );
 		}

-		// Contract totals are the price authority for the cycle - applied after
-		// add_item() so the line items do not recompute over them.
-		$renewal_order->set_discount_total( $contract->get_discount_total() );
-		$renewal_order->set_shipping_total( $contract->get_shipping_total() );
-		$renewal_order->set_cart_tax( $contract->get_tax_total() );
-		$renewal_order->set_total( $contract->get_billing_total() );
+		// The current cycle's expected_total is the price authority - applied after
+		// add_item() so the line items do not recompute over it. Reconstructing the
+		// granular discount/shipping/tax breakdown is the dispatcher money-path's job.
+		$renewal_order->set_total( $this->current_cycle_total( (int) $contract->get_id() ) );

 		$token_id = $instrument->get_token_id();
 		if ( null !== $token_id ) {
@@ -446,29 +364,38 @@ final class RenewalEngine {
 			}
 		}

-		// Tag with the renewal relation + the cycle this order bills, so the
-		// idempotency check can detect a duplicate fire for the same cycle.
+		// Tag the renewal relation + chargeable number so the idempotency check can
+		// detect a duplicate fire for the same number.
 		$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) ( $contract->get_cycle_count() + 1 ) );
+		$renewal_order->update_meta_data( self::renewal_cycle_meta_key(), (string) $count );

 		$renewal_order->save();

 		return $renewal_order;
 	}

+	/**
+	 * The amount the contract's current cycle expects to bill (its `expected_total`).
+	 * A contract with no cycle yet yields '0', making the charge a no-op rather than a
+	 * fatal - the safe state for a contract with nothing to bill.
+	 *
+	 * @param int $contract_id The contract being renewed.
+	 * @return string Decimal-safe amount string.
+	 */
+	private function current_cycle_total( int $contract_id ): string {
+		$cycle = $this->contracts->find_current_cycle( $contract_id );
+
+		return null === $cycle ? '0' : $cycle->get_expected_total();
+	}
+
 	/**
 	 * Attempt the gateway charge for `$renewal_order`.
 	 *
-	 * Mirrors WooCommerce Subscriptions' scheduled-payment dispatch: fire
-	 * `woocommerce_subscriptions_engine_scheduled_payment_{gateway}` with the
-	 * amount and the order so the contract's gateway (or its adapter) captures
-	 * using the stored token. The engine does not implement gateway charging
-	 * itself - it hands off to whatever gateway integration is registered, and
-	 * the resulting WC payment-complete / failed status is what downstream
-	 * accounting observes. A gateway that registers no handler leaves the order
-	 * `pending` (uncharged), which is the correct safe state for a contract
-	 * scheduled against a gateway that cannot actually charge.
+	 * Fires `woocommerce_subscriptions_engine_scheduled_payment_{gateway}` so the
+	 * registered gateway integration captures against the stored token; the engine does
+	 * not charge itself. A gateway that registers no handler leaves the order `pending`
+	 * (uncharged) - the safe state when it cannot actually charge.
 	 *
 	 * @param WC_Order $renewal_order The pending renewal order to charge.
 	 * @param Contract $contract      The contract being renewed.
@@ -483,21 +410,17 @@ final class RenewalEngine {

 		try {
 			/**
-			 * Fires to request a recurring charge for a renewal order.
-			 *
-			 * The contract's gateway (or a gateway adapter) hooks the
-			 * gateway-specific variant and captures against the stored token,
-			 * then transitions the order via the gateway's own
-			 * `payment_complete()` / failure handling.
+			 * 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.
 			 *
 			 * @param float    $amount        The amount to charge.
 			 * @param WC_Order $renewal_order The renewal order being charged.
 			 */
 			do_action( 'woocommerce_subscriptions_engine_scheduled_payment_' . $gateway_id, $amount, $renewal_order );
 		} catch ( Throwable $e ) {
-			// A throwing gateway handler must not leave the AS action in a
-			// retry-forever loop or roll back the advance we already persisted.
-			// Log and move on; the order stays pending for dunning to pick up.
+			// A throwing gateway handler must not leave the AS action in a retry-forever
+			// loop. Log and move on; the order stays pending for dunning to pick up.
 			wc_get_logger()->error(
 				sprintf( 'RenewalEngine: gateway charge for renewal order %d (contract %d) threw: %s', $renewal_order->get_id(), (int) $contract->get_id(), $e->getMessage() ),
 				array(
@@ -510,16 +433,13 @@ final class RenewalEngine {
 	}

 	/**
-	 * Whether a renewal order tagged for `$contract_id` at `$cycle` already exists.
+	 * Whether a renewal order tagged for `$contract_id` at `$cycle` already exists -
+	 * the idempotency check for AS retries.
 	 *
-	 * The idempotency check for AS retries. Queries on the single most-selective
-	 * key (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 three-clause `meta_query` because the legacy CPT order store (the
-	 * fallback under HPOS, and the only store with HPOS off) rejects `meta_query`
-	 * with `wc_doing_it_wrong` and drops it; the flat shortcut round-trips
-	 * through both stores. A contract has a handful of renewal orders, so the
-	 * in-memory narrowing is cheap.
+	 * 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
+	 * `meta_query` because the legacy CPT order store rejects `meta_query` with
+	 * `wc_doing_it_wrong`; the shortcut round-trips through both stores.
 	 *
 	 * @param int $contract_id Contract id.
 	 * @param int $cycle       The cycle number the renewal would bill.
@@ -535,9 +455,8 @@ final class RenewalEngine {
 			)
 		);

-		// This query does not paginate, so wc_get_orders() returns a plain list of
-		// orders. The guard narrows the declared WC_Order[]|stdClass return type and
-		// treats any unexpected non-array result as "no matching renewal".
+		// 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;
 		}
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 fbb129464bc..1adb1460296 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
@@ -1,10 +1,15 @@
 <?php
 /**
- * ContractRepository - persistence for {@see Contract} entities.
+ * Persistence for the live {@see Contract} (row + items / addresses / meta) plus
+ * targeted cycle access. Owns the $wpdb access across the contract-side tables.
  *
- * Lives in the integration layer: it owns the $wpdb access and spans the four
- * contract tables (contract row, items, addresses, meta), hydrating the Core
- * entity from clean arrays.
+ * 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()},
+ * 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}.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage
  */
@@ -14,12 +19,16 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage;

 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;

 defined( 'ABSPATH' ) || exit;

 /**
- * Contract repository.
+ * Live contract repository with targeted cycle access.
  */
 final class ContractRepository {

@@ -44,9 +53,31 @@ final class ContractRepository {
 		'phone',
 	);

+	/**
+	 * The per-contract typed snapshot store.
+	 *
+	 * @var SnapshotStore
+	 */
+	private $snapshots;
+
+	/**
+	 * Build a repository over the given snapshot store.
+	 *
+	 * @param SnapshotStore|null $snapshots Snapshot store; a default instance is
+	 *                                      created when omitted.
+	 */
+	public function __construct( ?SnapshotStore $snapshots = null ) {
+		$this->snapshots = $snapshots ?? new SnapshotStore();
+	}
+
 	/**
 	 * Insert a new contract and its items, addresses, and meta.
 	 *
+	 * Durable-intent-first (parent row, then children) and the seam a later
+	 * transaction-handling change wraps; it does not open a transaction now (a naive
+	 * one would commit the integration suite's outer test transaction, and bare
+	 * savepoints error in production without an ambient transaction).
+	 *
 	 * @param Contract $contract Contract to insert.
 	 * @return int The new contract id.
 	 * @throws \RuntimeException If the contract insert fails.
@@ -83,100 +114,77 @@ final class ContractRepository {
 		return $id;
 	}

+	/**
+	 * Insert a contract together with its signup cycle (cycle 1) - the checkout
+	 * create path.
+	 *
+	 * Durable-intent-first: insert the contract -> freeze the signup cycle's snapshots
+	 * (which need the contract id) -> record those ids on the contract and update its
+	 * row -> insert cycle 1 (which carries the same snapshot ids by construction). The
+	 * cycle is taken as built by the caller; this only stamps its contract id, resolves
+	 * its snapshots, and inserts it. The seam a later transaction-handling change wraps.
+	 *
+	 * @param Contract $contract The contract to insert.
+	 * @param Cycle    $cycle    The signup cycle (cycle 1), carrying its snapshot value objects.
+	 * @return int The new contract id.
+	 * @throws \RuntimeException If a contract, snapshot, or cycle write fails.
+	 */
+	public function insert_with_origin_cycle( Contract $contract, Cycle $cycle ): int {
+		$contract_id = $this->insert( $contract );
+
+		// First cycle in its chain: no previous to copy-forward from, so its snapshots
+		// are inserted fresh and their ids stamped onto it.
+		$cycle->set_contract_id( $contract_id );
+		$this->resolve_cycle_snapshots( $cycle, null );
+
+		// Record the signup snapshots as the contract's latest/live references, then
+		// persist the contract row before the cycle row (durable-intent-first).
+		$contract->set_plan_snapshot_id( $cycle->get_plan_snapshot_id() );
+		$contract->set_items_snapshot_id( $cycle->get_items_snapshot_id() );
+		$this->update_contract_row( $contract );
+
+		$this->insert_cycle( $cycle );
+
+		return $contract_id;
+	}
+
 	/**
 	 * Persist changes to an existing contract and its child rows.
 	 *
-	 * Updates the contract row in place, then replaces the items, addresses,
-	 * and meta rows wholesale (delete-then-reinsert). Replacement rather than a
-	 * per-row diff keeps update() symmetric with insert() - the child tables
-	 * always reflect the entity's current arrays - at the cost of churning rows
-	 * that did not change. The renewal money-path mutates only contract-row
-	 * scheduling fields (next_payment_gmt, cycle_count, status, the *_gmt
-	 * stamps), so the child churn is rare in practice; revisit with a diffing
-	 * upsert if a high-frequency child-row writer appears.
+	 * Updates the contract row in place, then reconciles items / addresses / meta only
+	 * when they differ - so the common renewal-cache write (status, next_payment_gmt)
+	 * does not churn child rows. The write seam a later transaction-handling change
+	 * wraps; no transaction now (see {@see self::insert()}).
 	 *
 	 * @param Contract $contract Contract to update. Must have an id whose row still exists.
 	 * @return bool True when the contract row was updated (or already current).
 	 * @throws \RuntimeException If the contract has no id, or its row no longer exists.
 	 */
 	public function update( Contract $contract ): bool {
-		global $wpdb;
-
 		$id = $contract->get_id();
 		if ( null === $id ) {
 			throw new \RuntimeException( 'Cannot update a contract that has no id. Use ContractRepository::insert() for a new contract.' );
 		}

-		$contracts_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
-
-		// Guard against a stale id / concurrent delete before touching any table.
-		// `$wpdb->update()` returns 0 for both "matched, nothing changed" and "no
-		// row matched", so on its own it cannot tell that the parent is gone - and
-		// the child delete/reinsert below would then write orphan rows, since no
-		// foreign key enforces the relation. This narrows but does not fully close
-		// the race; the complete fix wraps the whole method in a transaction with
-		// SELECT ... FOR UPDATE, tracked as separate hardening (the integration
-		// suite's transaction-based isolation needs a test-safe approach first).
-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-		$exists = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$contracts_table} WHERE id = %d", $id ) );
-		if ( null === $exists ) {
-			throw new \RuntimeException(
-				esc_html( sprintf( 'Cannot update contract %d: the contract row no longer exists (stale id or concurrent delete).', $id ) )
-			);
-		}
-
-		$data = $contract->to_storage();
-
-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-		$updated = $wpdb->update(
-			$contracts_table,
-			array_merge(
-				$data,
-				array( 'date_updated_gmt' => gmdate( 'Y-m-d H:i:s' ) )
-			),
-			array( 'id' => $id )
-		);
-
-		if ( false === $updated ) {
-			throw new \RuntimeException( 'Failed to update contract.' );
-		}
-
-		// Replace child rows so they mirror the entity's current arrays. Same
-		// delete set as delete(), minus the contract row itself.
-		foreach ( array(
-			SchemaInstaller::TABLE_CONTRACT_ITEMS,
-			SchemaInstaller::TABLE_CONTRACT_ADDRESSES,
-			SchemaInstaller::TABLE_CONTRACT_META,
-		) as $child ) {
-			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-			$wpdb->delete( SchemaInstaller::get_table_name( $child ), array( 'contract_id' => $id ) );
-		}
+		$this->assert_contract_exists( $id );

-		$this->insert_items( $id, $contract->get_items() );
-		$this->insert_addresses( $id, $contract->get_addresses() );
-		$this->insert_meta( $id, $contract->get_meta() );
+		$this->update_contract_row( $contract );
+		$this->sync_children( $contract );

-		// `$wpdb->update` returns 0 (int) when the row matched but no column
-		// changed - a successful no-op, not a failure. Only `false` is an error,
-		// and that path threw above; the "row no longer exists" case is ruled out
-		// by the existence guard at the top of this method.
 		return true;
 	}

 	/**
-	 * Fetch a contract by id, including its items, addresses, and meta.
+	 * Fetch a contract by id, hydrating the live entity with its items / addresses /
+	 * meta. Cycles are NOT hydrated - they are reached on demand through the targeted
+	 * cycle reads. For list / guard paths that do not need the children, use
+	 * {@see self::find_summary()}.
 	 *
 	 * @param int $id Contract id.
 	 * @return Contract|null Hydrated contract, or null if not found.
 	 */
 	public function find( int $id ): ?Contract {
-		global $wpdb;
-
-		$contracts = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
-
-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$contracts} WHERE id = %d", $id ), ARRAY_A );
-
+		$row = $this->find_contract_row( $id );
 		if ( null === $row ) {
 			return null;
 		}
@@ -190,7 +198,41 @@ final class ContractRepository {
 	}

 	/**
-	 * Delete a contract and its child rows.
+	 * Lightweight read: the contract row only, no children. The row IS the live state,
+	 * so list screens and guards that need only identity + schedule avoid loading children.
+	 *
+	 * @param int $id Contract id.
+	 * @return Contract|null The contract (row only), or null if not found.
+	 */
+	public function find_summary( int $id ): ?Contract {
+		$row = $this->find_contract_row( $id );
+		if ( null === $row ) {
+			return null;
+		}
+
+		return Contract::from_storage( $row );
+	}
+
+	/**
+	 * Whether a contract row exists for `$id`.
+	 *
+	 * @param int $id Contract id.
+	 */
+	public function exists( int $id ): bool {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$found = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE id = %d", $id ) );
+
+		return null !== $found;
+	}
+
+	/**
+	 * Delete a contract and its child + cycle rows.
+	 *
+	 * Snapshot rows are per contract, so they are deleted with the contract too.
 	 *
 	 * @param int $id Contract id.
 	 * @return bool True when the contract row was removed.
@@ -199,6 +241,8 @@ final class ContractRepository {
 		global $wpdb;

 		foreach ( array(
+			SchemaInstaller::TABLE_CYCLES,
+			SchemaInstaller::TABLE_SNAPSHOTS,
 			SchemaInstaller::TABLE_CONTRACT_ITEMS,
 			SchemaInstaller::TABLE_CONTRACT_ADDRESSES,
 			SchemaInstaller::TABLE_CONTRACT_META,
@@ -213,6 +257,508 @@ final class ContractRepository {
 		return (bool) $deleted;
 	}

+	/**
+	 * Append a cycle to its chain `(contract_id, kind)`, copy-forwarding snapshots.
+	 *
+	 * Resolves the cycle's snapshots (reused from `$previous` when unchanged, else
+	 * inserted fresh) then inserts the cycle row and stamps the generated id back onto
+	 * the entity. The seam a later transaction-handling change wraps.
+	 *
+	 * @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).
+	 */
+	public function append_cycle( Cycle $cycle, ?Cycle $previous = null ): void {
+		$this->resolve_cycle_snapshots( $cycle, $previous );
+		$this->insert_cycle( $cycle );
+	}
+
+	/**
+	 * Update an existing cycle row.
+	 *
+	 * @param Cycle $cycle Cycle to update. Must have an id whose row still exists.
+	 * @throws \RuntimeException If the cycle has no id, the update fails, or its row
+	 *                          no longer exists (stale id or concurrent delete).
+	 */
+	public function update_cycle( Cycle $cycle ): void {
+		global $wpdb;
+
+		$id = $cycle->get_id();
+		if ( null === $id ) {
+			throw new \RuntimeException( 'Cannot update a cycle that has no id. Use ContractRepository::append_cycle() for a new cycle.' );
+		}
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$updated = $wpdb->update(
+			SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES ),
+			array_merge(
+				$cycle->to_storage(),
+				array( 'date_updated_gmt' => gmdate( 'Y-m-d H:i:s' ) )
+			),
+			array( 'id' => (int) $id )
+		);
+
+		// date_updated_gmt always changes, so a matched row reports 1 affected row; 0
+		// means no row matched (stale id or concurrent delete) and must fail loudly.
+		if ( false === $updated || 0 === $updated ) {
+			throw new \RuntimeException( 'Cycle row not found during update (concurrent delete or stale id).' );
+		}
+	}
+
+	/**
+	 * 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()}).
+	 *
+	 * @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.
+	 */
+	public function find_current_cycle( int $contract_id, string $kind = Cycle::KIND_BILLING ): ?Cycle {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE contract_id = %d AND kind = %s ORDER BY sequence_no DESC LIMIT 1", $contract_id, $kind ), ARRAY_A );
+
+		if ( null === $row ) {
+			return null;
+		}
+
+		return $this->hydrate_cycle( $row );
+	}
+
+	/**
+	 * A window of a chain's cycle history, newest first - the paginated read for
+	 * history screens. Snapshots are decoded only for any in-flight cycle in the window.
+	 *
+	 * @param int    $contract_id Contract id.
+	 * @param string $kind        Chain kind. Defaults to billing.
+	 * @param int    $limit       Maximum rows to return.
+	 * @param int    $offset      Rows to skip (for paging).
+	 * @return array<int, Cycle> Cycles newest first.
+	 */
+	public function find_cycle_history( int $contract_id, string $kind = Cycle::KIND_BILLING, int $limit = 20, int $offset = 0 ): array {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE contract_id = %d AND kind = %s ORDER BY sequence_no DESC LIMIT %d OFFSET %d", $contract_id, $kind, $limit, $offset ), ARRAY_A );
+
+		$cycles = array();
+		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
+			if ( is_array( $row ) ) {
+				$cycles[] = $this->hydrate_cycle( self::as_string_keyed( $row ) );
+			}
+		}
+
+		return $cycles;
+	}
+
+	/**
+	 * The highest `count` in a chain `(contract_id, kind)` - the chargeable counter the
+	 * dispatcher advances (next chargeable cycle is `MAX(count) + 1`). Returns null for a
+	 * chain with no counting cycles (e.g. one holding only non-counting trial periods).
+	 *
+	 * @param int    $contract_id Contract id.
+	 * @param string $kind        Chain kind. Defaults to billing.
+	 * @return int|null The highest count, or null when the chain has no counting cycle.
+	 */
+	public function max_count( int $contract_id, string $kind = Cycle::KIND_BILLING ): ?int {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$max = $wpdb->get_var( $wpdb->prepare( "SELECT MAX(count) FROM {$table} WHERE contract_id = %d AND kind = %s", $contract_id, $kind ) );
+
+		return null === $max ? null : (int) $max;
+	}
+
+	/**
+	 * All cycles linked to `$order_id`, across kinds and contracts. `order_id` is a
+	 * non-1:1 reference (an aggregate order may serve many cycles), so this returns
+	 * every match. Snapshots are decoded for any in-flight cycle.
+	 *
+	 * @param int $order_id WooCommerce order id.
+	 * @return array<int, Cycle> Cycles linked to the order, oldest first.
+	 */
+	public function find_cycles_by_order_id( int $order_id ): array {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE order_id = %d ORDER BY id ASC", $order_id ), ARRAY_A );
+
+		$cycles = array();
+		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
+			if ( is_array( $row ) ) {
+				$cycles[] = $this->hydrate_cycle( self::as_string_keyed( $row ) );
+			}
+		}
+
+		return $cycles;
+	}
+
+	/**
+	 * Resolve a cycle's in-memory snapshot value objects into stored row ids (write-once).
+	 *
+	 * Per snapshot type: when the cycle carries a fresh value object and no stored id
+	 * yet, the id is copy-forwarded from `$previous` (when payload + schema version
+	 * match) or freshly inserted, then stamped onto the cycle. A cycle that already
+	 * carries an id is left as is - a frozen reference is never re-pointed.
+	 *
+	 * @param Cycle      $cycle    Cycle whose snapshots to resolve.
+	 * @param Cycle|null $previous The previous cycle to copy-forward from, or null.
+	 */
+	private function resolve_cycle_snapshots( Cycle $cycle, ?Cycle $previous ): void {
+		$contract_id = $cycle->get_contract_id();
+
+		$plan = $cycle->get_plan_snapshot();
+		if ( $plan instanceof PlanSnapshot && null === $cycle->get_plan_snapshot_id() ) {
+			$cycle->set_plan_snapshot_id(
+				$this->copy_forward_or_insert(
+					$contract_id,
+					SnapshotStore::TYPE_PLAN,
+					$plan->get_selling_plan_id(),
+					$plan->to_payload(),
+					$plan->get_schema_version(),
+					null === $previous ? null : $previous->get_plan_snapshot_id()
+				)
+			);
+		}
+
+		$items = $cycle->get_items_snapshot();
+		if ( $items instanceof ItemsSnapshot && null === $cycle->get_items_snapshot_id() ) {
+			$cycle->set_items_snapshot_id(
+				$this->copy_forward_or_insert(
+					$contract_id,
+					SnapshotStore::TYPE_ITEMS,
+					null,
+					$items->to_payload(),
+					$items->get_schema_version(),
+					null === $previous ? null : $previous->get_items_snapshot_id()
+				)
+			);
+		}
+	}
+
+	/**
+	 * Reuse the previous cycle's snapshot id when its payload + schema version match,
+	 * else insert a fresh row.
+	 *
+	 * The match is a strict comparison against the JSON-decoded stored payload, so
+	 * payload values must be JSON-round-trip-safe scalars: ints and strings (money as
+	 * decimal strings, never PHP floats - a float may not decode back identical,
+	 * defeating the reuse).
+	 *
+	 * @param int                      $contract_id          Owning contract id.
+	 * @param string                   $snapshot_type        Snapshot type (`plan` | `items`).
+	 * @param int|null                 $parent_id            Weak link to the source, or null.
+	 * @param array<int|string, mixed> $payload              The new snapshot payload.
+	 * @param int                      $schema_version       Payload-format version.
+	 * @param int|null                 $previous_snapshot_id The previous cycle's snapshot id, or null.
+	 * @return int The reused or newly-inserted snapshot id.
+	 */
+	private function copy_forward_or_insert( int $contract_id, string $snapshot_type, ?int $parent_id, array $payload, int $schema_version, ?int $previous_snapshot_id ): int {
+		if ( null !== $previous_snapshot_id ) {
+			$previous = $this->find_snapshot_payload( $previous_snapshot_id );
+			if ( null !== $previous && $previous['schema_version'] === $schema_version && $previous['payload'] === $payload ) {
+				return $previous_snapshot_id;
+			}
+		}
+
+		return $this->snapshots->insert( $contract_id, $snapshot_type, $parent_id, $payload, $schema_version );
+	}
+
+	/**
+	 * Hydrate a cycle row, attaching typed snapshot value objects only for an in-flight
+	 * (non-terminal) cycle. A settled record keeps its snapshot ids but skips the extra
+	 * reads to decode their payloads.
+	 *
+	 * @param array<string, mixed> $row Cycle row.
+	 * @return Cycle The hydrated cycle.
+	 */
+	private function hydrate_cycle( array $row ): Cycle {
+		$cycle = Cycle::from_storage( $row );
+
+		if ( CycleStatus::is_terminal( $cycle->get_status()->get_value() ) ) {
+			return $cycle;
+		}
+
+		$plan = $this->find_plan_snapshot( $cycle->get_plan_snapshot_id() );
+		if ( $plan instanceof PlanSnapshot ) {
+			$cycle->set_plan_snapshot( $plan );
+		}
+
+		$items = $this->find_items_snapshot( $cycle->get_items_snapshot_id() );
+		if ( $items instanceof ItemsSnapshot ) {
+			$cycle->set_items_snapshot( $items );
+		}
+
+		return $cycle;
+	}
+
+	/**
+	 * 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.
+	 */
+	private function insert_cycle( Cycle $cycle ): void {
+		global $wpdb;
+
+		$now = gmdate( 'Y-m-d H:i:s' );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$inserted = $wpdb->insert(
+			SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES ),
+			array_merge(
+				$cycle->to_storage(),
+				array(
+					'date_created_gmt' => $now,
+					'date_updated_gmt' => $now,
+				)
+			)
+		);
+
+		if ( false === $inserted ) {
+			throw new \RuntimeException( 'Failed to insert cycle.' );
+		}
+
+		$cycle->set_id( (int) $wpdb->insert_id );
+	}
+
+	/**
+	 * Decode a stored plan snapshot row into a typed value object.
+	 *
+	 * @param int|null $snapshot_id Snapshot row id, or null.
+	 * @return PlanSnapshot|null The decoded value object, or null.
+	 */
+	private function find_plan_snapshot( ?int $snapshot_id ): ?PlanSnapshot {
+		$decoded = $this->find_snapshot_payload( $snapshot_id );
+		if ( null === $decoded ) {
+			return null;
+		}
+
+		return PlanSnapshot::from_payload( self::as_string_keyed( $decoded['payload'] ), $decoded['schema_version'] );
+	}
+
+	/**
+	 * Decode a stored items snapshot row into a typed value object.
+	 *
+	 * @param int|null $snapshot_id Snapshot row id, or null.
+	 * @return ItemsSnapshot|null The decoded value object, or null.
+	 */
+	private function find_items_snapshot( ?int $snapshot_id ): ?ItemsSnapshot {
+		$decoded = $this->find_snapshot_payload( $snapshot_id );
+		if ( null === $decoded ) {
+			return null;
+		}
+
+		return ItemsSnapshot::from_payload( self::as_item_rows( $decoded['payload'] ), $decoded['schema_version'] );
+	}
+
+	/**
+	 * Read and JSON-decode a snapshot row's payload and schema version.
+	 *
+	 * @param int|null $snapshot_id Snapshot row id, or null.
+	 * @return array{payload: array<int|string, mixed>, schema_version: int}|null
+	 */
+	private function find_snapshot_payload( ?int $snapshot_id ): ?array {
+		global $wpdb;
+
+		if ( null === $snapshot_id ) {
+			return null;
+		}
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$row = $wpdb->get_row( $wpdb->prepare( "SELECT payload, schema_version FROM {$table} WHERE id = %d", $snapshot_id ), ARRAY_A );
+
+		if ( null === $row ) {
+			return null;
+		}
+
+		$payload = json_decode( (string) $row['payload'], true );
+
+		return array(
+			'payload'        => is_array( $payload ) ? $payload : array(),
+			'schema_version' => (int) $row['schema_version'],
+		);
+	}
+
+	/**
+	 * Re-key a decoded payload as a string-keyed map. A no-op at runtime (decoded JSON
+	 * object keys are already strings); it recovers the string-keyed type that
+	 * json_decode erases to `array<int|string, mixed>`.
+	 *
+	 * @param array<int|string, mixed> $payload Decoded payload.
+	 * @return array<string, mixed>
+	 */
+	private static function as_string_keyed( array $payload ): array {
+		$result = array();
+		foreach ( $payload as $key => $value ) {
+			$result[ (string) $key ] = $value;
+		}
+
+		return $result;
+	}
+
+	/**
+	 * Shape a decoded payload as an ordered list of item rows: each array element is
+	 * re-keyed as a string-keyed row, non-array elements skipped. Recovers the value
+	 * object's modelled shape without trusting the erased JSON types.
+	 *
+	 * @param array<int|string, mixed> $payload Decoded payload.
+	 * @return array<int, array<string, mixed>>
+	 */
+	private static function as_item_rows( array $payload ): array {
+		$rows = array();
+		foreach ( $payload as $row ) {
+			if ( is_array( $row ) ) {
+				$rows[] = self::as_string_keyed( $row );
+			}
+		}
+
+		return $rows;
+	}
+
+	/**
+	 * Read the contract row by id.
+	 *
+	 * @param int $id Contract id.
+	 * @return array<string, mixed>|null
+	 */
+	private function find_contract_row( int $id ): ?array {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), ARRAY_A );
+
+		return null === $row ? null : $row;
+	}
+
+	/**
+	 * Update the contract row in place (no child rows).
+	 *
+	 * @param Contract $contract Contract whose row to update. Must have an id.
+	 * @throws \RuntimeException If the update fails.
+	 */
+	private function update_contract_row( Contract $contract ): void {
+		global $wpdb;
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$updated = $wpdb->update(
+			SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ),
+			array_merge(
+				$contract->to_storage(),
+				array( 'date_updated_gmt' => gmdate( 'Y-m-d H:i:s' ) )
+			),
+			array( 'id' => (int) $contract->get_id() )
+		);
+
+		if ( false === $updated ) {
+			throw new \RuntimeException( 'Failed to update contract.' );
+		}
+	}
+
+	/**
+	 * Guard against a stale id / concurrent delete before touching any table.
+	 *
+	 * `$wpdb->update()` returns 0 for both "matched, nothing changed" and "no row
+	 * matched", so on its own it cannot tell the parent is gone - and a child reconcile
+	 * would then write orphan rows (no FK enforces the relation). This narrows but does
+	 * not fully close the race; the complete fix (a transaction with SELECT ... FOR UPDATE)
+	 * is the seam a later transaction-handling change wraps.
+	 *
+	 * @param int $id Contract id.
+	 * @throws \RuntimeException If the contract row no longer exists.
+	 */
+	private function assert_contract_exists( int $id ): void {
+		if ( ! $this->exists( $id ) ) {
+			throw new \RuntimeException(
+				esc_html( sprintf( 'Cannot update contract %d: the contract row no longer exists (stale id or concurrent delete).', $id ) )
+			);
+		}
+	}
+
+	/**
+	 * Reconcile a contract's items, addresses, and meta rows only when they differ.
+	 *
+	 * Each child set is compared via a normalized signature both the loaded rows and
+	 * the entity's arrays are projected through, so MySQL's column coercion (DECIMAL
+	 * padding, INT-as-string) applies to both sides and a no-op round-trip compares
+	 * equal. Only a changed set is rewritten (delete-then-reinsert for that one table).
+	 *
+	 * @param Contract $contract Contract whose children to reconcile. Must have an id.
+	 */
+	private function sync_children( Contract $contract ): void {
+		$id = (int) $contract->get_id();
+
+		if ( $this->items_signature( $this->find_items( $id ) ) !== $this->items_signature( $contract->get_items() ) ) {
+			$this->replace_items( $id, $contract->get_items() );
+		}
+
+		if ( $this->addresses_signature( $this->find_addresses( $id ) ) !== $this->addresses_signature( $contract->get_addresses() ) ) {
+			$this->replace_addresses( $id, $contract->get_addresses() );
+		}
+
+		if ( $this->meta_signature( $this->find_meta( $id ) ) !== $this->meta_signature( $contract->get_meta() ) ) {
+			$this->replace_meta( $id, $contract->get_meta() );
+		}
+	}
+
+	/**
+	 * Delete-then-reinsert a contract's item rows.
+	 *
+	 * @param int                              $contract_id Contract id.
+	 * @param array<int, array<string, mixed>> $items       Item rows.
+	 */
+	private function replace_items( int $contract_id, array $items ): void {
+		global $wpdb;
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ITEMS ), array( 'contract_id' => $contract_id ) );
+		$this->insert_items( $contract_id, $items );
+	}
+
+	/**
+	 * Delete-then-reinsert a contract's address rows.
+	 *
+	 * @param int                                 $contract_id Contract id.
+	 * @param array<string, array<string, mixed>> $addresses   Address rows keyed by type.
+	 */
+	private function replace_addresses( int $contract_id, array $addresses ): void {
+		global $wpdb;
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ADDRESSES ), array( 'contract_id' => $contract_id ) );
+		$this->insert_addresses( $contract_id, $addresses );
+	}
+
+	/**
+	 * Delete-then-reinsert a contract's meta rows.
+	 *
+	 * @param int                   $contract_id Contract id.
+	 * @param array<string, string> $meta        Meta as key => value.
+	 */
+	private function replace_meta( int $contract_id, array $meta ): void {
+		global $wpdb;
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ), array( 'contract_id' => $contract_id ) );
+		$this->insert_meta( $contract_id, $meta );
+	}
+
 	/**
 	 * Insert line items for a contract.
 	 *
@@ -257,7 +803,7 @@ final class ContractRepository {
 			);

 			foreach ( self::ADDRESS_COLUMNS as $column ) {
-				$record[ $column ] = self::coerce_nullable_string( $address[ $column ] ?? null );
+				$record[ $column ] = isset( $address[ $column ] ) ? self::coerce_string( $address[ $column ] ) : null;
 			}

 			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
@@ -275,8 +821,8 @@ final class ContractRepository {
 		global $wpdb;

 		foreach ( $meta as $key => $value ) {
-			// These are the engine's own contract-meta columns, not post/order
-			// meta; the slow-meta-query heuristic does not apply.
+			// The engine's own contract-meta columns, not post/order meta; the
+			// slow-meta-query heuristic does not apply.
 			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
 			$wpdb->insert(
 				SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ),
@@ -303,14 +849,7 @@ final class ContractRepository {
 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
 		$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE contract_id = %d ORDER BY id ASC", $contract_id ), ARRAY_A );

-		$items = array();
-		foreach ( (array) $rows as $row ) {
-			if ( is_array( $row ) ) {
-				$items[] = $row;
-			}
-		}
-
-		return $items;
+		return self::as_item_rows( is_array( $rows ) ? $rows : array() );
 	}

 	/**
@@ -328,9 +867,9 @@ final class ContractRepository {
 		$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE contract_id = %d", $contract_id ), ARRAY_A );

 		$by_type = array();
-		foreach ( (array) $rows as $row ) {
+		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
 			if ( is_array( $row ) ) {
-				$by_type[ self::coerce_string( $row['address_type'] ?? null ) ] = $row;
+				$by_type[ self::coerce_string( $row['address_type'] ?? null ) ] = self::as_string_keyed( $row );
 			}
 		}

@@ -348,13 +887,13 @@ final class ContractRepository {

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

-		// These are the engine's own contract-meta columns, not post/order meta;
-		// the slow-meta-query heuristic does not apply.
+		// The engine's own contract-meta columns, not post/order meta; the
+		// slow-meta-query heuristic does not apply.
 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
 		$rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, meta_value FROM {$table} WHERE contract_id = %d", $contract_id ), ARRAY_A );

 		$meta = array();
-		foreach ( (array) $rows as $row ) {
+		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
 			if ( is_array( $row ) ) {
 				$meta[ self::coerce_string( $row['meta_key'] ?? null ) ] = self::coerce_string( $row['meta_value'] ?? null );
 			}
@@ -362,4 +901,100 @@ final class ContractRepository {

 		return $meta;
 	}
+
+	/**
+	 * A change-detection signature for an item set: each item projected to the
+	 * comparable columns, with money / count fields coerced to the fixed shape MySQL
+	 * stores (DECIMAL scale, INT-as-string) so a no-op round-trip compares equal in
+	 * {@see self::sync_children()}. The id, `contract_id`, and column order are ignored.
+	 *
+	 * @param array<int, array<string, mixed>> $items Item arrays (loaded rows or entity items).
+	 * @return array<int, array<string, string|null>> Per-item comparable projection.
+	 */
+	private function items_signature( array $items ): array {
+		$signature = array();
+
+		foreach ( $items as $item ) {
+			$signature[] = array(
+				'item_name'    => self::coerce_string( $item['item_name'] ?? null ),
+				'item_type'    => self::coerce_string( $item['item_type'] ?? null, 'line_item' ),
+				'product_id'   => isset( $item['product_id'] ) ? (string) self::coerce_int( $item['product_id'] ) : null,
+				'variation_id' => isset( $item['variation_id'] ) ? (string) self::coerce_int( $item['variation_id'] ) : null,
+				'quantity'     => number_format( self::coerce_float( $item['quantity'] ?? 1 ), 4, '.', '' ),
+				'subtotal'     => number_format( self::coerce_float( $item['subtotal'] ?? 0 ), 8, '.', '' ),
+				'total'        => number_format( self::coerce_float( $item['total'] ?? 0 ), 8, '.', '' ),
+				'taxes'        => $this->taxes_signature( $item['taxes'] ?? null ),
+			);
+		}
+
+		return $signature;
+	}
+
+	/**
+	 * A change-detection signature for an address set, keyed by type.
+	 *
+	 * Projects each address to the persisted columns with the same string coercion
+	 * the load path returns, so an unchanged set compares equal in
+	 * {@see self::sync_children()}.
+	 *
+	 * @param array<string, array<string, mixed>> $addresses Address arrays keyed by type.
+	 * @return array<string, array<string, string|null>> Per-type comparable projection.
+	 */
+	private function addresses_signature( array $addresses ): array {
+		$signature = array();
+
+		foreach ( $addresses as $type => $address ) {
+			$record = array();
+			foreach ( self::ADDRESS_COLUMNS as $column ) {
+				$value             = isset( $address[ $column ] ) ? self::coerce_string( $address[ $column ] ) : '';
+				$record[ $column ] = '' !== $value ? $value : null;
+			}
+
+			$signature[ (string) $type ] = $record;
+		}
+
+		ksort( $signature );
+
+		return $signature;
+	}
+
+	/**
+	 * A change-detection signature for a meta set.
+	 *
+	 * @param array<string, string> $meta Meta as key => value.
+	 * @return array<string, string> Comparable projection (key-sorted).
+	 */
+	private function meta_signature( array $meta ): array {
+		$signature = array();
+
+		foreach ( $meta as $key => $value ) {
+			$signature[ (string) $key ] = (string) $value;
+		}
+
+		ksort( $signature );
+
+		return $signature;
+	}
+
+	/**
+	 * Normalize a taxes value to canonical JSON (or null), so a loaded JSON string and
+	 * the entity's decoded array compare equal regardless of which side it came from.
+	 *
+	 * @param mixed $taxes A JSON string (loaded) or an array (entity), or null.
+	 * @return string|null Canonical JSON, or null when there is nothing to compare.
+	 */
+	private function taxes_signature( $taxes ): ?string {
+		if ( null === $taxes ) {
+			return null;
+		}
+
+		$decoded = is_string( $taxes ) ? json_decode( $taxes, true ) : $taxes;
+		if ( ! is_array( $decoded ) ) {
+			return null;
+		}
+
+		$encoded = wp_json_encode( $decoded );
+
+		return false === $encoded ? null : $encoded;
+	}
 }
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 47598c51d55..878140b7af9 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
@@ -1,19 +1,14 @@
 <?php
 /**
- * SchemaInstaller - owns the engine's baseline database tables.
+ * Owns the engine's baseline database tables (plan, contract, and cycle tables).
+ * Mirrors the order/HPOS conventions: BIGINT UNSIGNED ids, `*_gmt` datetime
+ * columns, JSON columns for policy bundles, no foreign-key constraints. A chain is
+ * not a stored table: it is the pair `(contract_id, kind)` on the cycle rows.
  *
- * Creates and drops the plan tables (`wc_selling_plan_groups`,
- * `wc_selling_plans`) and the contract tables (`wc_subscription_contracts`,
- * `wc_subscription_contract_items`, `wc_subscription_contract_addresses`,
- * `wc_subscription_contract_meta`). Mirrors the order/HPOS conventions:
- * BIGINT UNSIGNED ids, `*_gmt` datetime columns, JSON columns for policy
- * bundles, no foreign-key constraints.
- *
- * Schema is additive-only: columns shipped here are permanent.
- *
- * The engine is bundled rather than independently activated, so install runs
- * through {@see self::maybe_install()} (a version-gated check on boot), not a
- * plugin activation hook.
+ * Pre-freeze, tables are private and mutable: schema changes ship via a `VERSION`
+ * bump that re-runs dbDelta, not migrations. Install runs through
+ * {@see self::maybe_install()} (a version-gated check on boot), as the engine is
+ * bundled rather than independently activated.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage
  */
@@ -33,10 +28,17 @@ final class SchemaInstaller {
 	 * Schema version. Bump when the CREATE TABLE statements change so the
 	 * version-gated install runs dbDelta again.
 	 *
-	 * 1.0.0 - baseline plan and contract tables, including the nullable `extension_slug`
-	 *         column on plans and contracts.
+	 * 1.0.0 - baseline plan and contract tables.
+	 * 2.0.0 - cycle-chain model: contract as live source of truth (schedule, snapshot
+	 *         references, totals, stamps); immutable cycle records keyed on
+	 *         `(contract_id, kind)`; per-contract snapshots deduped by copy-forward.
+	 *
+	 * 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.
 	 */
-	const VERSION = '1.0.0';
+	const VERSION = '2.0.0';

 	/**
 	 * Option key tracking the installed schema version.
@@ -52,6 +54,8 @@ final class SchemaInstaller {
 	const TABLE_CONTRACT_ITEMS     = 'contract_items';
 	const TABLE_CONTRACT_ADDRESSES = 'contract_addresses';
 	const TABLE_CONTRACT_META      = 'contract_meta';
+	const TABLE_CYCLES             = 'cycles';
+	const TABLE_SNAPSHOTS          = 'snapshots';

 	/**
 	 * Resolve a logical identifier to its prefixed table name.
@@ -126,10 +130,9 @@ final class SchemaInstaller {
 	}

 	/**
-	 * Map of logical => prefixed table names, keyed by TABLE_* constants.
-	 *
-	 * Contract tables use the `wc_subscription_*` prefix (what the data
-	 * represents), while the namespace boundary is about code ownership.
+	 * Map of logical => prefixed table names, keyed by TABLE_* constants. Contract
+	 * tables use the `wc_subscription_*` prefix (what the data represents), independent
+	 * of the code-ownership namespace boundary.
 	 *
 	 * @param string $prefix Usually `$wpdb->prefix`.
 	 * @return array<string, string>
@@ -142,16 +145,17 @@ final class SchemaInstaller {
 			self::TABLE_CONTRACT_ITEMS     => $prefix . 'wc_subscription_contract_items',
 			self::TABLE_CONTRACT_ADDRESSES => $prefix . 'wc_subscription_contract_addresses',
 			self::TABLE_CONTRACT_META      => $prefix . 'wc_subscription_contract_meta',
+			self::TABLE_CYCLES             => $prefix . 'wc_subscription_cycles',
+			self::TABLE_SNAPSHOTS          => $prefix . 'wc_subscription_snapshots',
 		);
 	}

 	/**
 	 * CREATE TABLE statements, formatted for dbDelta.
 	 *
-	 * The dbDelta function is fussy: each column on its own line, two spaces
-	 * between name and type, `KEY` (not `INDEX`), no trailing comma before
-	 * PRIMARY KEY. Do not reformat these without re-testing dbDelta diffing - it
-	 * parses with regex.
+	 * Formatting is fragile: dbDelta parses with regex (each column on its own line,
+	 * two spaces between name and type, `KEY` not `INDEX`, no trailing comma before
+	 * PRIMARY KEY). Do not reformat these without re-testing dbDelta diffing.
 	 *
 	 * @param array<string, string> $names   Map of logical => prefixed table names.
 	 * @param string                $collate Charset/collate clause from $wpdb.
@@ -164,10 +168,12 @@ final class SchemaInstaller {
 		$contract_items     = $names[ self::TABLE_CONTRACT_ITEMS ];
 		$contract_addresses = $names[ self::TABLE_CONTRACT_ADDRESSES ];
 		$contract_meta      = $names[ self::TABLE_CONTRACT_META ];
+		$cycles             = $names[ self::TABLE_CYCLES ];
+		$snapshots          = $names[ self::TABLE_SNAPSHOTS ];

-		// `merchant_code` is UNIQUE (not just KEY) for DB-enforced idempotency on
-		// consumer-supplied codes. NULL values are allowed and treated as distinct,
-		// so consumers that do not use merchant codes are unaffected.
+		// `merchant_code` is UNIQUE (not KEY) for DB-enforced idempotency on
+		// consumer-supplied codes; NULLs are treated as distinct, so consumers that do
+		// not use merchant codes are unaffected.
 		$plan_groups_sql = "CREATE TABLE {$plan_groups} (
   id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
   name VARCHAR(255) NOT NULL,
@@ -181,9 +187,9 @@ final class SchemaInstaller {
   KEY app_id (app_id)
 ) {$collate};";

-		// `extension_slug` records the registered slug of the extension that created the
-		// plan. Nullable while owner identifier/registration semantics are still
-		// open; tightened additively once decided.
+		// `extension_slug` records the creating extension's registered slug. Nullable
+		// while owner identifier/registration semantics are still open; tightened
+		// additively once decided.
 		$plans_sql = "CREATE TABLE {$plans} (
   id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
   group_id BIGINT UNSIGNED NOT NULL,
@@ -204,33 +210,36 @@ final class SchemaInstaller {
   KEY extension_slug (extension_slug)
 ) {$collate};";

-		// `currency` is first-class (forward-compat for multi-currency recurring;
-		// today always the store base currency). `schedule_source` distinguishes
-		// contracts whose renewals this engine owns from gateway-owned schedules.
-		// `extension_slug` mirrors the plans column. Totals follow the order PHP-property
-		// naming rather than the HPOS storage-column names.
+		// 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.
 		$contracts_sql = "CREATE TABLE {$contracts} (
   id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
   status VARCHAR(20) NOT NULL,
   customer_id BIGINT UNSIGNED NOT NULL,
   currency CHAR(3) NOT NULL,
   selling_plan_id BIGINT UNSIGNED NOT NULL,
-  origin_order_id BIGINT UNSIGNED NOT NULL,
+  origin_order_id BIGINT UNSIGNED NULL,
   extension_slug VARCHAR(64) NULL,
   payment_method VARCHAR(100) NULL,
   payment_method_title VARCHAR(200) NULL,
   payment_token_id BIGINT UNSIGNED NULL,
+  start_gmt DATETIME NOT NULL,
+  next_payment_gmt DATETIME NULL,
+  plan_snapshot_id BIGINT UNSIGNED NULL,
+  items_snapshot_id BIGINT UNSIGNED NULL,
   billing_total DECIMAL(26,8) NOT NULL DEFAULT 0,
   discount_total DECIMAL(26,8) NOT NULL DEFAULT 0,
   shipping_total DECIMAL(26,8) NOT NULL DEFAULT 0,
   tax_total DECIMAL(26,8) NOT NULL DEFAULT 0,
-  start_gmt DATETIME NOT NULL,
-  next_payment_gmt DATETIME NULL,
   last_payment_gmt DATETIME NULL,
   last_attempt_gmt DATETIME NULL,
   trial_end_gmt DATETIME NULL,
   end_gmt DATETIME NULL,
-  cycle_count INT UNSIGNED NOT NULL DEFAULT 0,
   schedule_source VARCHAR(20) NOT NULL DEFAULT 'primitive',
   date_created_gmt DATETIME NOT NULL,
   date_updated_gmt DATETIME NOT NULL,
@@ -257,7 +266,7 @@ final class SchemaInstaller {
 ) {$collate};";

 		// One billing + one shipping address per contract: composite PK on
-		// (contract_id, address_type). Mirrors the order-addresses column shape.
+		// (contract_id, address_type).
 		$contract_addresses_sql = "CREATE TABLE {$contract_addresses} (
   contract_id BIGINT UNSIGNED NOT NULL,
   address_type VARCHAR(20) NOT NULL,
@@ -284,6 +293,59 @@ final class SchemaInstaller {
   KEY contract_key (contract_id, meta_key(100))
 ) {$collate};";

+		// Immutable billing records. A chain is the pair `(contract_id, kind)` - there is
+		// no chains table - so cycles carry both directly. `chain_seq` (UNIQUE) keeps a
+		// chain from holding two cycles at one position; `chain_count` (UNIQUE) is the
+		// per-charge idempotency anchor, with `count` nullable so non-counting cycles
+		// (e.g. future trial periods) coexist freely under MySQL's NULL-distinct rule.
+		// 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).
+		$cycles_sql = "CREATE TABLE {$cycles} (
+  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+  contract_id BIGINT UNSIGNED NOT NULL,
+  kind VARCHAR(20) NOT NULL DEFAULT 'billing',
+  sequence_no INT UNSIGNED NOT NULL,
+  count INT UNSIGNED NULL,
+  status VARCHAR(20) NOT NULL,
+  reason TEXT NULL,
+  starts_at_gmt DATETIME NOT NULL,
+  ends_at_gmt DATETIME NOT NULL,
+  expected_total DECIMAL(26,8) NOT NULL DEFAULT 0,
+  currency CHAR(3) NOT NULL,
+  plan_snapshot_id BIGINT UNSIGNED NULL,
+  items_snapshot_id BIGINT UNSIGNED NULL,
+  order_id BIGINT UNSIGNED NULL,
+  extension_slug VARCHAR(64) NULL,
+  date_created_gmt DATETIME NOT NULL,
+  date_updated_gmt DATETIME NOT NULL,
+  PRIMARY KEY  (id),
+  UNIQUE KEY chain_seq (contract_id, kind, sequence_no),
+  UNIQUE KEY chain_count (contract_id, kind, count),
+  KEY due (kind, status, starts_at_gmt),
+  KEY order_id (order_id),
+  KEY contract_kind (contract_id, kind)
+) {$collate};";
+
+		// Per-contract typed snapshot payloads, deduped by copy-forward (no content
+		// hash). `parent_id` is the weak link back to the source (the plan a plan
+		// snapshot was taken from). `schema_version` is the payload-FORMAT version a
+		// reader parses/upcasts by, not the plan's content version. LONGTEXT payload for
+		// the MySQL 5.6 floor (no JSON column type).
+		$snapshots_sql = "CREATE TABLE {$snapshots} (
+  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+  contract_id BIGINT UNSIGNED NOT NULL,
+  snapshot_type VARCHAR(20) NOT NULL,
+  parent_id BIGINT UNSIGNED NULL,
+  schema_version SMALLINT UNSIGNED NOT NULL DEFAULT 1,
+  payload LONGTEXT NOT NULL,
+  date_created_gmt DATETIME NOT NULL,
+  PRIMARY KEY  (id),
+  KEY contract_type (contract_id, snapshot_type),
+  KEY parent (parent_id)
+) {$collate};";
+
 		return array(
 			$plan_groups_sql,
 			$plans_sql,
@@ -291,6 +353,8 @@ final class SchemaInstaller {
 			$contract_items_sql,
 			$contract_addresses_sql,
 			$contract_meta_sql,
+			$cycles_sql,
+			$snapshots_sql,
 		);
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SnapshotStore.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SnapshotStore.php
new file mode 100644
index 00000000000..0e52a58cf5e
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SnapshotStore.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * Per-contract typed storage for cycle snapshot payloads. Does the one thing
+ * copy-forward dedup leaves it - insert a typed snapshot row and return its id;
+ * deciding when to reuse an existing id is the caller's job (repository / factory),
+ * not this store's. There is no content hash or uniqueness constraint: copy-forward,
+ * not a UNIQUE index, is what keeps identical consecutive snapshots from duplicating.
+ *
+ * A row is per contract and typed (`contract_id`, `snapshot_type`, a weak `parent_id`
+ * link to the source, and a `schema_version` that is the payload FORMAT version, NOT
+ * the plan's content version). Integration layer: JSON encoding happens here, never
+ * in `Core\`. No foreign keys (MySQL 5.6 floor).
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Per-contract typed snapshot store.
+ */
+final class SnapshotStore {
+
+	const TYPE_PLAN  = 'plan';
+	const TYPE_ITEMS = 'items';
+
+	/**
+	 * Insert a typed snapshot row and return its id. The payload (the value object's
+	 * WP-free serialized form) is JSON-encoded here into the LONGTEXT column. No dedup
+	 * at insert time - copy-forward reuse is the caller's job.
+	 *
+	 * @param int                      $contract_id    Owning contract id.
+	 * @param string                   $snapshot_type  Snapshot type (`plan` | `items`).
+	 * @param int|null                 $parent_id      Weak link to the source (the plan id for a plan snapshot; null for items).
+	 * @param array<int|string, mixed> $payload        Snapshot payload to serialize.
+	 * @param int                      $schema_version Payload-format version.
+	 * @return int The inserted snapshot row id.
+	 * @throws \RuntimeException If the payload cannot be encoded or the insert fails.
+	 */
+	public function insert( int $contract_id, string $snapshot_type, ?int $parent_id, array $payload, int $schema_version ): int {
+		global $wpdb;
+
+		$json = wp_json_encode( $payload );
+		if ( false === $json ) {
+			throw new \RuntimeException( 'Failed to JSON-encode snapshot payload.' );
+		}
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$inserted = $wpdb->insert(
+			$table,
+			array(
+				'contract_id'      => $contract_id,
+				'snapshot_type'    => $snapshot_type,
+				'parent_id'        => $parent_id,
+				'schema_version'   => $schema_version,
+				'payload'          => $json,
+				'date_created_gmt' => gmdate( 'Y-m-d H:i:s' ),
+			)
+		);
+
+		if ( false === $inserted ) {
+			throw new \RuntimeException( 'Failed to insert snapshot.' );
+		}
+
+		return (int) $wpdb->insert_id;
+	}
+}
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 1fed245e80c..1bc78f3fadf 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
@@ -13,6 +13,8 @@ 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\ValueObject\BillingPolicy;
@@ -71,6 +73,9 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
 		return $order;
 	}

+	/**
+	 * @testdox create_from_order persists and links a lean contract.
+	 */
 	public function test_create_from_order_persists_and_links_contract(): void {
 		$order = $this->make_order();
 		$plan  = $this->make_plan();
@@ -97,6 +102,71 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
 		$this->assertSame( OrderLinkage::RELATION_PARENT, $tagged_order->get_meta( OrderLinkage::META_RELATION_TYPE ) );
 	}

+	/**
+	 * @testdox create_from_order builds cycle 1 as the paid origin period.
+	 */
+	public function test_create_from_order_builds_cycle_one_as_the_origin_period(): void {
+		$order = $this->make_order();
+		$order->set_date_paid( '2026-01-15 00:00:00' );
+		$order->save();
+
+		$contract = ( new ContractFactory() )->create_from_order( $order, $this->make_plan() );
+
+		// The contract's next-bill cache is the first renewal, one cadence out.
+		$this->assertSame( '2026-02-15 00:00:00', $contract->get_next_payment_gmt() );
+
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$repo  = new ContractRepository();
+		$cycle = $repo->find_current_cycle( $contract_id );
+
+		$this->assertInstanceOf( Cycle::class, $cycle );
+		$this->assertSame( Cycle::KIND_BILLING, $cycle->get_kind() );
+		$this->assertSame( 1, $cycle->get_sequence_no() );
+
+		// Cycle 1 is the origin period: count 1, linked to the origin order, billed.
+		$this->assertSame( 1, $cycle->get_count() );
+		$this->assertSame( $order->get_id(), $cycle->get_order_id() );
+		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertSame( 'lite', $cycle->get_extension_slug() );
+
+		// Its period runs from the paid time to the first renewal date.
+		$this->assertSame( '2026-01-15 00:00:00', $cycle->get_starts_at_gmt() );
+		$this->assertSame( '2026-02-15 00:00:00', $cycle->get_ends_at_gmt() );
+		$this->assertSame( '19.99000000', $cycle->get_expected_total() );
+
+		// It carries the plan + items snapshots, stored on the repository create path.
+		$this->assertNotNull( $cycle->get_plan_snapshot_id() );
+		$this->assertNotNull( $cycle->get_items_snapshot_id() );
+
+		// The contract is the live source of truth: it records the SAME snapshot refs
+		// as cycle 1 and seeds its live billing total from the order.
+		$reloaded = $repo->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $reloaded );
+		$this->assertSame( $cycle->get_plan_snapshot_id(), $reloaded->get_plan_snapshot_id() );
+		$this->assertSame( $cycle->get_items_snapshot_id(), $reloaded->get_items_snapshot_id() );
+		$this->assertSame( '19.99000000', $reloaded->get_billing_total() );
+	}
+
+	/**
+	 * @testdox The origin cycle is reachable by the origin order id.
+	 */
+	public function test_origin_cycle_is_reachable_by_order_id(): void {
+		$order = $this->make_order();
+		$order->save();
+
+		$contract = ( new ContractFactory() )->create_from_order( $order, $this->make_plan() );
+
+		$cycles = ( new ContractRepository() )->find_cycles_by_order_id( $order->get_id() );
+		$this->assertCount( 1, $cycles );
+		$this->assertSame( $contract->get_id(), $cycles[0]->get_contract_id() );
+		$this->assertSame( 1, $cycles[0]->get_count() );
+	}
+
+	/**
+	 * @testdox The first renewal date follows the billing cadence.
+	 */
 	public function test_first_renewal_date_follows_billing_cadence(): void {
 		$order = $this->make_order();
 		$order->set_date_paid( '2026-01-15 00:00:00' );
@@ -104,10 +174,12 @@ class ContractFactoryTest extends EngineIntegrationTestCase {

 		$contract = ( new ContractFactory() )->create_from_order( $order, $this->make_plan() );

-		// Monthly cadence anchored on the paid date.
 		$this->assertSame( '2026-02-15 00:00:00', $contract->get_next_payment_gmt() );
 	}

+	/**
+	 * @testdox A native trial delays the first renewal date.
+	 */
 	public function test_native_trial_delays_first_renewal(): void {
 		$order = $this->make_order();
 		$order->set_date_paid( '2026-01-15 00:00:00' );
@@ -125,8 +197,19 @@ class ContractFactoryTest extends EngineIntegrationTestCase {

 		// First bill is the trial end, not one month out.
 		$this->assertSame( '2026-01-29 00:00:00', $contract->get_next_payment_gmt() );
+
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		// Cycle 1's period end matches that first renewal date.
+		$cycle = ( new ContractRepository() )->find_current_cycle( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $cycle );
+		$this->assertSame( '2026-01-29 00:00:00', $cycle->get_ends_at_gmt() );
 	}

+	/**
+	 * @testdox Overrides take precedence over order-derived values.
+	 */
 	public function test_overrides_take_precedence(): void {
 		$order = $this->make_order();
 		$plan  = $this->make_plan();
@@ -140,10 +223,21 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
 			)
 		);

-		$this->assertSame( '49.00', $contract->get_billing_total() );
+		// next_payment_gmt override sets both the cache and cycle 1's period end.
 		$this->assertSame( '2026-12-01 00:00:00', $contract->get_next_payment_gmt() );
+
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$cycle = ( new ContractRepository() )->find_current_cycle( $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() );
 	}

+	/**
+	 * @testdox An unsaved plan is rejected.
+	 */
 	public function test_unsaved_plan_is_rejected(): void {
 		$order = $this->make_order();
 		$plan  = Plan::create(
@@ -159,6 +253,9 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
 		( new ContractFactory() )->create_from_order( $order, $plan );
 	}

+	/**
+	 * @testdox An unsaved order is rejected.
+	 */
 	public function test_unsaved_order_is_rejected(): void {
 		// An order that was never saved reports id 0, which would persist
 		// origin_order_id => 0 and link the contract to a non-existent order.
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 393c8389a1f..622c64f0bde 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
@@ -68,39 +68,11 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		return $order;
 	}

-	/**
-	 * Renewal orders tagged for a contract at a given cycle, narrowed in PHP
-	 * (store-agnostic, like the engine's own idempotency check).
-	 *
-	 * @param int $contract_id Contract id.
-	 * @param int $cycle       Cycle number.
-	 * @return array<int, WC_Order>
-	 */
-	private function renewal_orders_for_cycle( int $contract_id, int $cycle ): 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
-			)
-		);
-		$this->assertIsArray( $orders );
-
-		return array_values(
-			array_filter(
-				$orders,
-				static function ( $order ) use ( $cycle ) {
-					return $order instanceof WC_Order
-						&& OrderLinkage::RELATION_RENEWAL === $order->get_meta( OrderLinkage::META_RELATION_TYPE )
-						&& (string) $cycle === $order->get_meta( '_subscription_renewal_cycle' );
-				}
-			)
-		);
-	}
-
-	private function make_contract( int $plan_id, int $origin_order_id, ?int $max_cycles = null ): Contract {
+	private function make_contract( int $plan_id, int $origin_order_id ): Contract {
+		// A lean contract row (no cycles). The renewal-advancement tests that
+		// exercise the money-path are skipped until the dispatcher slice; when they
+		// are reactivated they will append a billing cycle (with an expected_total)
+		// so the renewal amount resolves off the current cycle.
 		$contract = Contract::create(
 			array(
 				'customer_id'      => 1,
@@ -108,7 +80,6 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 				'selling_plan_id'  => $plan_id,
 				'origin_order_id'  => $origin_order_id,
 				'payment_method'   => self::GATEWAY,
-				'billing_total'    => '19.99',
 				'start_gmt'        => '2026-01-15 00:00:00',
 				'next_payment_gmt' => '2026-02-15 00:00:00',
 			)
@@ -118,11 +89,13 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		return $contract;
 	}

+	/**
+	 * @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() );
-
+		$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 );

@@ -138,13 +111,15 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$this->assertTrue( RenewalScheduler::is_scheduled( $contract_id ) );
 	}

+	/**
+	 * @testdox schedule replaces any existing pending row (one row per contract).
+	 */
 	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() );
-
+		$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 );

@@ -164,125 +139,123 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$this->assertCount( 1, $pending );
 	}

-	public function test_process_due_creates_renewal_and_advances(): void {
+	/**
+	 * @testdox process_due creates a renewal order tagged for the next chargeable number.
+	 */
+	public function test_process_due_creates_renewal_order(): 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() );
-
+		$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();
-		$renewal_order = $engine->process_due( $contract_id );
+		$renewal_order = ( new RenewalEngine() )->process_due( $contract_id );

+		// Order creation is wired; advancing the chain is the dispatcher slice, so
+		// only order creation + tagging is asserted here.
 		$this->assertInstanceOf( WC_Order::class, $renewal_order );
 		$this->assertSame( (string) $contract_id, $renewal_order->get_meta( OrderLinkage::META_CONTRACT_ID ) );
 		$this->assertSame( OrderLinkage::RELATION_RENEWAL, $renewal_order->get_meta( OrderLinkage::META_RELATION_TYPE ) );
-		$this->assertSame( '19.99', $renewal_order->get_total() );
-
-		// Contract advanced: cycle_count incremented, next bill date moved one month.
-		$reloaded = ( new ContractRepository() )->find( $contract_id );
-		$this->assertInstanceOf( Contract::class, $reloaded );
-		$this->assertSame( 1, $reloaded->get_cycle_count() );
-		$this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
-		$this->assertSame( ContractStatus::ACTIVE, $reloaded->get_status() );

-		// Next cycle re-armed.
-		$this->assertTrue( RenewalScheduler::is_scheduled( $contract_id ) );
+		// A lean contract has no counting cycle yet, so the next chargeable number is 1.
+		$this->assertSame( '1', $renewal_order->get_meta( '_subscription_renewal_cycle' ) );
+		$this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 1 ) );
 	}

+	/**
+	 * @testdox process_due is idempotent: a retried due action creates no second order.
+	 */
 	public function test_process_due_is_idempotent_for_a_retried_cycle(): void {
 		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );

-		$repo     = new ContractRepository();
-		$plan_id  = $this->make_plan();
-		$order    = $this->make_origin_order();
-		$contract = $this->make_contract( $plan_id, $order->get_id() );
-		$engine   = new RenewalEngine();
-
+		$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();

-		// First fire creates the cycle-1 renewal and advances to cycle 1.
 		$first = $engine->process_due( $contract_id );
 		$this->assertInstanceOf( WC_Order::class, $first );

-		// Simulate an Action Scheduler retry of the same due action: rewind the
-		// persisted contract to its pre-advance state (cycle 0, original next
-		// date) so the retry attempts cycle 1 again - exactly what a duplicate
-		// dispatch would do before the advance committed.
-		$rewound = $repo->find( $contract_id );
-		$this->assertInstanceOf( Contract::class, $rewound );
-		$rewound->set_cycle_count( 0 );
-		$rewound->set_status( ContractStatus::ACTIVE );
-		$rewound->set_next_payment_gmt( '2026-02-15 00:00:00' );
-		$repo->update( $rewound );
-
+		// A retried due action for the same chargeable number is suppressed.
 		$retry = $engine->process_due( $contract_id );
-
-		// The per-cycle guard suppresses the retry: no second order, no advance.
 		$this->assertNull( $retry );

 		$this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 1 ) );
-
-		$reloaded = $repo->find( $contract_id );
-		$this->assertInstanceOf( Contract::class, $reloaded );
-		$this->assertSame( 0, $reloaded->get_cycle_count() );
 	}

+	/**
+	 * @testdox process_due expires the contract when it hits max cycles.
+	 */
 	public function test_process_due_expires_contract_at_max_cycles(): void {
-		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
+		$this->markTestSkipped( 'Max-cycle expiry lands with the dispatcher.' );
+	}

-		$plan_id  = $this->make_plan( 1 );
-		$order    = $this->make_origin_order();
-		$contract = $this->make_contract( $plan_id, $order->get_id() );
+	/**
+	 * @testdox process_due skips a non-active contract and creates no renewal order.
+	 */
+	public function test_process_due_skips_non_active_contract(): 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 );
+		$contract->set_status( ContractStatus::ON_HOLD );
+		( new ContractRepository() )->update( $contract );

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

-		$reloaded = ( new ContractRepository() )->find( $contract_id );
-		$this->assertInstanceOf( Contract::class, $reloaded );
-		$this->assertSame( 1, $reloaded->get_cycle_count() );
-		$this->assertSame( ContractStatus::EXPIRED, $reloaded->get_status() );
-		$this->assertNull( $reloaded->get_next_payment_gmt() );
-		$this->assertFalse( RenewalScheduler::is_scheduled( $contract_id ) );
+		$this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 1 ) );
 	}

-	public function test_process_due_skips_non_active_contract(): void {
+	/**
+	 * @testdox process_due skips a gateway-scheduled contract and creates no renewal order.
+	 */
+	public function test_process_due_skips_gateway_scheduled_contract(): 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->set_status( ContractStatus::ON_HOLD );
-		( new ContractRepository() )->update( $contract );
-
-		$contract_id = $contract->get_id();
-		$this->assertNotNull( $contract_id );
+		$plan_id     = $this->make_plan();
+		$order       = $this->make_origin_order();
+		$contract    = Contract::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 = ( new ContractRepository() )->insert( $contract );

+		// Active, but the gateway owns the schedule: the primitive path bails.
 		$this->assertNull( ( new RenewalEngine() )->process_due( $contract_id ) );
-
-		$reloaded = ( new ContractRepository() )->find( $contract_id );
-		$this->assertInstanceOf( Contract::class, $reloaded );
-		$this->assertSame( 0, $reloaded->get_cycle_count() );
+		$this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 1 ) );
 	}

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

+	/**
+	 * @testdox cancel transitions the contract to cancelled and clears its pending row.
+	 */
 	public function test_cancel_transitions_and_unschedules(): 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() );
-
+		$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 );

@@ -298,30 +271,60 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$this->assertFalse( RenewalScheduler::is_scheduled( $contract_id ) );
 	}

+	/**
+	 * @testdox A gateway-scheduled contract is not scheduled by the engine.
+	 */
 	public function test_gateway_scheduled_contract_is_not_scheduled(): void {
 		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );

-		$plan_id  = $this->make_plan();
-		$order    = $this->make_origin_order();
-		$contract = Contract::create(
+		$plan_id     = $this->make_plan();
+		$order       = $this->make_origin_order();
+		$contract    = Contract::create(
 			array(
 				'customer_id'      => 1,
 				'currency'         => 'USD',
 				'selling_plan_id'  => $plan_id,
 				'origin_order_id'  => $order->get_id(),
 				'payment_method'   => self::GATEWAY,
-				'billing_total'    => '19.99',
 				'start_gmt'        => '2026-01-15 00:00:00',
 				'next_payment_gmt' => '2026-02-15 00:00:00',
 				'schedule_source'  => Contract::SCHEDULE_SOURCE_GATEWAY,
 			)
 		);
-		( new ContractRepository() )->insert( $contract );
-
-		$contract_id = $contract->get_id();
-		$this->assertNotNull( $contract_id );
+		$contract_id = ( new ContractRepository() )->insert( $contract );

 		$this->assertFalse( ( new RenewalEngine() )->schedule( $contract ) );
 		$this->assertFalse( RenewalScheduler::is_scheduled( $contract_id ) );
 	}
+
+	/**
+	 * Renewal orders tagged for a contract at a given chargeable number, narrowed
+	 * in PHP (store-agnostic, like the engine's own idempotency check).
+	 *
+	 * @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/Storage/ContractRepositoryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
index 726bce0fec8..b8d61c22776 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
@@ -1,6 +1,6 @@
 <?php
 /**
- * Integration tests for ContractRepository.
+ * Integration tests for the lean ContractRepository and its targeted cycle access.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine
  */
@@ -12,13 +12,30 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integrati
 use EngineIntegrationTestCase;
 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\ValueObject\ItemsSnapshot;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository
  */
 class ContractRepositoryTest extends EngineIntegrationTestCase {

+	/**
+	 * The System Under Test.
+	 *
+	 * @var ContractRepository
+	 */
+	private $sut;
+
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new ContractRepository();
+	}
+
 	private function make_contract(): Contract {
 		return Contract::create(
 			array(
@@ -30,9 +47,16 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 				'payment_method'       => 'woocommerce_payments',
 				'payment_method_title' => 'Credit card',
 				'payment_token_id'     => 55,
-				'billing_total'        => '19.99',
 				'start_gmt'            => '2026-06-15 00:00:00',
 				'next_payment_gmt'     => '2026-07-15 00:00:00',
+				'billing_total'        => '19.99',
+				'discount_total'       => '1.00',
+				'shipping_total'       => '5.00',
+				'tax_total'            => '2.50',
+				'last_payment_gmt'     => '2026-06-15 00:00:00',
+				'last_attempt_gmt'     => '2026-06-15 00:00:00',
+				'trial_end_gmt'        => null,
+				'end_gmt'              => null,
 				'items'                => array(
 					array(
 						'item_name'  => 'Coffee bag',
@@ -63,13 +87,64 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		);
 	}

-	public function test_contract_round_trips_with_children(): void {
-		$repo = new ContractRepository();
+	/**
+	 * Build a billing cycle for the given contract at a sequence/count.
+	 *
+	 * @param int                $contract_id Contract id.
+	 * @param int                $sequence_no Position in the chain.
+	 * @param int|null           $count       Chargeable count, or null for non-counting.
+	 * @param string             $starts_at   Period start GMT string.
+	 * @param string             $ends_at     Period end GMT string.
+	 * @param PlanSnapshot|null  $plan        Plan snapshot, or null.
+	 * @param ItemsSnapshot|null $items       Items snapshot, or null.
+	 * @param int|null           $order_id    Linked order id, or null.
+	 */
+	private function make_cycle( int $contract_id, int $sequence_no, ?int $count, string $starts_at, string $ends_at, ?PlanSnapshot $plan = null, ?ItemsSnapshot $items = null, ?int $order_id = null ): Cycle {
+		return Cycle::create(
+			array(
+				'contract_id'    => $contract_id,
+				'sequence_no'    => $sequence_no,
+				'count'          => $count,
+				'starts_at_gmt'  => $starts_at,
+				'ends_at_gmt'    => $ends_at,
+				'expected_total' => '19.99',
+				'currency'       => 'USD',
+				'extension_slug' => 'lite',
+				'order_id'       => $order_id,
+				'plan_snapshot'  => $plan,
+				'items_snapshot' => $items,
+			)
+		);
+	}
+
+	private function sample_plan_snapshot(): PlanSnapshot {
+		return PlanSnapshot::from_array(
+			array(
+				'selling_plan_id' => 7,
+				'cadence'         => 'monthly',
+			)
+		);
+	}
+
+	private function sample_items_snapshot(): ItemsSnapshot {
+		return ItemsSnapshot::from_items(
+			array(
+				array(
+					'product_id' => 200,
+					'quantity'   => 1,
+				),
+			)
+		);
+	}

-		$id = $repo->insert( $this->make_contract() );
+	/**
+	 * @testdox A contract round-trips its live state, children, and config, no cycle graph.
+	 */
+	public function test_contract_round_trips_its_live_state(): void {
+		$id = $this->sut->insert( $this->make_contract() );
 		$this->assertGreaterThan( 0, $id );

-		$fetched = $repo->find( $id );
+		$fetched = $this->sut->find( $id );

 		$this->assertInstanceOf( Contract::class, $fetched );
 		$this->assertSame( $id, $fetched->get_id() );
@@ -77,32 +152,104 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$this->assertSame( 'USD', $fetched->get_currency() );
 		$this->assertSame( 'lite', $fetched->get_extension_slug() );
 		$this->assertSame( ContractStatus::ACTIVE, $fetched->get_status() );
+		$this->assertSame( 1001, $fetched->get_origin_order_id() );
 		$this->assertSame( '2026-07-15 00:00:00', $fetched->get_next_payment_gmt() );

-		// Payment instrument reference.
+		// The live config round-trips (totals normalized to the storage scale).
+		$this->assertSame( '19.99000000', $fetched->get_billing_total() );
+		$this->assertSame( '1.00000000', $fetched->get_discount_total() );
+		$this->assertSame( '5.00000000', $fetched->get_shipping_total() );
+		$this->assertSame( '2.50000000', $fetched->get_tax_total() );
+		$this->assertSame( '2026-06-15 00:00:00', $fetched->get_last_payment_gmt() );
+		$this->assertSame( '2026-06-15 00:00:00', $fetched->get_last_attempt_gmt() );
+		$this->assertNull( $fetched->get_trial_end_gmt() );
+		$this->assertNull( $fetched->get_end_gmt() );
+
 		$instrument = $fetched->get_payment_instrument();
 		$this->assertSame( 55, $instrument->get_token_id() );
 		$this->assertSame( 'woocommerce_payments', $instrument->get_gateway() );

-		// Items.
 		$items = $fetched->get_items();
 		$this->assertCount( 1, $items );
 		$this->assertSame( 'Coffee bag', $items[0]['item_name'] );

-		// Addresses.
 		$addresses = $fetched->get_addresses();
 		$this->assertArrayHasKey( Contract::ADDRESS_BILLING, $addresses );
 		$this->assertArrayHasKey( Contract::ADDRESS_SHIPPING, $addresses );
 		$this->assertSame( 'Ada', $addresses[ Contract::ADDRESS_BILLING ]['first_name'] );

-		// Meta.
 		$this->assertSame( 'pdp', $fetched->get_meta()['source_channel'] );
 	}

-	public function test_extension_slug_defaults_to_null_when_unset(): void {
-		$repo = new ContractRepository();
+	/**
+	 * @testdox find_summary reads the contract row only, without children.
+	 */
+	public function test_find_summary_reads_the_contract_row_only(): void {
+		$id = $this->sut->insert( $this->make_contract() );

-		$id = $repo->insert(
+		$summary = $this->sut->find_summary( $id );
+
+		$this->assertInstanceOf( Contract::class, $summary );
+		$this->assertSame( $id, $summary->get_id() );
+		$this->assertSame( '2026-07-15 00:00:00', $summary->get_next_payment_gmt() );
+		$this->assertSame( array(), $summary->get_items() );
+		$this->assertSame( array(), $summary->get_meta() );
+	}
+
+	/**
+	 * @testdox A manual/admin contract with a null origin order round-trips.
+	 */
+	public function test_contract_round_trips_a_null_origin_order(): void {
+		$id = $this->sut->insert(
+			Contract::create(
+				array(
+					'customer_id'     => 1,
+					'currency'        => 'EUR',
+					'selling_plan_id' => 2,
+					'start_gmt'       => '2026-06-15 00:00:00',
+				)
+			)
+		);
+
+		$fetched = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $fetched );
+		$this->assertNull( $fetched->get_origin_order_id() );
+	}
+
+	/**
+	 * @testdox insert_with_origin_cycle records cycle 1's snapshot refs on the contract too.
+	 */
+	public function test_insert_with_origin_cycle_records_refs_on_the_contract(): void {
+		$contract = $this->make_contract();
+		$cycle    = $this->make_cycle( 0, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', $this->sample_plan_snapshot(), $this->sample_items_snapshot(), 1001 );
+		$cycle->set_status( CycleStatus::billed() );
+
+		$id = $this->sut->insert_with_origin_cycle( $contract, $cycle );
+		$this->assertGreaterThan( 0, $id );
+
+		// The signup cycle was stamped with the contract id and its snapshots resolved.
+		$this->assertSame( $id, $cycle->get_contract_id() );
+		$this->assertNotNull( $cycle->get_plan_snapshot_id() );
+		$this->assertNotNull( $cycle->get_items_snapshot_id() );
+
+		// The contract carries the SAME snapshot refs as cycle 1 (latest/live).
+		$reloaded = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $reloaded );
+		$this->assertSame( $cycle->get_plan_snapshot_id(), $reloaded->get_plan_snapshot_id() );
+		$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 );
+		$this->assertInstanceOf( Cycle::class, $current );
+		$this->assertSame( 1, $current->get_count() );
+		$this->assertTrue( $current->get_status()->equals( CycleStatus::billed() ) );
+	}
+
+	/**
+	 * @testdox extension_slug defaults to null when unset.
+	 */
+	public function test_extension_slug_defaults_to_null_when_unset(): void {
+		$id = $this->sut->insert(
 			Contract::create(
 				array(
 					'customer_id'     => 1,
@@ -114,49 +261,67 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 			)
 		);

-		$found = $repo->find( $id );
-		$this->assertInstanceOf( Contract::class, $found );
-		$this->assertNull( $found->get_extension_slug() );
+		$fetched = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $fetched );
+		$this->assertNull( $fetched->get_extension_slug() );
 	}

-	public function test_update_persists_scheduling_fields(): void {
-		$repo = new ContractRepository();
-		$id   = $repo->insert( $this->make_contract() );
+	/**
+	 * @testdox update persists the contract-row cache without touching the cycle rows.
+	 */
+	public function test_update_persists_the_contract_cache(): void {
+		$id = $this->sut->insert( $this->make_contract() );

-		$contract = $repo->find( $id );
+		$contract = $this->sut->find( $id );
 		$this->assertInstanceOf( Contract::class, $contract );
 		$contract->set_status( ContractStatus::ON_HOLD );
 		$contract->set_next_payment_gmt( '2026-08-15 00:00:00' );
-		$contract->set_cycle_count( 3 );
-		$contract->set_last_payment_gmt( '2026-07-15 00:00:00' );

-		$this->assertTrue( $repo->update( $contract ) );
+		$this->assertTrue( $this->sut->update( $contract ) );

-		$reloaded = $repo->find( $id );
+		$reloaded = $this->sut->find( $id );
 		$this->assertInstanceOf( Contract::class, $reloaded );
 		$this->assertSame( ContractStatus::ON_HOLD, $reloaded->get_status() );
 		$this->assertSame( '2026-08-15 00:00:00', $reloaded->get_next_payment_gmt() );
-		$this->assertSame( 3, $reloaded->get_cycle_count() );
-		$this->assertSame( '2026-07-15 00:00:00', $reloaded->get_last_payment_gmt() );
 	}

-	public function test_update_replaces_child_rows(): void {
-		$repo = new ContractRepository();
-		$id   = $repo->insert( $this->make_contract() );
+	/**
+	 * @testdox update leaves unchanged child rows in place (no churn).
+	 */
+	public function test_update_does_not_churn_unchanged_children(): void {
+		global $wpdb;
+
+		$id = $this->sut->insert( $this->make_contract() );

-		$contract = $repo->find( $id );
+		$items_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ITEMS );
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$item_id_before = (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$items_table} WHERE contract_id = %d", $id ) );
+
+		// A cache-only update (status) must not delete-and-reinsert the items row.
+		$contract = $this->sut->find( $id );
 		$this->assertInstanceOf( Contract::class, $contract );
-		$this->assertCount( 1, $contract->get_items() );
+		$contract->set_status( ContractStatus::ON_HOLD );
+		$this->sut->update( $contract );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$item_id_after = (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$items_table} WHERE contract_id = %d", $id ) );
+
+		$this->assertSame( $item_id_before, $item_id_after, 'An unchanged item set must keep its row id (not be rewritten).' );
+	}
+
+	/**
+	 * @testdox update rewrites child rows when they change.
+	 */
+	public function test_update_rewrites_changed_children(): void {
+		$id = $this->sut->insert( $this->make_contract() );

-		// Re-create with a different set of items / meta and update.
 		$mutated = Contract::create(
 			array(
-				'customer_id'     => $contract->get_customer_id(),
-				'currency'        => $contract->get_currency(),
-				'selling_plan_id' => $contract->get_selling_plan_id(),
-				'origin_order_id' => $contract->get_origin_order_id(),
-				'start_gmt'       => $contract->get_start_gmt(),
-				'billing_total'   => $contract->get_billing_total(),
+				'customer_id'     => 42,
+				'currency'        => 'USD',
+				'selling_plan_id' => 7,
+				'origin_order_id' => 1001,
+				'start_gmt'       => '2026-06-15 00:00:00',
 				'items'           => array(
 					array(
 						'item_name'  => 'Tea tin',
@@ -172,9 +337,9 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		);
 		$mutated->set_id( $id );

-		$this->assertTrue( $repo->update( $mutated ) );
+		$this->assertTrue( $this->sut->update( $mutated ) );

-		$reloaded = $repo->find( $id );
+		$reloaded = $this->sut->find( $id );
 		$this->assertInstanceOf( Contract::class, $reloaded );
 		$items = $reloaded->get_items();
 		$this->assertCount( 1, $items );
@@ -182,55 +347,377 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$this->assertSame( 'email', $reloaded->get_meta()['source_channel'] );
 	}

+	/**
+	 * @testdox update throws when the contract has no id.
+	 */
 	public function test_update_throws_without_id(): void {
 		$this->expectException( \RuntimeException::class );
-		( new ContractRepository() )->update( $this->make_contract() );
+		$this->sut->update( $this->make_contract() );
 	}

+	/**
+	 * @testdox update rejects a deleted contract and writes no orphan child rows.
+	 */
 	public function test_update_rejects_deleted_contract_and_writes_no_orphans(): void {
 		global $wpdb;

-		$repo = new ContractRepository();
-		$id   = $repo->insert( $this->make_contract() );
-
-		// Simulate a concurrent delete: the contract row and its children are gone.
-		$this->assertTrue( $repo->delete( $id ) );
+		$id = $this->sut->insert( $this->make_contract() );
+		$this->assertTrue( $this->sut->delete( $id ) );

 		$stale = $this->make_contract();
 		$stale->set_id( $id );

 		try {
-			$repo->update( $stale );
+			$this->sut->update( $stale );
 			$this->fail( 'Expected RuntimeException when updating a contract whose row no longer exists.' );
 		} catch ( \RuntimeException $e ) {
 			$this->assertStringContainsString( 'no longer exists', $e->getMessage() );
 		}

-		// The guard fired before any child write, so no orphan rows were created.
-		$items_table = \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller::get_table_name(
-			\Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller::TABLE_CONTRACT_ITEMS
-		);
+		$items_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ITEMS );
 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
 		$remaining = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$items_table} WHERE contract_id = %d", $id ) );

 		$this->assertSame( '0', $remaining );
 	}

-	public function test_delete_removes_contract_and_children(): void {
+	/**
+	 * @testdox append_cycle inserts a cycle reachable as the chain's current cycle.
+	 */
+	public function test_append_cycle_and_find_current_cycle(): 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() );
+		$this->sut->append_cycle( $cycle );
+
+		$this->assertNotNull( $cycle->get_id() );
+
+		$current = $this->sut->find_current_cycle( $id );
+		$this->assertInstanceOf( Cycle::class, $current );
+		$this->assertSame( $cycle->get_id(), $current->get_id() );
+		$this->assertSame( 1, $current->get_sequence_no() );
+		$this->assertSame( 1, $current->get_count() );
+		$this->assertTrue( $current->get_status()->equals( CycleStatus::pending() ) );
+		$this->assertSame( '2026-07-15 00:00:00', $current->get_starts_at_gmt() );
+		$this->assertSame( '19.99000000', $current->get_expected_total() );
+		$this->assertSame( 'lite', $current->get_extension_slug() );
+
+		// Snapshots decoded back into typed value objects on an in-flight cycle.
+		$this->assertInstanceOf( PlanSnapshot::class, $current->get_plan_snapshot() );
+		$this->assertSame(
+			array(
+				'selling_plan_id' => 7,
+				'cadence'         => 'monthly',
+			),
+			$current->get_plan_snapshot()->to_array()
+		);
+		$this->assertInstanceOf( ItemsSnapshot::class, $current->get_items_snapshot() );
+	}
+
+	/**
+	 * @testdox expected_total round-trips at full DECIMAL(26,8) precision, not just two decimals.
+	 */
+	public function test_expected_total_round_trips_full_decimal_precision(): void {
+		$id = $this->sut->insert( $this->make_contract() );
+
+		// Eight fractional digits: a DECIMAL(26,2) column would truncate this on the
+		// way in, so an exact reload proves the storage scale is (26,8).
+		$cycle = Cycle::create(
+			array(
+				'contract_id'    => $id,
+				'sequence_no'    => 1,
+				'count'          => 1,
+				'starts_at_gmt'  => '2026-07-15 00:00:00',
+				'ends_at_gmt'    => '2026-08-15 00:00:00',
+				'expected_total' => '9.12345678',
+				'currency'       => 'USD',
+				'extension_slug' => 'lite',
+			)
+		);
+		$this->sut->append_cycle( $cycle );
+
+		$reloaded = $this->sut->find_current_cycle( $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.
+	 */
+	public function test_find_current_cycle_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' );
+		$this->sut->append_cycle( $first );
+
+		$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 );
+		$this->assertInstanceOf( Cycle::class, $current );
+		$this->assertSame( 2, $current->get_sequence_no() );
+	}
+
+	/**
+	 * @testdox find_current_cycle returns null for a chain with no cycles.
+	 */
+	public function test_find_current_cycle_is_null_when_empty(): void {
+		$id = $this->sut->insert( $this->make_contract() );
+
+		$this->assertNull( $this->sut->find_current_cycle( $id ) );
+	}
+
+	/**
+	 * @testdox find_cycle_history returns a window of cycles newest first.
+	 */
+	public function test_find_cycle_history_pages_newest_first(): void {
+		$id = $this->sut->insert( $this->make_contract() );
+
+		$prev = null;
+		for ( $n = 1; $n <= 3; $n++ ) {
+			$cycle = $this->make_cycle( $id, $n, $n, sprintf( '2026-%02d-15 00:00:00', 6 + $n ), sprintf( '2026-%02d-15 00:00:00', 7 + $n ) );
+			$this->sut->append_cycle( $cycle, $prev );
+			$prev = $cycle;
+		}
+
+		$page = $this->sut->find_cycle_history( $id, Cycle::KIND_BILLING, 2, 0 );
+		$this->assertCount( 2, $page );
+		$this->assertSame( 3, $page[0]->get_sequence_no() );
+		$this->assertSame( 2, $page[1]->get_sequence_no() );
+
+		$next = $this->sut->find_cycle_history( $id, Cycle::KIND_BILLING, 2, 2 );
+		$this->assertCount( 1, $next );
+		$this->assertSame( 1, $next[0]->get_sequence_no() );
+	}
+
+	/**
+	 * @testdox max_count tracks the highest count appended (the MAX(count) + 1 anchor).
+	 */
+	public function test_max_count_reads_the_per_chain_counter(): void {
+		$id = $this->sut->insert( $this->make_contract() );
+
+		$this->assertNull( $this->sut->max_count( $id ), 'An empty chain has no counting cycle.' );
+
+		$this->sut->append_cycle( $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' ) );
+		$this->assertSame( 1, $this->sut->max_count( $id ) );
+
+		$this->sut->append_cycle( $this->make_cycle( $id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00' ) );
+		$this->assertSame( 2, $this->sut->max_count( $id ) );
+
+		// The next chargeable number is derived as MAX(count) + 1; appending it must
+		// advance the counter, confirming the derivation is wired through the writes.
+		$next = (int) $this->sut->max_count( $id ) + 1;
+		$this->sut->append_cycle( $this->make_cycle( $id, 3, $next, '2026-09-15 00:00:00', '2026-10-15 00:00:00' ) );
+		$this->assertSame( 3, $this->sut->max_count( $id ) );
+	}
+
+	/**
+	 * @testdox find_cycles_by_order_id returns every cycle linked to an order.
+	 */
+	public function test_find_cycles_by_order_id(): void {
+		$first_id  = $this->sut->insert( $this->make_contract() );
+		$second_id = $this->sut->insert( $this->make_contract() );
+
+		// One aggregate order serves a cycle on each of two contracts (not 1:1).
+		$this->sut->append_cycle( $this->make_cycle( $first_id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', null, null, 9090 ) );
+		$this->sut->append_cycle( $this->make_cycle( $second_id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', null, null, 9090 ) );
+		// A different order on the first contract must not match.
+		$this->sut->append_cycle( $this->make_cycle( $first_id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00', null, null, 7070 ) );
+
+		$linked = $this->sut->find_cycles_by_order_id( 9090 );
+		$this->assertCount( 2, $linked );
+
+		$contract_ids = array_map(
+			static function ( Cycle $cycle ) {
+				return $cycle->get_contract_id();
+			},
+			$linked
+		);
+		sort( $contract_ids );
+		$this->assertSame( array( $first_id, $second_id ), $contract_ids );
+	}
+
+	/**
+	 * @testdox update_cycle persists a status transition on a stored cycle.
+	 */
+	public function test_update_cycle_persists_a_status_change(): 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->sut->append_cycle( $cycle );
+
+		$cycle->set_status( CycleStatus::billed() );
+		$this->sut->update_cycle( $cycle );
+
+		$reloaded = $this->sut->find_current_cycle( $id );
+		$this->assertInstanceOf( Cycle::class, $reloaded );
+		$this->assertTrue( $reloaded->get_status()->equals( CycleStatus::billed() ) );
+	}
+
+	/**
+	 * @testdox Consecutive cycles with an unchanged plan/items share one snapshot row each.
+	 */
+	public function test_copy_forward_reuses_unchanged_snapshots(): void {
+		global $wpdb;
+
+		$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', $this->sample_plan_snapshot(), $this->sample_items_snapshot() );
+		$this->sut->append_cycle( $first );
+
+		// The next cycle's plan/items are unchanged: copy-forward should reuse the ids.
+		$second = $this->make_cycle( $id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00', $this->sample_plan_snapshot(), $this->sample_items_snapshot() );
+		$this->sut->append_cycle( $second, $first );
+
+		$this->assertSame( $first->get_plan_snapshot_id(), $second->get_plan_snapshot_id() );
+		$this->assertSame( $first->get_items_snapshot_id(), $second->get_items_snapshot_id() );
+
+		// Exactly two snapshot rows: one plan payload, one items payload.
+		$snapshots = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$row_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$snapshots} WHERE contract_id = %d", $id ) );
+		$this->assertSame( 2, $row_count );
+	}
+
+	/**
+	 * @testdox A changed plan snapshot inserts a new row instead of copy-forwarding.
+	 */
+	public function test_copy_forward_inserts_a_new_row_when_the_plan_changes(): void {
 		global $wpdb;

-		$repo = new ContractRepository();
-		$id   = $repo->insert( $this->make_contract() );
+		$id = $this->sut->insert( $this->make_contract() );

-		$this->assertTrue( $repo->delete( $id ) );
-		$this->assertNull( $repo->find( $id ) );
+		$first = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', $this->sample_plan_snapshot(), $this->sample_items_snapshot() );
+		$this->sut->append_cycle( $first );

-		$items_table = \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller::get_table_name(
-			\Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller::TABLE_CONTRACT_ITEMS
+		// The plan terms changed, so the plan snapshot must not be reused.
+		$changed_plan = PlanSnapshot::from_array(
+			array(
+				'selling_plan_id' => 7,
+				'cadence'         => 'weekly',
+			)
 		);
+		$second       = $this->make_cycle( $id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00', $changed_plan, $this->sample_items_snapshot() );
+		$this->sut->append_cycle( $second, $first );
+
+		$this->assertNotSame( $first->get_plan_snapshot_id(), $second->get_plan_snapshot_id() );
+		// The items were unchanged, so that row is still shared.
+		$this->assertSame( $first->get_items_snapshot_id(), $second->get_items_snapshot_id() );
+
+		$snapshots = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-		$remaining = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$items_table} WHERE contract_id = %d", $id ) );
+		$row_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$snapshots} WHERE contract_id = %d", $id ) );
+		$this->assertSame( 3, $row_count, 'Two plan payloads plus one shared items payload.' );
+	}

-		$this->assertSame( '0', $remaining );
+	/**
+	 * @testdox A duplicate (contract_id, kind, sequence_no) is rejected by the UNIQUE index.
+	 */
+	public function test_duplicate_sequence_no_is_rejected(): void {
+		global $wpdb;
+
+		$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' ) );
+
+		$cycles_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+		$now          = gmdate( 'Y-m-d H:i:s' );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$inserted = $wpdb->insert(
+			$cycles_table,
+			array(
+				'contract_id'      => $id,
+				'kind'             => Cycle::KIND_BILLING,
+				'sequence_no'      => 1,
+				'count'            => 99,
+				'status'           => CycleStatus::PENDING,
+				'starts_at_gmt'    => '2026-09-15 00:00:00',
+				'ends_at_gmt'      => '2026-10-15 00:00:00',
+				'expected_total'   => '19.99',
+				'currency'         => 'USD',
+				'date_created_gmt' => $now,
+				'date_updated_gmt' => $now,
+			)
+		);
+
+		$this->assertFalse( $inserted, 'A duplicate (contract_id, kind, sequence_no) must be rejected by the UNIQUE index.' );
+	}
+
+	/**
+	 * @testdox A duplicate (contract_id, kind, count) is rejected by the UNIQUE index.
+	 */
+	public function test_duplicate_count_is_rejected(): void {
+		global $wpdb;
+
+		$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' ) );
+
+		$cycles_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+		$now          = gmdate( 'Y-m-d H:i:s' );
+
+		// Same count (1) at a different sequence_no must violate UNIQUE(contract_id, kind, count).
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$inserted = $wpdb->insert(
+			$cycles_table,
+			array(
+				'contract_id'      => $id,
+				'kind'             => Cycle::KIND_BILLING,
+				'sequence_no'      => 2,
+				'count'            => 1,
+				'status'           => CycleStatus::PENDING,
+				'starts_at_gmt'    => '2026-09-15 00:00:00',
+				'ends_at_gmt'      => '2026-10-15 00:00:00',
+				'expected_total'   => '19.99',
+				'currency'         => 'USD',
+				'date_created_gmt' => $now,
+				'date_updated_gmt' => $now,
+			)
+		);
+
+		$this->assertFalse( $inserted, 'A duplicate (contract_id, kind, count) must be rejected by the UNIQUE index.' );
+	}
+
+	/**
+	 * @testdox Multiple non-counting cycles (count = null) coexist in one chain.
+	 */
+	public function test_multiple_null_count_cycles_coexist(): void {
+		$id = $this->sut->insert( $this->make_contract() );
+
+		// MySQL treats NULLs as distinct, so two count = null cycles do not collide
+		// under UNIQUE(contract_id, kind, count).
+		$this->sut->append_cycle( $this->make_cycle( $id, 1, null, '2026-07-15 00:00:00', '2026-08-15 00:00:00' ) );
+		$this->sut->append_cycle( $this->make_cycle( $id, 2, null, '2026-08-15 00:00:00', '2026-09-15 00:00:00' ) );
+
+		$history = $this->sut->find_cycle_history( $id );
+		$this->assertCount( 2, $history );
+		$this->assertNull( $history[0]->get_count() );
+		$this->assertNull( $history[1]->get_count() );
+
+		// No counting cycle, so the per-chain counter is null.
+		$this->assertNull( $this->sut->max_count( $id ) );
+	}
+
+	/**
+	 * @testdox delete removes the contract, its children, cycles, and snapshots.
+	 */
+	public function test_delete_removes_contract_children_cycles_and_snapshots(): void {
+		global $wpdb;
+
+		$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->sample_plan_snapshot(), $this->sample_items_snapshot() ) );
+
+		$this->assertTrue( $this->sut->delete( $id ) );
+		$this->assertNull( $this->sut->find( $id ) );
+
+		foreach ( array(
+			SchemaInstaller::TABLE_CONTRACT_ITEMS,
+			SchemaInstaller::TABLE_CYCLES,
+			SchemaInstaller::TABLE_SNAPSHOTS,
+		) as $child ) {
+			$table = SchemaInstaller::get_table_name( $child );
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+			$remaining = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE contract_id = %d", $id ) );
+			$this->assertSame( '0', $remaining, "Rows must be removed from {$table} when the contract is deleted." );
+		}
 	}
 }
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 1c31368d64c..d96649bef37 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
@@ -10,6 +10,7 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Storage;

 use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;

 /**
@@ -17,8 +18,10 @@ use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstall
  */
 class SchemaInstallerTest extends EngineIntegrationTestCase {

+	use ScalarCoercion;
+
 	/**
-	 * The six baseline tables the installer owns.
+	 * The baseline tables the installer owns, including the cycle tables.
 	 *
 	 * @return array<int, array<int, string>>
 	 */
@@ -30,6 +33,8 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
 			array( SchemaInstaller::TABLE_CONTRACT_ITEMS ),
 			array( SchemaInstaller::TABLE_CONTRACT_ADDRESSES ),
 			array( SchemaInstaller::TABLE_CONTRACT_META ),
+			array( SchemaInstaller::TABLE_CYCLES ),
+			array( SchemaInstaller::TABLE_SNAPSHOTS ),
 		);
 	}

@@ -66,6 +71,28 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
 		SchemaInstaller::get_table_name( 'not_a_table' );
 	}

+	/**
+	 * @testdox A chain is the pair (contract_id, kind), so there is no chains table.
+	 */
+	public function test_there_is_no_chains_table(): void {
+		global $wpdb;
+
+		$chains = $wpdb->prefix . 'wc_subscription_chains';
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $chains ) );
+
+		$this->assertNull( $found, 'Did not expect a wc_subscription_chains table.' );
+	}
+
+	/**
+	 * @testdox The chains logical identifier is no longer registered.
+	 */
+	public function test_chains_logical_identifier_is_unknown(): void {
+		$this->expectException( \InvalidArgumentException::class );
+		SchemaInstaller::get_table_name( 'chains' );
+	}
+
 	public function test_plans_table_has_extension_slug_column(): void {
 		global $wpdb;

@@ -87,4 +114,402 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {

 		$this->assertSame( 'extension_slug', $column );
 	}
+
+	public function test_contracts_table_has_the_live_source_of_truth_columns(): void {
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+		$columns = array(
+			'id',
+			'status',
+			'customer_id',
+			'currency',
+			'selling_plan_id',
+			'origin_order_id',
+			'extension_slug',
+			'payment_method',
+			'payment_method_title',
+			'payment_token_id',
+			'start_gmt',
+			'next_payment_gmt',
+			'plan_snapshot_id',
+			'items_snapshot_id',
+			'billing_total',
+			'discount_total',
+			'shipping_total',
+			'tax_total',
+			'last_payment_gmt',
+			'last_attempt_gmt',
+			'trial_end_gmt',
+			'end_gmt',
+			'schedule_source',
+		);
+
+		foreach ( $columns as $column ) {
+			$this->assertTrue( $this->has_column( $table, $column ), "Expected contracts.{$column} column." );
+		}
+	}
+
+	/**
+	 * @testdox origin_order_id is nullable so a manual/admin contract can omit it.
+	 */
+	public function test_contracts_origin_order_id_is_nullable(): void {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+		// 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", 'origin_order_id' ), ARRAY_A );
+
+		$this->assertIsArray( $row, 'Expected a contracts.origin_order_id column.' );
+		$this->assertArrayHasKey( 'Null', $row );
+		$this->assertIsString( $row['Null'] );
+		$this->assertSame( 'YES', $row['Null'], 'Expected contracts.origin_order_id to be NULLable.' );
+	}
+
+	/**
+	 * @testdox The contract row has no generic cycle_count - counters are per-chain and derived.
+	 */
+	public function test_contracts_table_has_no_generic_cycle_count(): void {
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+		$this->assertFalse(
+			$this->has_column( $table, 'cycle_count' ),
+			'Did not expect a generic contracts.cycle_count column; per-chain counts are derived from the cycle rows.'
+		);
+	}
+
+	/**
+	 * @testdox The contract carries the live config: the four totals and the four stamps.
+	 */
+	public function test_contracts_table_carries_live_config_columns(): void {
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+		// These are live values on the contract (the source of truth), not caches of
+		// cycles: the four recurring totals and the four lifecycle stamps.
+		$live_config = array(
+			'billing_total',
+			'discount_total',
+			'shipping_total',
+			'tax_total',
+			'last_payment_gmt',
+			'last_attempt_gmt',
+			'trial_end_gmt',
+			'end_gmt',
+		);
+
+		foreach ( $live_config as $column ) {
+			$this->assertTrue( $this->has_column( $table, $column ), "Expected the live contracts.{$column} column." );
+		}
+	}
+
+	/**
+	 * @testdox The contract carries the latest/live snapshot references.
+	 */
+	public function test_contracts_table_carries_latest_snapshot_refs(): void {
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+		$this->assertTrue( $this->has_column( $table, 'plan_snapshot_id' ), 'Expected contracts.plan_snapshot_id.' );
+		$this->assertTrue( $this->has_column( $table, 'items_snapshot_id' ), 'Expected contracts.items_snapshot_id.' );
+	}
+
+	public function test_contracts_due_index_keys_the_next_bill_cache(): void {
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+		$this->assertContains( 'due', $this->index_names( $table ) );
+		$this->assertSame(
+			array( 'next_payment_gmt', 'status' ),
+			$this->index_columns( $table, 'due' )
+		);
+	}
+
+	public function test_cycles_table_has_expected_columns(): void {
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+		$columns = array(
+			'id',
+			'contract_id',
+			'kind',
+			'sequence_no',
+			'count',
+			'status',
+			'reason',
+			'starts_at_gmt',
+			'ends_at_gmt',
+			'expected_total',
+			'currency',
+			'plan_snapshot_id',
+			'items_snapshot_id',
+			'order_id',
+			'extension_slug',
+		);
+
+		foreach ( $columns as $column ) {
+			$this->assertTrue( $this->has_column( $table, $column ), "Expected cycles.{$column} column." );
+		}
+	}
+
+	/**
+	 * @testdox A chain is (contract_id, kind), so cycles carry no chain_id column.
+	 */
+	public function test_cycles_table_has_no_chain_id_column(): void {
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+		$this->assertFalse(
+			$this->has_column( $table, 'chain_id' ),
+			'Did not expect a cycles.chain_id column; a chain is the pair (contract_id, kind).'
+		);
+	}
+
+	public function test_cycles_table_has_all_expected_indexes(): void {
+		$table   = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+		$indexes = $this->index_names( $table );
+
+		$this->assertContains( 'chain_seq', $indexes, 'Expected the UNIQUE (contract_id, kind, sequence_no) index.' );
+		$this->assertContains( 'chain_count', $indexes, 'Expected the UNIQUE (contract_id, kind, count) index.' );
+		$this->assertContains( 'due', $indexes, 'Expected the due (kind, status, starts_at_gmt) index.' );
+		$this->assertContains( 'order_id', $indexes, 'Expected the (order_id) index.' );
+		$this->assertContains( 'contract_kind', $indexes, 'Expected the (contract_id, kind) index.' );
+	}
+
+	public function test_cycles_chain_sequence_index_columns_are_in_order(): void {
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+		$this->assertSame(
+			array( 'contract_id', 'kind', 'sequence_no' ),
+			$this->index_columns( $table, 'chain_seq' )
+		);
+	}
+
+	public function test_cycles_chain_count_index_columns_are_in_order(): void {
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+		$this->assertSame(
+			array( 'contract_id', 'kind', 'count' ),
+			$this->index_columns( $table, 'chain_count' )
+		);
+	}
+
+	public function test_cycles_due_index_columns_are_in_scan_order(): void {
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+		// The dispatcher scans the due index as (kind, status, starts_at_gmt); the
+		// column order is load-bearing, so assert it exactly, not just existence.
+		$this->assertSame(
+			array( 'kind', 'status', 'starts_at_gmt' ),
+			$this->index_columns( $table, 'due' )
+		);
+	}
+
+	public function test_cycles_contract_kind_index_columns_are_in_order(): void {
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+		$this->assertSame(
+			array( 'contract_id', 'kind' ),
+			$this->index_columns( $table, 'contract_kind' )
+		);
+	}
+
+	public function test_cycles_chain_sequence_index_is_unique(): void {
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+		$this->assertTrue(
+			$this->index_is_unique( $table, 'chain_seq' ),
+			'Expected the (contract_id, kind, sequence_no) index to be UNIQUE.'
+		);
+	}
+
+	/**
+	 * @testdox The chain_count index is UNIQUE so (contract_id, kind, count) is the per-charge idempotency anchor.
+	 */
+	public function test_cycles_chain_count_index_is_unique(): void {
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+		$this->assertTrue(
+			$this->index_is_unique( $table, 'chain_count' ),
+			'Expected the (contract_id, kind, count) index to be UNIQUE.'
+		);
+	}
+
+	/**
+	 * @testdox A nullable count lets multiple non-counting cycles coexist under the UNIQUE chain_count index.
+	 */
+	public function test_cycles_count_column_is_nullable(): void {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+		// 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", 'count' ), ARRAY_A );
+
+		$this->assertIsArray( $row, 'Expected a cycles.count column.' );
+		$this->assertArrayHasKey( 'Null', $row );
+		$this->assertIsString( $row['Null'] );
+		$this->assertSame( 'YES', $row['Null'], 'Expected cycles.count to be NULLable so NULL counts coexist.' );
+	}
+
+	public function test_snapshots_table_has_expected_columns(): void {
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+
+		$columns = array(
+			'id',
+			'contract_id',
+			'snapshot_type',
+			'parent_id',
+			'schema_version',
+			'payload',
+			'date_created_gmt',
+		);
+
+		foreach ( $columns as $column ) {
+			$this->assertTrue( $this->has_column( $table, $column ), "Expected snapshots.{$column} column." );
+		}
+	}
+
+	/**
+	 * @testdox Snapshots are deduped by copy-forward, so there is no content_hash column.
+	 */
+	public function test_snapshots_table_has_no_content_hash_column(): void {
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+
+		$this->assertFalse(
+			$this->has_column( $table, 'content_hash' ),
+			'Did not expect a snapshots.content_hash column; dedup is by copy-forward, not content hashing.'
+		);
+	}
+
+	/**
+	 * @testdox The owner lives on the cycle, not the snapshot, so snapshots have no extension_slug column.
+	 */
+	public function test_snapshots_table_has_no_extension_slug_column(): void {
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+
+		$this->assertFalse(
+			$this->has_column( $table, 'extension_slug' ),
+			'Did not expect a snapshots.extension_slug column; the owner is recorded on the cycle.'
+		);
+	}
+
+	public function test_snapshots_table_has_contract_type_and_parent_indexes(): void {
+		$table   = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+		$indexes = $this->index_names( $table );
+
+		$this->assertContains( 'contract_type', $indexes, 'Expected the (contract_id, snapshot_type) index.' );
+		$this->assertContains( 'parent', $indexes, 'Expected the (parent_id) index.' );
+	}
+
+	public function test_snapshots_contract_type_index_columns_are_in_order(): void {
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+
+		$this->assertSame(
+			array( 'contract_id', 'snapshot_type' ),
+			$this->index_columns( $table, 'contract_type' )
+		);
+	}
+
+	public function test_snapshots_parent_index_columns_are_in_order(): void {
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+
+		$this->assertSame(
+			array( 'parent_id' ),
+			$this->index_columns( $table, 'parent' )
+		);
+	}
+
+	/**
+	 * Whether `$table` has a column named `$column`.
+	 *
+	 * @param string $table  Prefixed table name.
+	 * @param string $column Column name.
+	 */
+	private function has_column( string $table, string $column ): bool {
+		global $wpdb;
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$found = $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", $column ) );
+
+		return $column === $found;
+	}
+
+	/**
+	 * The set of index names defined on `$table`.
+	 *
+	 * @param string $table Prefixed table name.
+	 * @return array<int, string>
+	 */
+	private function index_names( string $table ): array {
+		global $wpdb;
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$rows = $wpdb->get_results( "SHOW INDEX FROM {$table}", ARRAY_A );
+
+		$names = array();
+		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
+			if ( is_array( $row ) ) {
+				$names[] = self::coerce_string( $row['Key_name'] ?? null );
+			}
+		}
+
+		return array_values( array_unique( $names ) );
+	}
+
+	/**
+	 * The ordered column names of a named index on `$table`.
+	 *
+	 * @param string $table     Prefixed table name.
+	 * @param string $key_name  Index name.
+	 * @return array<int, string> Column names in index order (by Seq_in_index).
+	 */
+	private function index_columns( string $table, string $key_name ): array {
+		global $wpdb;
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$rows = $wpdb->get_results( $wpdb->prepare( "SHOW INDEX FROM {$table} WHERE Key_name = %s", $key_name ), ARRAY_A );
+
+		$rows = is_array( $rows ) ? $rows : array();
+		usort(
+			$rows,
+			static function ( $a, $b ): int {
+				$a_seq = is_array( $a ) ? self::coerce_int( $a['Seq_in_index'] ?? null ) : 0;
+				$b_seq = is_array( $b ) ? self::coerce_int( $b['Seq_in_index'] ?? null ) : 0;
+
+				return $a_seq <=> $b_seq;
+			}
+		);
+
+		$columns = array();
+		foreach ( $rows as $row ) {
+			if ( is_array( $row ) ) {
+				$columns[] = self::coerce_string( $row['Column_name'] ?? null );
+			}
+		}
+
+		return $columns;
+	}
+
+	/**
+	 * Whether the named index on `$table` is UNIQUE.
+	 *
+	 * @param string $table    Prefixed table name.
+	 * @param string $key_name Index name.
+	 */
+	private function index_is_unique( string $table, string $key_name ): bool {
+		global $wpdb;
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$rows = $wpdb->get_results( $wpdb->prepare( "SHOW INDEX FROM {$table} WHERE Key_name = %s", $key_name ), ARRAY_A );
+
+		$rows = is_array( $rows ) ? $rows : array();
+		if ( empty( $rows ) ) {
+			return false;
+		}
+
+		foreach ( $rows as $row ) {
+			// Non_unique = 0 marks a UNIQUE index.
+			if ( is_array( $row ) && '0' !== self::coerce_string( $row['Non_unique'] ?? null ) ) {
+				return false;
+			}
+		}
+
+		return true;
+	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SnapshotStoreTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SnapshotStoreTest.php
new file mode 100644
index 00000000000..6b2ce8129d0
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SnapshotStoreTest.php
@@ -0,0 +1,127 @@
+<?php
+/**
+ * Integration tests for SnapshotStore.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Storage;
+
+use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SnapshotStore;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SnapshotStore
+ */
+class SnapshotStoreTest extends EngineIntegrationTestCase {
+
+	use ScalarCoercion;
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var SnapshotStore
+	 */
+	private $sut;
+
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new SnapshotStore();
+	}
+
+	/**
+	 * Count the rows in the snapshots table.
+	 */
+	private function snapshot_row_count(): int {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
+	}
+
+	/**
+	 * Read a snapshot row by id.
+	 *
+	 * @param int $id Snapshot row id.
+	 * @return array<string, mixed>|null
+	 */
+	private function snapshot_row( int $id ): ?array {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), ARRAY_A );
+
+		return null === $row ? null : $row;
+	}
+
+	/**
+	 * @testdox insert() writes a snapshot row and returns its id.
+	 */
+	public function test_insert_writes_a_row_and_returns_its_id(): void {
+		$id = $this->sut->insert( 100, SnapshotStore::TYPE_PLAN, 7, array( 'selling_plan_id' => 7 ), 1 );
+
+		$this->assertGreaterThan( 0, $id );
+		$this->assertSame( 1, $this->snapshot_row_count() );
+	}
+
+	/**
+	 * @testdox insert() persists the typed columns (contract, type, parent, schema version).
+	 */
+	public function test_insert_persists_the_typed_columns(): void {
+		$id = $this->sut->insert( 100, SnapshotStore::TYPE_PLAN, 7, array( 'selling_plan_id' => 7 ), 2 );
+
+		$row = $this->snapshot_row( $id );
+		$this->assertNotNull( $row );
+		$this->assertSame( '100', self::coerce_string( $row['contract_id'] ?? null ) );
+		$this->assertSame( SnapshotStore::TYPE_PLAN, $row['snapshot_type'] );
+		$this->assertSame( '7', self::coerce_string( $row['parent_id'] ?? null ) );
+		$this->assertSame( '2', self::coerce_string( $row['schema_version'] ?? null ) );
+	}
+
+	/**
+	 * @testdox insert() stores an items snapshot with a null parent_id.
+	 */
+	public function test_insert_stores_items_snapshot_with_null_parent(): void {
+		$id = $this->sut->insert( 100, SnapshotStore::TYPE_ITEMS, null, array( array( 'product_id' => 200 ) ), 1 );
+
+		$row = $this->snapshot_row( $id );
+		$this->assertNotNull( $row );
+		$this->assertSame( SnapshotStore::TYPE_ITEMS, $row['snapshot_type'] );
+		$this->assertNull( $row['parent_id'] );
+	}
+
+	/**
+	 * @testdox insert() JSON-encodes the payload and round-trips it.
+	 */
+	public function test_insert_round_trips_the_payload(): void {
+		$payload = array(
+			'selling_plan_id' => 7,
+			'name'            => 'Monthly box',
+		);
+
+		$id  = $this->sut->insert( 100, SnapshotStore::TYPE_PLAN, 7, $payload, 1 );
+		$row = $this->snapshot_row( $id );
+
+		$this->assertNotNull( $row );
+		$this->assertSame( $payload, json_decode( self::coerce_string( $row['payload'] ?? null ), true ) );
+	}
+
+	/**
+	 * @testdox insert() writes a distinct row per call (dedup is the caller's copy-forward).
+	 */
+	public function test_insert_writes_a_distinct_row_per_call(): void {
+		$first  = $this->sut->insert( 100, SnapshotStore::TYPE_PLAN, 7, array( 'selling_plan_id' => 7 ), 1 );
+		$second = $this->sut->insert( 100, SnapshotStore::TYPE_PLAN, 7, array( 'selling_plan_id' => 7 ), 1 );
+
+		$this->assertNotSame( $first, $second, 'Each insert() is a fresh row; reuse is decided by the caller, not the store.' );
+		$this->assertSame( 2, $this->snapshot_row_count() );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
index 095562ef9d2..862482b0f46 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
@@ -1,11 +1,12 @@
 <?php
 /**
- * Unit tests for Contract construction invariants.
+ * Unit tests for the live Contract entity.
  *
- * Confirms that Contract::from_storage() enforces the same boundary invariants
- * as the entity's setters (a corrupted or migrated row cannot smuggle an illegal
- * value past hydration), and that Contract::create() rejects missing or invalid
- * required fields rather than coercing them to a silent default.
+ * Covers the live source-of-truth shape: stable identity plus the live schedule,
+ * the latest/live snapshot references, and the live config values (the four totals
+ * and the four stamps). The contract holds no in-memory cycle graph and no generic
+ * cycle_count (counters are per-chain and derived from the cycle rows), and
+ * origin_order_id may be null.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine
  */
@@ -18,6 +19,7 @@ use DomainException;
 use PHPUnit\Framework\TestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\InstrumentRef;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract
@@ -27,107 +29,335 @@ class ContractTest extends TestCase {
 	/**
 	 * A complete, valid contract row.
 	 *
-	 * @param int $cycle_count Value for the cycle_count column.
 	 * @return array<string, mixed>
 	 */
-	private function valid_row( int $cycle_count ): array {
+	private function valid_row(): array {
 		return array(
-			'id'               => 10,
-			'status'           => ContractStatus::ACTIVE,
-			'customer_id'      => 1,
-			'currency'         => 'USD',
-			'selling_plan_id'  => 2,
-			'origin_order_id'  => 3,
-			'billing_total'    => '10.00',
-			'start_gmt'        => '2026-01-01 00:00:00',
-			'next_payment_gmt' => '2026-02-01 00:00:00',
-			'cycle_count'      => $cycle_count,
+			'id'                => 10,
+			'status'            => ContractStatus::ACTIVE,
+			'customer_id'       => 1,
+			'currency'          => 'USD',
+			'selling_plan_id'   => 2,
+			'origin_order_id'   => 3,
+			'extension_slug'    => 'acme-subs',
+			'start_gmt'         => '2026-01-01 00:00:00',
+			'next_payment_gmt'  => '2026-02-01 00:00:00',
+			'plan_snapshot_id'  => 11,
+			'items_snapshot_id' => 22,
+			'billing_total'     => '20.00',
+			'discount_total'    => '0',
+			'shipping_total'    => '5.00',
+			'tax_total'         => '2.50',
+			'last_payment_gmt'  => '2026-01-01 00:00:00',
+			'last_attempt_gmt'  => '2026-01-01 00:00:00',
+			'trial_end_gmt'     => null,
+			'end_gmt'           => null,
+			'schedule_source'   => Contract::SCHEDULE_SOURCE_PRIMITIVE,
 		);
 	}

-	public function test_from_storage_hydrates_cycle_count(): void {
-		$contract = Contract::from_storage( $this->valid_row( 3 ) );
-
-		$this->assertSame( 3, $contract->get_cycle_count() );
+	private function make_contract(): Contract {
+		return Contract::create(
+			array(
+				'customer_id'      => 1,
+				'currency'         => 'USD',
+				'selling_plan_id'  => 2,
+				'origin_order_id'  => 3,
+				'extension_slug'   => 'acme-subs',
+				'start_gmt'        => '2026-01-01 00:00:00',
+				'next_payment_gmt' => '2026-02-01 00:00:00',
+			)
+		);
 	}

-	public function test_from_storage_rejects_negative_cycle_count(): void {
-		$this->expectException( DomainException::class );
+	/**
+	 * @testdox create() builds an active contract from its identity and live config.
+	 */
+	public function test_create_builds_an_active_contract(): void {
+		$contract = $this->make_contract();

-		Contract::from_storage( $this->valid_row( -1 ) );
+		$this->assertNull( $contract->get_id() );
+		$this->assertSame( ContractStatus::ACTIVE, $contract->get_status() );
+		$this->assertSame( 1, $contract->get_customer_id() );
+		$this->assertSame( 'USD', $contract->get_currency() );
+		$this->assertSame( 2, $contract->get_selling_plan_id() );
+		$this->assertSame( 3, $contract->get_origin_order_id() );
+		$this->assertSame( 'acme-subs', $contract->get_extension_slug() );
+		$this->assertSame( '2026-01-01 00:00:00', $contract->get_start_gmt() );
+		$this->assertSame( '2026-02-01 00:00:00', $contract->get_next_payment_gmt() );
+		$this->assertSame( Contract::SCHEDULE_SOURCE_PRIMITIVE, $contract->get_schedule_source() );
 	}

-	public function test_from_storage_rejects_non_integer_cycle_count(): void {
-		$row                = $this->valid_row( 0 );
-		$row['cycle_count'] = '1.5';
+	/**
+	 * @testdox create() defaults the live config to empty values.
+	 */
+	public function test_create_defaults_live_config(): void {
+		$contract = Contract::create(
+			array(
+				'customer_id'     => 1,
+				'currency'        => 'USD',
+				'selling_plan_id' => 2,
+				'start_gmt'       => '2026-01-01 00:00:00',
+			)
+		);

-		$this->expectException( DomainException::class );
+		$this->assertNull( $contract->get_next_payment_gmt() );
+		$this->assertNull( $contract->get_extension_slug() );
+		$this->assertNull( $contract->get_origin_order_id() );
+		$this->assertNull( $contract->get_plan_snapshot_id() );
+		$this->assertNull( $contract->get_items_snapshot_id() );
+		$this->assertSame( '0.00000000', $contract->get_billing_total() );
+		$this->assertSame( '0.00000000', $contract->get_discount_total() );
+		$this->assertSame( '0.00000000', $contract->get_shipping_total() );
+		$this->assertSame( '0.00000000', $contract->get_tax_total() );
+		$this->assertNull( $contract->get_last_payment_gmt() );
+		$this->assertNull( $contract->get_last_attempt_gmt() );
+		$this->assertNull( $contract->get_trial_end_gmt() );
+		$this->assertNull( $contract->get_end_gmt() );
+	}
+
+	/**
+	 * @testdox create() normalizes the live totals to the storage scale.
+	 */
+	public function test_create_normalizes_live_totals(): void {
+		$contract = Contract::create(
+			array(
+				'customer_id'     => 1,
+				'currency'        => 'USD',
+				'selling_plan_id' => 2,
+				'origin_order_id' => 3,
+				'start_gmt'       => '2026-01-01 00:00:00',
+				'billing_total'   => '20.00',
+				'discount_total'  => '1.5',
+				'shipping_total'  => '5',
+				'tax_total'       => '2.345',
+			)
+		);

-		Contract::from_storage( $row );
+		$this->assertSame( '20.00000000', $contract->get_billing_total() );
+		$this->assertSame( '1.50000000', $contract->get_discount_total() );
+		$this->assertSame( '5.00000000', $contract->get_shipping_total() );
+		$this->assertSame( '2.34500000', $contract->get_tax_total() );
 	}

-	public function test_from_storage_rejects_invalid_schedule_source(): void {
-		$row                    = $this->valid_row( 1 );
-		$row['schedule_source'] = 'bogus';
+	/**
+	 * @testdox create() allows a null origin_order_id (a manual/admin contract).
+	 */
+	public function test_create_allows_a_null_origin_order_id(): void {
+		$contract = Contract::create(
+			array(
+				'customer_id'     => 1,
+				'currency'        => 'USD',
+				'selling_plan_id' => 2,
+				'start_gmt'       => '2026-01-01 00:00:00',
+			)
+		);
+
+		$this->assertNull( $contract->get_origin_order_id() );
+	}

+	/**
+	 * @testdox create() rejects an invalid status.
+	 */
+	public function test_create_rejects_an_invalid_status(): void {
 		$this->expectException( DomainException::class );

-		Contract::from_storage( $row );
+		Contract::create(
+			array(
+				'customer_id'     => 1,
+				'currency'        => 'USD',
+				'selling_plan_id' => 2,
+				'origin_order_id' => 3,
+				'start_gmt'       => '2026-01-01 00:00:00',
+				'status'          => 'nonsense',
+			)
+		);
 	}

 	/**
-	 * A minimal, valid set of Contract::create() arguments.
-	 *
-	 * @return array<string, mixed>
+	 * @testdox create() rejects an invalid schedule source.
 	 */
-	private function valid_create_args(): array {
-		return array(
-			'customer_id'     => 1,
-			'currency'        => 'USD',
-			'selling_plan_id' => 2,
-			'origin_order_id' => 3,
-			'start_gmt'       => '2026-01-01 00:00:00',
+	public function test_create_rejects_an_invalid_schedule_source(): void {
+		$this->expectException( DomainException::class );
+
+		Contract::create(
+			array(
+				'customer_id'     => 1,
+				'currency'        => 'USD',
+				'selling_plan_id' => 2,
+				'origin_order_id' => 3,
+				'start_gmt'       => '2026-01-01 00:00:00',
+				'schedule_source' => 'nonsense',
+			)
 		);
 	}

-	public function test_create_succeeds_with_valid_required_fields(): void {
-		$contract = Contract::create( $this->valid_create_args() );
+	/**
+	 * @testdox The payment instrument round-trips through an InstrumentRef.
+	 */
+	public function test_payment_instrument_round_trips(): void {
+		$contract = $this->make_contract();

+		$contract->set_payment_instrument( new InstrumentRef( 99, 'dummy', 'Dummy Gateway' ) );
+
+		$instrument = $contract->get_payment_instrument();
+		$this->assertSame( 99, $instrument->get_token_id() );
+		$this->assertSame( 'dummy', $instrument->get_gateway() );
+		$this->assertSame( 'Dummy Gateway', $instrument->get_title() );
+	}
+
+	/**
+	 * @testdox The live schedule is replaceable.
+	 */
+	public function test_next_payment_schedule_is_replaceable(): void {
+		$contract = $this->make_contract();
+
+		$contract->set_next_payment_gmt( '2026-03-01 00:00:00' );
+		$this->assertSame( '2026-03-01 00:00:00', $contract->get_next_payment_gmt() );
+
+		$contract->set_next_payment_gmt( null );
+		$this->assertNull( $contract->get_next_payment_gmt() );
+	}
+
+	/**
+	 * @testdox The live snapshot references and stamps are settable over the contract's life.
+	 */
+	public function test_live_snapshot_refs_and_stamps_are_settable(): void {
+		$contract = $this->make_contract();
+
+		$contract->set_plan_snapshot_id( 11 );
+		$contract->set_items_snapshot_id( 22 );
+		$contract->set_billing_total( '49.00' );
+		$contract->set_discount_total( '1.00' );
+		$contract->set_shipping_total( '5.00' );
+		$contract->set_tax_total( '2.50' );
+		$contract->set_last_payment_gmt( '2026-02-01 00:00:00' );
+		$contract->set_last_attempt_gmt( '2026-02-01 00:00:00' );
+		$contract->set_trial_end_gmt( '2026-01-15 00:00:00' );
+		$contract->set_end_gmt( '2027-01-01 00:00:00' );
+
+		$this->assertSame( 11, $contract->get_plan_snapshot_id() );
+		$this->assertSame( 22, $contract->get_items_snapshot_id() );
+		$this->assertSame( '49.00000000', $contract->get_billing_total() );
+		$this->assertSame( '1.00000000', $contract->get_discount_total() );
+		$this->assertSame( '5.00000000', $contract->get_shipping_total() );
+		$this->assertSame( '2.50000000', $contract->get_tax_total() );
+		$this->assertSame( '2026-02-01 00:00:00', $contract->get_last_payment_gmt() );
+		$this->assertSame( '2026-02-01 00:00:00', $contract->get_last_attempt_gmt() );
+		$this->assertSame( '2026-01-15 00:00:00', $contract->get_trial_end_gmt() );
+		$this->assertSame( '2027-01-01 00:00:00', $contract->get_end_gmt() );
+	}
+
+	/**
+	 * @testdox The live snapshot reference can be re-pointed (unlike a cycle's frozen ref).
+	 */
+	public function test_live_snapshot_ref_can_be_repointed(): void {
+		$contract = $this->make_contract();
+
+		// The contract holds the latest/live snapshot ref and re-points it when the
+		// plan changes; this is the intentional contrast with a cycle's write-once ref.
+		$contract->set_plan_snapshot_id( 11 );
+		$contract->set_plan_snapshot_id( 99 );
+
+		$this->assertSame( 99, $contract->get_plan_snapshot_id() );
+	}
+
+	/**
+	 * @testdox from_storage() hydrates the identity, schedule, refs, and live config.
+	 */
+	public function test_from_storage_hydrates_the_live_state(): void {
+		$contract = Contract::from_storage( $this->valid_row() );
+
+		$this->assertSame( 10, $contract->get_id() );
+		$this->assertSame( ContractStatus::ACTIVE, $contract->get_status() );
 		$this->assertSame( 1, $contract->get_customer_id() );
-		$this->assertSame( 'USD', $contract->get_currency() );
 		$this->assertSame( 2, $contract->get_selling_plan_id() );
 		$this->assertSame( 3, $contract->get_origin_order_id() );
+		$this->assertSame( 'acme-subs', $contract->get_extension_slug() );
+		$this->assertSame( '2026-02-01 00:00:00', $contract->get_next_payment_gmt() );
+		$this->assertSame( 11, $contract->get_plan_snapshot_id() );
+		$this->assertSame( 22, $contract->get_items_snapshot_id() );
+		$this->assertSame( '20.00000000', $contract->get_billing_total() );
+		$this->assertSame( '0.00000000', $contract->get_discount_total() );
+		$this->assertSame( '5.00000000', $contract->get_shipping_total() );
+		$this->assertSame( '2.50000000', $contract->get_tax_total() );
+		$this->assertSame( '2026-01-01 00:00:00', $contract->get_last_payment_gmt() );
+		$this->assertSame( '2026-01-01 00:00:00', $contract->get_last_attempt_gmt() );
+		$this->assertNull( $contract->get_trial_end_gmt() );
+		$this->assertNull( $contract->get_end_gmt() );
+		$this->assertSame( Contract::SCHEDULE_SOURCE_PRIMITIVE, $contract->get_schedule_source() );
 	}

-	public function test_create_allows_guest_customer_id_zero(): void {
-		$contract = Contract::create(
-			array_merge( $this->valid_create_args(), array( 'customer_id' => 0 ) )
-		);
+	/**
+	 * @testdox from_storage() hydrates a manual/admin contract with a null origin order.
+	 */
+	public function test_from_storage_hydrates_a_null_origin_order(): void {
+		$row                    = $this->valid_row();
+		$row['origin_order_id'] = null;
+
+		$contract = Contract::from_storage( $row );

-		$this->assertSame( 0, $contract->get_customer_id() );
+		$this->assertNull( $contract->get_origin_order_id() );
 	}

 	/**
-	 * @dataProvider provide_invalid_create_args
-	 * @param array<string, mixed> $overrides Field overrides that should make create() reject the args.
+	 * @testdox from_storage() hydrates items, addresses, and meta children.
 	 */
-	public function test_create_rejects_invalid_required_fields( array $overrides ): void {
-		$this->expectException( DomainException::class );
+	public function test_from_storage_hydrates_children(): void {
+		$items     = array( array( 'product_id' => 42 ) );
+		$addresses = array( 'billing' => array( 'first_name' => 'Ada' ) );
+		$meta      = array( 'flag' => 'on' );
+
+		$contract = Contract::from_storage( $this->valid_row(), $items, $addresses, $meta );

-		Contract::create( array_merge( $this->valid_create_args(), $overrides ) );
+		$this->assertSame( $items, $contract->get_items() );
+		$this->assertSame( $addresses, $contract->get_addresses() );
+		$this->assertSame( $meta, $contract->get_meta() );
 	}

 	/**
-	 * @return array<string, array{0: array<string, mixed>}>
+	 * @testdox to_storage() carries the full live column set.
 	 */
-	public function provide_invalid_create_args(): array {
-		return array(
-			'missing customer_id'         => array( array( 'customer_id' => null ) ),
-			'zero selling_plan_id'        => array( array( 'selling_plan_id' => 0 ) ),
-			'non-numeric origin_order_id' => array( array( 'origin_order_id' => 'x' ) ),
-			'missing currency'            => array( array( 'currency' => null ) ),
-			'empty currency'              => array( array( 'currency' => '' ) ),
-			'missing start_gmt'           => array( array( 'start_gmt' => null ) ),
+	public function test_to_storage_carries_the_live_column_set(): void {
+		$row = $this->make_contract()->to_storage();
+
+		// Assert the key SET, not the insertion order: the row's column order is
+		// not load-bearing, so canonicalize to avoid a brittle ordering coupling.
+		$this->assertEqualsCanonicalizing(
+			array(
+				'status',
+				'customer_id',
+				'currency',
+				'selling_plan_id',
+				'origin_order_id',
+				'extension_slug',
+				'payment_method',
+				'payment_method_title',
+				'payment_token_id',
+				'start_gmt',
+				'next_payment_gmt',
+				'plan_snapshot_id',
+				'items_snapshot_id',
+				'billing_total',
+				'discount_total',
+				'shipping_total',
+				'tax_total',
+				'last_payment_gmt',
+				'last_attempt_gmt',
+				'trial_end_gmt',
+				'end_gmt',
+				'schedule_source',
+			),
+			array_keys( $row )
 		);
 	}
+
+	/**
+	 * @testdox to_storage() does not carry a generic cycle_count column.
+	 */
+	public function test_to_storage_has_no_generic_cycle_count(): void {
+		$row = $this->make_contract()->to_storage();
+
+		$this->assertArrayNotHasKey( 'cycle_count', $row, 'to_storage() must not carry a generic cycle_count; counters are per-chain and derived.' );
+	}
 }
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
new file mode 100644
index 00000000000..b4a8ac085a2
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleStatusTest.php
@@ -0,0 +1,146 @@
+<?php
+/**
+ * Unit tests for the CycleStatus state machine.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
+
+use DomainException;
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus
+ */
+class CycleStatusTest extends TestCase {
+
+	public function test_all_returns_exactly_the_known_statuses(): void {
+		$this->assertSame(
+			array(
+				CycleStatus::PENDING,
+				CycleStatus::BILLED,
+				CycleStatus::FAILED,
+				CycleStatus::CANCELLED,
+			),
+			CycleStatus::all()
+		);
+	}
+
+	public function test_known_statuses_are_valid(): void {
+		$this->assertTrue( CycleStatus::is_valid( CycleStatus::PENDING ) );
+		$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 {
+		$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.
+		$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 ) );
+	}
+
+	public function test_billed_and_cancelled_are_terminal(): void {
+		$this->assertTrue( CycleStatus::is_terminal( CycleStatus::BILLED ) );
+		$this->assertTrue( CycleStatus::is_terminal( CycleStatus::CANCELLED ) );
+
+		foreach ( CycleStatus::all() as $target ) {
+			$this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::BILLED, $target ) );
+			$this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::CANCELLED, $target ) );
+		}
+	}
+
+	public function test_pending_and_failed_are_not_terminal(): void {
+		$this->assertFalse( CycleStatus::is_terminal( CycleStatus::PENDING ) );
+		$this->assertFalse( CycleStatus::is_terminal( CycleStatus::FAILED ) );
+	}
+
+	public function test_unknown_statuses_never_transition(): void {
+		$this->assertFalse( CycleStatus::is_transition_allowed( 'nonsense', CycleStatus::BILLED ) );
+		$this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::PENDING, 'nonsense' ) );
+	}
+
+	public function test_same_status_is_not_an_allowed_transition(): void {
+		$this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::PENDING ) );
+	}
+
+	public function test_can_transition_aliases_is_transition_allowed(): void {
+		$this->assertSame(
+			CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::BILLED ),
+			CycleStatus::can_transition( CycleStatus::PENDING, CycleStatus::BILLED )
+		);
+		$this->assertSame(
+			CycleStatus::is_transition_allowed( CycleStatus::BILLED, CycleStatus::PENDING ),
+			CycleStatus::can_transition( CycleStatus::BILLED, CycleStatus::PENDING )
+		);
+	}
+
+	public function test_assert_transition_allowed_passes_for_a_legal_move(): void {
+		CycleStatus::assert_transition_allowed( CycleStatus::PENDING, CycleStatus::BILLED );
+
+		// No exception thrown.
+		$this->addToAssertionCount( 1 );
+	}
+
+	public function test_assert_transition_allowed_throws_for_an_illegal_move(): void {
+		$this->expectException( DomainException::class );
+
+		CycleStatus::assert_transition_allowed( CycleStatus::FAILED, CycleStatus::BILLED );
+	}
+
+	public function test_assert_transition_allowed_throws_out_of_a_terminal_status(): void {
+		$this->expectException( DomainException::class );
+
+		CycleStatus::assert_transition_allowed( CycleStatus::BILLED, CycleStatus::PENDING );
+	}
+
+	public function test_named_factories_carry_their_status_value(): void {
+		$this->assertSame( CycleStatus::PENDING, CycleStatus::pending()->get_value() );
+		$this->assertSame( CycleStatus::BILLED, CycleStatus::billed()->get_value() );
+		$this->assertSame( CycleStatus::FAILED, CycleStatus::failed()->get_value() );
+		$this->assertSame( CycleStatus::CANCELLED, CycleStatus::cancelled()->get_value() );
+	}
+
+	public function test_from_builds_a_known_status(): void {
+		$this->assertSame( CycleStatus::PENDING, CycleStatus::from( CycleStatus::PENDING )->get_value() );
+	}
+
+	public function test_from_rejects_an_unknown_status(): void {
+		$this->expectException( DomainException::class );
+
+		CycleStatus::from( 'nonsense' );
+	}
+
+	public function test_equals_compares_by_value(): void {
+		$this->assertTrue( CycleStatus::pending()->equals( CycleStatus::pending() ) );
+		$this->assertFalse( CycleStatus::pending()->equals( CycleStatus::billed() ) );
+	}
+
+	public function test_can_transition_to_mirrors_the_static_table(): void {
+		$this->assertTrue( CycleStatus::pending()->can_transition_to( CycleStatus::billed() ) );
+		$this->assertFalse( CycleStatus::failed()->can_transition_to( CycleStatus::billed() ) );
+	}
+
+	public function test_transition_to_returns_the_target_for_a_legal_move(): void {
+		$next = CycleStatus::pending()->transition_to( CycleStatus::billed() );
+
+		$this->assertTrue( $next->equals( CycleStatus::billed() ) );
+	}
+
+	public function test_transition_to_throws_for_an_illegal_move(): void {
+		$this->expectException( DomainException::class );
+
+		CycleStatus::failed()->transition_to( CycleStatus::billed() );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
new file mode 100644
index 00000000000..e695a67e070
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
@@ -0,0 +1,445 @@
+<?php
+/**
+ * Unit tests for the Cycle entity.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
+
+use DomainException;
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle
+ */
+class CycleTest extends TestCase {
+
+	/**
+	 * Build a pending billing cycle with sensible defaults, overridable per test.
+	 *
+	 * @param array<string, mixed> $overrides Field overrides.
+	 */
+	private function make_pending( array $overrides = array() ): Cycle {
+		return Cycle::create(
+			array_merge(
+				array(
+					'contract_id'    => 7,
+					'sequence_no'    => 1,
+					'count'          => 1,
+					'starts_at_gmt'  => '2026-02-01 00:00:00',
+					'ends_at_gmt'    => '2026-03-01 00:00:00',
+					'expected_total' => '20.00',
+					'currency'       => 'USD',
+				),
+				$overrides
+			)
+		);
+	}
+
+	public function test_create_builds_a_pending_billing_cycle(): void {
+		$cycle = $this->make_pending();
+
+		$this->assertNull( $cycle->get_id() );
+		$this->assertSame( 7, $cycle->get_contract_id() );
+		$this->assertSame( 1, $cycle->get_sequence_no() );
+		$this->assertSame( 1, $cycle->get_count() );
+		$this->assertSame( Cycle::KIND_BILLING, $cycle->get_kind() );
+		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::pending() ) );
+		$this->assertNull( $cycle->get_reason() );
+		$this->assertSame( '2026-02-01 00:00:00', $cycle->get_starts_at_gmt() );
+		$this->assertSame( '2026-03-01 00:00:00', $cycle->get_ends_at_gmt() );
+		$this->assertSame( '20.00000000', $cycle->get_expected_total() );
+		$this->assertSame( 'USD', $cycle->get_currency() );
+		$this->assertNull( $cycle->get_order_id() );
+		$this->assertNull( $cycle->get_extension_slug() );
+		$this->assertNull( $cycle->get_plan_snapshot_id() );
+		$this->assertNull( $cycle->get_items_snapshot_id() );
+	}
+
+	public function test_expected_total_preserves_a_large_realistic_amount(): void {
+		// Money normalizes through a float (the same path as core's wc_format_decimal),
+		// which is exact within double precision - so a realistic large amount with full
+		// 8-decimal scale round-trips unchanged.
+		$cycle = $this->make_pending( array( 'expected_total' => '12345.12345678' ) );
+
+		$this->assertSame( '12345.12345678', $cycle->get_expected_total() );
+	}
+
+	public function test_create_can_build_a_billed_cycle_directly(): void {
+		// The checkout signup cycle is created directly billed (the origin order is paid).
+		$cycle = $this->make_pending( array( 'status' => CycleStatus::billed() ) );
+
+		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+	}
+
+	public function test_create_requires_a_contract_id(): void {
+		$this->expectException( DomainException::class );
+
+		Cycle::create(
+			array(
+				'sequence_no'    => 1,
+				'starts_at_gmt'  => '2026-02-01 00:00:00',
+				'ends_at_gmt'    => '2026-03-01 00:00:00',
+				'expected_total' => '20.00',
+				'currency'       => 'USD',
+			)
+		);
+	}
+
+	public function test_create_accepts_the_known_billing_kind(): void {
+		$cycle = $this->make_pending( array( 'kind' => Cycle::KIND_BILLING ) );
+
+		$this->assertSame( Cycle::KIND_BILLING, $cycle->get_kind() );
+	}
+
+	public function test_create_accepts_an_unknown_but_well_formed_kind(): void {
+		// kind is known-but-extensible: a third party may introduce its own kind,
+		// so an unrecognized non-empty kind is accepted rather than rejected.
+		$cycle = $this->make_pending( array( 'kind' => 'shipping' ) );
+
+		$this->assertSame( 'shipping', $cycle->get_kind() );
+	}
+
+	public function test_create_rejects_an_empty_kind(): void {
+		$this->expectException( DomainException::class );
+
+		$this->make_pending( array( 'kind' => '' ) );
+	}
+
+	public function test_create_rejects_a_non_positive_sequence_no(): void {
+		$this->expectException( DomainException::class );
+
+		$this->make_pending( array( 'sequence_no' => 0 ) );
+	}
+
+	public function test_create_rejects_a_non_positive_count(): void {
+		$this->expectException( DomainException::class );
+
+		$this->make_pending( array( 'count' => 0 ) );
+	}
+
+	public function test_create_allows_a_null_count_for_a_non_counting_cycle(): void {
+		// A non-counting cycle (for example a future trial period) carries no
+		// chargeable number, so count is null and no charge idempotency anchor applies.
+		$cycle = $this->make_pending( array( 'count' => null ) );
+
+		$this->assertNull( $cycle->get_count() );
+	}
+
+	public function test_create_rejects_an_invalid_status(): void {
+		$this->expectException( DomainException::class );
+
+		$this->make_pending( array( 'status' => 'nonsense' ) );
+	}
+
+	public function test_create_carries_order_id_and_owner(): void {
+		$cycle = $this->make_pending(
+			array(
+				'order_id'       => 123,
+				'extension_slug' => 'acme-subs',
+			)
+		);
+
+		$this->assertSame( 123, $cycle->get_order_id() );
+		$this->assertSame( 'acme-subs', $cycle->get_extension_slug() );
+	}
+
+	public function test_holds_snapshot_vos_when_provided(): void {
+		$plan  = PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ) );
+		$items = ItemsSnapshot::from_items( array( array( 'product_id' => 42 ) ) );
+
+		$cycle = $this->make_pending(
+			array(
+				'plan_snapshot'  => $plan,
+				'items_snapshot' => $items,
+			)
+		);
+
+		$this->assertSame( $plan, $cycle->get_plan_snapshot() );
+		$this->assertSame( $items, $cycle->get_items_snapshot() );
+	}
+
+	public function test_status_changes_go_through_cycle_status(): void {
+		$cycle = $this->make_pending();
+
+		$cycle->set_status( CycleStatus::billed() );
+		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+	}
+
+	public function test_status_change_rejects_an_illegal_transition(): void {
+		$cycle = $this->make_pending( array( 'status' => CycleStatus::billed() ) );
+
+		// billed is terminal, so it cannot move to failed.
+		$this->expectException( DomainException::class );
+		$cycle->set_status( CycleStatus::failed() );
+	}
+
+	public function test_setting_the_same_status_is_a_no_op(): void {
+		$cycle = $this->make_pending();
+
+		$cycle->set_status( CycleStatus::pending() );
+
+		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::pending() ) );
+	}
+
+	public function test_period_boundaries_are_frozen_at_construction(): void {
+		// The Cycle is an immutable record: there is no setter for the period
+		// boundaries, they are fixed by create()/from_storage(). Asserting the
+		// public surface via reflection means re-introducing a setter trips this.
+		$reflection = new \ReflectionClass( Cycle::class );
+
+		$this->assertFalse( $reflection->hasMethod( 'set_starts_at_gmt' ) );
+		$this->assertFalse( $reflection->hasMethod( 'set_ends_at_gmt' ) );
+	}
+
+	public function test_plan_snapshot_id_is_stamped_write_once(): void {
+		$cycle = $this->make_pending();
+
+		$cycle->set_plan_snapshot_id( 11 );
+		$cycle->set_items_snapshot_id( 22 );
+
+		$this->assertSame( 11, $cycle->get_plan_snapshot_id() );
+		$this->assertSame( 22, $cycle->get_items_snapshot_id() );
+	}
+
+	public function test_plan_snapshot_id_cannot_be_re_pointed(): void {
+		$cycle = $this->make_pending();
+		$cycle->set_plan_snapshot_id( 11 );
+
+		$this->expectException( DomainException::class );
+		$cycle->set_plan_snapshot_id( 99 );
+	}
+
+	public function test_items_snapshot_id_cannot_be_re_pointed(): void {
+		$cycle = $this->make_pending();
+		$cycle->set_items_snapshot_id( 22 );
+
+		$this->expectException( DomainException::class );
+		$cycle->set_items_snapshot_id( 99 );
+	}
+
+	public function test_plan_snapshot_vo_is_attached_write_once(): void {
+		$cycle = $this->make_pending();
+
+		$plan = PlanSnapshot::from_array( array( 'selling_plan_id' => 9 ) );
+		$cycle->set_plan_snapshot( $plan );
+
+		$this->assertSame( $plan, $cycle->get_plan_snapshot() );
+	}
+
+	public function test_plan_snapshot_vo_cannot_be_replaced(): void {
+		$cycle = $this->make_pending( array( 'plan_snapshot' => PlanSnapshot::from_array( array( 'selling_plan_id' => 9 ) ) ) );
+
+		$this->expectException( DomainException::class );
+		$cycle->set_plan_snapshot( PlanSnapshot::from_array( array( 'selling_plan_id' => 10 ) ) );
+	}
+
+	public function test_items_snapshot_vo_cannot_be_replaced(): void {
+		$cycle = $this->make_pending( array( 'items_snapshot' => ItemsSnapshot::from_items( array() ) ) );
+
+		$this->expectException( DomainException::class );
+		$cycle->set_items_snapshot( ItemsSnapshot::from_items( array( array( 'product_id' => 1 ) ) ) );
+	}
+
+	public function test_reason_can_be_annotated_on_a_pending_cycle(): void {
+		$cycle = $this->make_pending();
+
+		$cycle->set_reason( 'flagged for review' );
+
+		$this->assertSame( 'flagged for review', $cycle->get_reason() );
+	}
+
+	public function test_reason_can_be_annotated_when_cancelling_a_pending_cycle(): void {
+		$cycle = $this->make_pending();
+
+		$cycle->set_status( CycleStatus::cancelled() );
+		$cycle->set_reason( 'customer requested cancellation' );
+
+		$this->assertSame( 'customer requested cancellation', $cycle->get_reason() );
+	}
+
+	public function test_reason_can_be_annotated_on_a_billed_cycle(): void {
+		// `reason` is one of the few mutable fields: any cycle may carry one.
+		$cycle = $this->make_pending( array( 'status' => CycleStatus::billed() ) );
+
+		$cycle->set_reason( 'settled after retry' );
+
+		$this->assertSame( 'settled after retry', $cycle->get_reason() );
+	}
+
+	public function test_reason_can_be_annotated_on_a_failed_cycle(): void {
+		$cycle = $this->make_pending();
+		$cycle->set_status( CycleStatus::failed() );
+
+		$cycle->set_reason( 'gateway declined the charge' );
+
+		$this->assertSame( 'gateway declined the charge', $cycle->get_reason() );
+	}
+
+	public function test_from_storage_hydrates_a_persisted_cycle(): void {
+		$cycle = Cycle::from_storage(
+			array(
+				'id'                => 5,
+				'contract_id'       => 7,
+				'sequence_no'       => 2,
+				'count'             => 2,
+				'kind'              => Cycle::KIND_BILLING,
+				'status'            => 'billed',
+				'reason'            => null,
+				'starts_at_gmt'     => '2026-03-01 00:00:00',
+				'ends_at_gmt'       => '2026-04-01 00:00:00',
+				'expected_total'    => '20.00',
+				'currency'          => 'USD',
+				'plan_snapshot_id'  => 11,
+				'items_snapshot_id' => 22,
+				'order_id'          => 123,
+				'extension_slug'    => 'acme-subs',
+			)
+		);
+
+		$this->assertSame( 5, $cycle->get_id() );
+		$this->assertSame( 7, $cycle->get_contract_id() );
+		$this->assertSame( 2, $cycle->get_sequence_no() );
+		$this->assertSame( 2, $cycle->get_count() );
+		$this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+		$this->assertSame( 11, $cycle->get_plan_snapshot_id() );
+		$this->assertSame( 22, $cycle->get_items_snapshot_id() );
+		$this->assertSame( 123, $cycle->get_order_id() );
+		$this->assertSame( 'acme-subs', $cycle->get_extension_slug() );
+	}
+
+	public function test_from_storage_cycle_plan_snapshot_ref_is_write_once(): void {
+		// A cycle hydrated from storage already carries non-null snapshot refs through
+		// the constructor; the write-once guard must still reject re-pointing them.
+		$cycle = Cycle::from_storage(
+			array(
+				'id'                => 5,
+				'contract_id'       => 7,
+				'sequence_no'       => 2,
+				'count'             => 2,
+				'kind'              => Cycle::KIND_BILLING,
+				'status'            => 'billed',
+				'reason'            => null,
+				'starts_at_gmt'     => '2026-03-01 00:00:00',
+				'ends_at_gmt'       => '2026-04-01 00:00:00',
+				'expected_total'    => '20.00',
+				'currency'          => 'USD',
+				'plan_snapshot_id'  => 11,
+				'items_snapshot_id' => 22,
+				'order_id'          => 123,
+				'extension_slug'    => 'acme-subs',
+			)
+		);
+
+		$this->expectException( DomainException::class );
+		$cycle->set_plan_snapshot_id( 99 );
+	}
+
+	public function test_from_storage_cycle_items_snapshot_ref_is_write_once(): void {
+		$cycle = Cycle::from_storage(
+			array(
+				'id'                => 5,
+				'contract_id'       => 7,
+				'sequence_no'       => 2,
+				'count'             => 2,
+				'kind'              => Cycle::KIND_BILLING,
+				'status'            => 'billed',
+				'reason'            => null,
+				'starts_at_gmt'     => '2026-03-01 00:00:00',
+				'ends_at_gmt'       => '2026-04-01 00:00:00',
+				'expected_total'    => '20.00',
+				'currency'          => 'USD',
+				'plan_snapshot_id'  => 11,
+				'items_snapshot_id' => 22,
+				'order_id'          => 123,
+				'extension_slug'    => 'acme-subs',
+			)
+		);
+
+		$this->expectException( DomainException::class );
+		$cycle->set_items_snapshot_id( 99 );
+	}
+
+	public function test_from_storage_hydrates_a_non_counting_cycle(): void {
+		$cycle = Cycle::from_storage(
+			array(
+				'id'             => 6,
+				'contract_id'    => 7,
+				'sequence_no'    => 1,
+				'count'          => null,
+				'kind'           => Cycle::KIND_BILLING,
+				'status'         => 'pending',
+				'starts_at_gmt'  => '2026-02-01 00:00:00',
+				'ends_at_gmt'    => '2026-03-01 00:00:00',
+				'expected_total' => '0',
+				'currency'       => 'USD',
+			)
+		);
+
+		$this->assertNull( $cycle->get_count() );
+	}
+
+	public function test_to_storage_excludes_id_and_serializes_the_row(): void {
+		$cycle = $this->make_pending(
+			array(
+				'order_id'         => 123,
+				'extension_slug'   => 'acme-subs',
+				'plan_snapshot_id' => 11,
+			)
+		);
+		$cycle->set_items_snapshot_id( 22 );
+
+		$row = $cycle->to_storage();
+
+		$this->assertArrayNotHasKey( 'id', $row );
+		$this->assertArrayNotHasKey( 'chain_id', $row );
+		$this->assertSame( 7, $row['contract_id'] );
+		$this->assertSame( 1, $row['sequence_no'] );
+		$this->assertSame( 1, $row['count'] );
+		$this->assertSame( Cycle::KIND_BILLING, $row['kind'] );
+		$this->assertSame( 'pending', $row['status'] );
+		$this->assertSame( '20.00000000', $row['expected_total'] );
+		$this->assertSame( 11, $row['plan_snapshot_id'] );
+		$this->assertSame( 22, $row['items_snapshot_id'] );
+		$this->assertSame( 123, $row['order_id'] );
+		$this->assertSame( 'acme-subs', $row['extension_slug'] );
+	}
+
+	public function test_to_storage_serializes_the_status_as_its_string_value(): void {
+		$row = $this->make_pending()->to_storage();
+
+		// The status column stores the plain string value; the typed CycleStatus is
+		// an in-memory concern only.
+		$this->assertSame( 'pending', $row['status'] );
+	}
+
+	public function test_sequence_no_can_be_reassigned_after_construction(): void {
+		$cycle = $this->make_pending( array( 'sequence_no' => 1 ) );
+
+		$cycle->set_sequence_no( 4 );
+
+		$this->assertSame( 4, $cycle->get_sequence_no() );
+	}
+
+	public function test_set_sequence_no_rejects_a_non_positive_value(): void {
+		$cycle = $this->make_pending();
+
+		$this->expectException( DomainException::class );
+		$cycle->set_sequence_no( 0 );
+	}
+
+	public function test_id_can_be_stamped_after_persistence(): void {
+		$cycle = $this->make_pending();
+
+		$cycle->set_id( 5 );
+
+		$this->assertSame( 5, $cycle->get_id() );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/ItemsSnapshotTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/ItemsSnapshotTest.php
new file mode 100644
index 00000000000..fc62bb0a0cf
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/ItemsSnapshotTest.php
@@ -0,0 +1,92 @@
+<?php
+/**
+ * Unit tests for ItemsSnapshot.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\ValueObject;
+
+use DomainException;
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot
+ */
+class ItemsSnapshotTest extends TestCase {
+
+	/**
+	 * A representative two-item set.
+	 *
+	 * @return array<int, array<string, mixed>>
+	 */
+	private function sample_items(): array {
+		return array(
+			array(
+				'product_id' => 42,
+				'quantity'   => 2,
+				'total'      => '20.00',
+			),
+			array(
+				'product_id' => 7,
+				'quantity'   => 1,
+				'total'      => '5.00',
+			),
+		);
+	}
+
+	public function test_schema_version_defaults_to_one(): void {
+		$snapshot = ItemsSnapshot::from_items( $this->sample_items() );
+
+		$this->assertSame( 1, $snapshot->get_schema_version() );
+	}
+
+	public function test_schema_version_is_carried(): void {
+		$snapshot = ItemsSnapshot::from_items( $this->sample_items(), 2 );
+
+		$this->assertSame( 2, $snapshot->get_schema_version() );
+	}
+
+	public function test_round_trips_through_items(): void {
+		$items    = $this->sample_items();
+		$snapshot = ItemsSnapshot::from_items( $items );
+
+		$this->assertSame( $items, $snapshot->get_items() );
+	}
+
+	public function test_to_payload_preserves_the_item_list_order(): void {
+		$snapshot = ItemsSnapshot::from_items( $this->sample_items() );
+
+		$payload = $snapshot->to_payload();
+
+		// The item list order is meaningful and preserved; no per-item key
+		// reordering happens (dedup is copy-forward, not a canonical hash).
+		$this->assertSame( 42, $payload[0]['product_id'] );
+		$this->assertSame( 7, $payload[1]['product_id'] );
+	}
+
+	public function test_from_payload_reconstructs_an_equal_snapshot(): void {
+		$snapshot = ItemsSnapshot::from_items( $this->sample_items(), 2 );
+
+		$restored = ItemsSnapshot::from_payload( $snapshot->to_payload(), $snapshot->get_schema_version() );
+
+		$this->assertSame( $snapshot->to_payload(), $restored->to_payload() );
+		$this->assertSame( 2, $restored->get_schema_version() );
+	}
+
+	public function test_empty_item_set_is_allowed(): void {
+		$snapshot = ItemsSnapshot::from_items( array() );
+
+		$this->assertSame( array(), $snapshot->get_items() );
+		$this->assertSame( array(), $snapshot->to_payload() );
+	}
+
+	public function test_negative_schema_version_throws(): void {
+		$this->expectException( DomainException::class );
+
+		ItemsSnapshot::from_items( $this->sample_items(), -1 );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
new file mode 100644
index 00000000000..d41799d4ee2
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
@@ -0,0 +1,97 @@
+<?php
+/**
+ * Unit tests for PlanSnapshot.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\ValueObject;
+
+use DomainException;
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot
+ */
+class PlanSnapshotTest extends TestCase {
+
+	public function test_schema_version_defaults_to_one(): void {
+		$snapshot = PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ) );
+
+		$this->assertSame( 1, $snapshot->get_schema_version() );
+	}
+
+	public function test_schema_version_is_carried(): void {
+		$snapshot = PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ), 3 );
+
+		$this->assertSame( 3, $snapshot->get_schema_version() );
+	}
+
+	/**
+	 * @testdox get_selling_plan_id returns the plan id when present.
+	 */
+	public function test_get_selling_plan_id_returns_the_id_when_present(): void {
+		$snapshot = PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ) );
+
+		$this->assertSame( 7, $snapshot->get_selling_plan_id() );
+	}
+
+	/**
+	 * @testdox get_selling_plan_id returns null when the key is absent.
+	 */
+	public function test_get_selling_plan_id_is_null_when_absent(): void {
+		$snapshot = PlanSnapshot::from_array( array( 'name' => 'Monthly box' ) );
+
+		$this->assertNull( $snapshot->get_selling_plan_id() );
+	}
+
+	public function test_round_trips_through_array(): void {
+		$data     = array(
+			'selling_plan_id' => 7,
+			'name'            => 'Monthly box',
+			'billing_policy'  => array(
+				'period'   => 'month',
+				'interval' => 1,
+			),
+		);
+		$snapshot = PlanSnapshot::from_array( $data );
+
+		$this->assertSame( $data, $snapshot->to_array() );
+	}
+
+	public function test_to_payload_returns_the_plan_terms_in_their_original_order(): void {
+		$data     = array(
+			'name'            => 'Monthly box',
+			'selling_plan_id' => 7,
+		);
+		$snapshot = PlanSnapshot::from_array( $data );
+
+		// The payload is the data as given; no key reordering happens here. Equal
+		// consecutive plans dedupe by copy-forward in storage, not by a canonical hash.
+		$this->assertSame( $data, $snapshot->to_payload() );
+	}
+
+	public function test_from_payload_reconstructs_an_equal_snapshot(): void {
+		$snapshot = PlanSnapshot::from_array(
+			array(
+				'selling_plan_id' => 7,
+				'name'            => 'Monthly box',
+			),
+			2
+		);
+
+		$restored = PlanSnapshot::from_payload( $snapshot->to_payload(), $snapshot->get_schema_version() );
+
+		$this->assertSame( $snapshot->to_payload(), $restored->to_payload() );
+		$this->assertSame( 2, $restored->get_schema_version() );
+	}
+
+	public function test_negative_schema_version_throws(): void {
+		$this->expectException( DomainException::class );
+
+		PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ), 0 );
+	}
+}