Commit 1b7abd534ef for woocommerce

commit 1b7abd534effb90484ec38e3a949352fe751133e
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date:   Wed Oct 7 19:55:21 2026 +0200

    Subscriptions engine: explicit contract creation and view reads through the facade (#69447)

    Create contracts from explicit fields and read them as views through Api\Contracts

    - Add Api\Contracts as the single contract facade: create(), update() (writes only the passed columns; null when the contract is gone), add_cycle() and WordPress-style multi-value meta, plus all contract reads (get, find_by_origin_order, list, count, count_by_status, item_counts, list_for_customer with a status filter, get_for_customer, get_cycles)
    - Return immutable ContractView and CycleView objects from every facade read and write; item and address children use the write shape
    - Accept WooCommerce-style argument arrays; unknown keys raise _doing_it_wrong() and are ignored, shape checks live in an internal ArgumentValidator and invariants on the entities
    - Only extension_slug is required to create a contract; status defaults to a new draft status, and customer, currency, plan and start become nullable (schema 2.5.0, with HPOS-style contract meta indexes)
    - Assign the next cycle position on append; the cycle count is left to the caller
    - Move meta out of the contract entity and row write, and fail item and address writes loudly when the database write fails
    - Remove ContractFactory, insert_with_origin_cycle(), the parent order linkage and OrderRef; the engine no longer reads orders or writes order meta to create a contract, and creates no snapshots
    - Keep only the interim lifecycle and renewal methods on Api\Subscriptions, marked for removal
    - Rename ScalarCoercion to Coercion and share the array coercion helpers
    - Let the interim renewal flow park contracts without a currency, renew contracts without a customer as guest orders, and cancel drafts; bound the related-orders query to the requested page

diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-explicit-contract-creation b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-explicit-contract-creation
new file mode 100644
index 00000000000..2fe25465413
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-explicit-contract-creation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Subscriptions engine package is not released yet; explicit contract creation and view reads through the facade need no changelog entry.
+
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
new file mode 100644
index 00000000000..e14b0b36028
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
@@ -0,0 +1,546 @@
+<?php
+/**
+ * Contracts - the engine's public contract facade (reads and writes).
+ *
+ * Extensions create and progressively build contracts from explicit argument arrays:
+ * the engine records the facts it is given and decides nothing about them. Any caller
+ * may read or write any contract (authorization is the caller's concern). Reads return
+ * read-only views ({@see ContractView}, {@see CycleView}). The engine opens no
+ * transaction and keeps no cache, so a caller may wrap several calls in its own
+ * transaction. No hooks fire.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
+
+use DomainException;
+use InvalidArgumentException;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\CycleView;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\InstrumentRef;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\DuplicateCycleException;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Public contract facade: reads and writes.
+ *
+ * Final and static-only: a stateless entry point, not an extension seam.
+ */
+final class Contracts {
+
+	/**
+	 * Keys accepted by {@see self::update()}, as a key map.
+	 *
+	 * @var array<string, true>
+	 */
+	private const CONTRACT_KEYS = array(
+		'customer_id'          => true,
+		'currency'             => true,
+		'selling_plan_id'      => true,
+		'origin_order_id'      => true,
+		'status'               => true,
+		'payment_method'       => true,
+		'payment_method_title' => true,
+		'payment_token_id'     => true,
+		'start_gmt'            => true,
+		'next_payment_gmt'     => true,
+		'last_payment_gmt'     => true,
+		'last_attempt_gmt'     => true,
+		'trial_end_gmt'        => true,
+		'end_gmt'              => true,
+		'schedule_source'      => true,
+		'billing_total'        => true,
+		'discount_total'       => true,
+		'shipping_total'       => true,
+		'tax_total'            => true,
+		'items'                => true,
+		'addresses'            => true,
+	);
+
+	/**
+	 * Keys accepted by {@see self::create()}: the update keys plus the create-only `extension_slug`.
+	 *
+	 * @var array<string, true>
+	 */
+	private const CREATE_KEYS = array( 'extension_slug' => true ) + self::CONTRACT_KEYS;
+
+	/**
+	 * Keys accepted by {@see self::add_cycle()}, as a key map.
+	 *
+	 * @var array<string, true>
+	 */
+	private const CYCLE_KEYS = array(
+		'status'         => true,
+		'kind'           => true,
+		'sequence_no'    => true,
+		'count'          => true,
+		'starts_at_gmt'  => true,
+		'ends_at_gmt'    => true,
+		'expected_total' => true,
+		'currency'       => true,
+		'order_id'       => true,
+	);
+
+	/**
+	 * Create a contract from explicit fields.
+	 *
+	 * Only `extension_slug` is required; the status defaults to `draft`. Dates accept a
+	 * `DateTimeInterface` or a GMT `Y-m-d H:i:s` string; money accepts numbers or numeric
+	 * strings, and a non-null money value requires a currency. `items` is a list of item
+	 * rows; `addresses` is keyed `billing` / `shipping`. Unknown keys, also inside item rows
+	 * and addresses, raise a `_doing_it_wrong()` notice and are ignored. The contract row,
+	 * items and addresses are separate writes and the engine opens no transaction: wrap the
+	 * call in one when a failed write must leave nothing behind.
+	 *
+	 * @param array<string, mixed> $args Contract fields: `extension_slug` (required, the owning
+	 *                                   extension), `status` (a registered contract status,
+	 *                                   default `draft`), and any of {@see self::CONTRACT_KEYS}.
+	 * @return ContractView The new contract, built from the written fields (no re-read): items
+	 *                      and addresses as given.
+	 * @throws InvalidArgumentException If `extension_slug` is missing or a value is invalid.
+	 */
+	public static function create( array $args ): ContractView {
+		$filtered_args  = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::CREATE_KEYS );
+		$extension_slug = ArgumentValidator::validate_nullable_string( 'extension_slug', $filtered_args['extension_slug'] ?? null );
+		unset( $filtered_args['extension_slug'] );
+
+		try {
+			$contract = Contract::create( array( 'extension_slug' => $extension_slug ) );
+			self::apply( $contract, $filtered_args );
+		} catch ( DomainException $e ) {
+			throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
+		}
+
+		( new ContractRepository() )->insert( $contract );
+
+		return ContractView::from_contract( $contract, true );
+	}
+
+	/**
+	 * Write the given fields to an existing contract.
+	 *
+	 * Takes the keys of {@see self::create()} except `extension_slug`. Only the columns of
+	 * the present keys are written, so fields a concurrent writer changed in between keep
+	 * its values; `items` and `addresses` replace the whole set; `null` clears a
+	 * nullable field (and resets a money field to 0). Unknown keys (`extension_slug`
+	 * included) raise a `_doing_it_wrong()` notice and are ignored. Items and addresses are
+	 * replaced delete-then-insert and the engine opens no transaction: wrap the call in one
+	 * when a failed replacement must keep the previous rows.
+	 *
+	 * @param int                  $contract_id Contract id.
+	 * @param array<string, mixed> $args        Fields to write.
+	 * @return ContractView|null The row as read before the write plus the written fields (a column
+	 *                           another writer changed meanwhile may be stale here, not in storage);
+	 *                           null when the contract does not exist (also when it is deleted
+	 *                           before the write).
+	 * @throws InvalidArgumentException If a value is invalid.
+	 */
+	public static function update( int $contract_id, array $args ): ?ContractView {
+		$filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::CONTRACT_KEYS );
+
+		$repository = new ContractRepository();
+		$contract   = $repository->find( $contract_id );
+		if ( null === $contract ) {
+			return null;
+		}
+
+		try {
+			self::apply( $contract, $filtered_args );
+		} catch ( DomainException $e ) {
+			throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
+		}
+
+		$fields = array_keys( $filtered_args );
+		if ( array() === $fields ) {
+			return ContractView::from_contract( $contract, true );
+		}
+
+		if ( ! $repository->update_fields( $contract, $fields ) ) {
+			return null;
+		}
+
+		return ContractView::from_contract( $contract, true );
+	}
+
+	/**
+	 * Append a cycle to a contract's chain `(contract_id, kind)`.
+	 *
+	 * The first public form of the cycle append tool: append-if-absent on the chain's
+	 * unique positions. `starts_at_gmt`, `ends_at_gmt` and `currency` are required.
+	 * `status` (a registered cycle status) defaults to `pending`; `kind` defaults to
+	 * `billing`; an absent or null `sequence_no` is assigned on append as the head's plus one;
+	 * `count` is the caller's chargeable number (absent or null for a non-counting cycle;
+	 * unique within the chain); `expected_total` defaults to 0; `order_id` is optional. The contract is not
+	 * read: appending to an unknown contract id is a caller error. Unknown keys raise a
+	 * `_doing_it_wrong()` notice and are ignored.
+	 *
+	 * @param int                  $contract_id Contract id.
+	 * @param array<string, mixed> $args        Cycle fields.
+	 * @return CycleView The appended cycle.
+	 * @throws InvalidArgumentException If a required key is missing or a value is invalid.
+	 * @throws DomainException If the chain position or count is already taken.
+	 */
+	public static function add_cycle( int $contract_id, array $args ): CycleView {
+		$filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::CYCLE_KEYS );
+
+		$cycle_args = array(
+			'contract_id'    => $contract_id,
+			'kind'           => $filtered_args['kind'] ?? null,
+			'sequence_no'    => ArgumentValidator::validate_nullable_id( 'sequence_no', $filtered_args['sequence_no'] ?? null ),
+			'count'          => ArgumentValidator::validate_nullable_id( 'count', $filtered_args['count'] ?? null ),
+			'status'         => $filtered_args['status'] ?? null,
+			'starts_at_gmt'  => ArgumentValidator::validate_nullable_date( 'starts_at_gmt', $filtered_args['starts_at_gmt'] ?? null ),
+			'ends_at_gmt'    => ArgumentValidator::validate_nullable_date( 'ends_at_gmt', $filtered_args['ends_at_gmt'] ?? null ),
+			'expected_total' => ArgumentValidator::validate_money( 'expected_total', $filtered_args['expected_total'] ?? null ),
+			'currency'       => ArgumentValidator::validate_currency( $filtered_args['currency'] ?? null ),
+			'order_id'       => ArgumentValidator::validate_nullable_id( 'order_id', $filtered_args['order_id'] ?? null ),
+		);
+
+		try {
+			$cycle = Cycle::create( $cycle_args );
+		} catch ( DomainException $e ) {
+			throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
+		}
+
+		try {
+			( new ContractRepository() )->append_cycle( $cycle, null );
+		} catch ( DuplicateCycleException $e ) {
+			throw new DomainException( 'Contracts: the cycle position or count already exists.' );
+		}
+
+		return CycleView::from_cycle( $cycle );
+	}
+
+	/**
+	 * Add a meta value to a contract, like `add_post_meta()`. A key may hold several values.
+	 * The contract is not looked up: meta for an unknown contract id is a caller error.
+	 *
+	 * @param int    $contract_id Contract id.
+	 * @param string $key         Meta key.
+	 * @param mixed  $value       Meta value; serialized when not scalar.
+	 * @param bool   $unique      When true, add nothing if the key already exists. Advisory: checked
+	 *                            before the insert with no unique index, so concurrent adds can both write.
+	 * @return int|null The meta row id; null when `$unique` and the key exists.
+	 * @throws InvalidArgumentException If `$key` is empty.
+	 */
+	public static function add_meta( int $contract_id, string $key, $value, bool $unique = false ): ?int {
+		return ( new ContractRepository() )->add_meta( $contract_id, $key, $value, $unique );
+	}
+
+	/**
+	 * Update a contract's meta values for `$key`, like `update_post_meta()`: adds the key
+	 * when absent, else rewrites every value, or only the values equal to `$prev_value`.
+	 * The absent-key check runs before the write with no unique index, so it is not a lock.
+	 * The contract is not looked up: meta for an unknown contract id is a caller error.
+	 *
+	 * @param int    $contract_id Contract id.
+	 * @param string $key         Meta key.
+	 * @param mixed  $value       New value; serialized when not scalar.
+	 * @param mixed  $prev_value  Only update values equal to this; null updates all. Any other
+	 *                            value ('' and false included) matches literally.
+	 * @return bool True when a value was added or changed; false when nothing changed.
+	 * @throws InvalidArgumentException If `$key` is empty.
+	 */
+	public static function update_meta( int $contract_id, string $key, $value, $prev_value = null ): bool {
+		return ( new ContractRepository() )->update_meta( $contract_id, $key, $value, $prev_value );
+	}
+
+	/**
+	 * Delete a contract's meta values for `$key`, like `delete_post_meta()`.
+	 *
+	 * @param int    $contract_id Contract id.
+	 * @param string $key         Meta key.
+	 * @param mixed  $value       Only delete values equal to this; null deletes every value for the key.
+	 *                            Any other value ('' and false included) matches literally.
+	 * @return bool True when at least one value was deleted.
+	 * @throws InvalidArgumentException If `$key` is empty.
+	 */
+	public static function delete_meta( int $contract_id, string $key, $value = null ): bool {
+		return ( new ContractRepository() )->delete_meta( $contract_id, $key, $value );
+	}
+
+	/**
+	 * Read contract meta (WordPress `get_post_meta()` semantics), oldest value first.
+	 *
+	 * @param int    $contract_id Contract id.
+	 * @param string $key         Meta key; empty for every key.
+	 * @param bool   $single      With a key: return the first value only.
+	 * @return mixed Empty key: values grouped by key. Key + `$single`: the first value, or ''
+	 *               when absent. Key only: the list of values (`[]` when absent).
+	 */
+	public static function get_meta( int $contract_id, string $key = '', bool $single = false ) {
+		return ( new ContractRepository() )->get_meta( $contract_id, $key, $single );
+	}
+
+	/**
+	 * Fetch a contract by id, with its items and addresses.
+	 *
+	 * @param int $contract_id Contract id.
+	 * @return ContractView|null The contract, or null when none exists.
+	 */
+	public static function get( int $contract_id ): ?ContractView {
+		return self::view( ( new ContractRepository() )->find( $contract_id ), true );
+	}
+
+	/**
+	 * The contracts created from an origin order, oldest first (children not loaded).
+	 *
+	 * @param int $order_id Origin order id.
+	 * @return array<int, ContractView>
+	 */
+	public static function find_by_origin_order( int $order_id ): array {
+		return self::views( ( new ContractRepository() )->find_by_origin_order( $order_id ) );
+	}
+
+	/**
+	 * List contracts for an admin list screen - newest first by default, or
+	 * filtered / sorted / paged / searched via a WooCommerce-style args array (cf.
+	 * `wc_get_orders()`). The status + search filter matches {@see self::count()}, so a page
+	 * and its total describe the same set.
+	 *
+	 * @param array<string, mixed> $args {
+	 *     Optional. Query args.
+	 *
+	 *     @type int    $limit   Maximum contracts to return. Default 20.
+	 *     @type int    $offset  Contracts to skip (for paging). Default 0.
+	 *     @type string $status  Filter to one status ({@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus}); ignored when empty or invalid.
+	 *     @type string $orderby One of id, next_payment, total, start; default id.
+	 *     @type string $order   ASC or DESC (case-insensitive); default DESC.
+	 *     @type string $search  Numeric term matches contract id or origin order id; text term matches the owning customer.
+	 * }
+	 * @return array<int, ContractView> Contracts in the requested order (children not loaded).
+	 */
+	public static function list( array $args = array() ): array {
+		return self::views( ( new ContractRepository() )->query( $args ) );
+	}
+
+	/**
+	 * The contract count per status - the read behind an admin list's status views bar.
+	 * Keyed by every {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus} value (absent statuses are 0); the `All` total
+	 * is the caller's `array_sum()`. Independent of any search or paging.
+	 *
+	 * @return array<string, int> Status => count, every known status present.
+	 */
+	public static function count_by_status(): array {
+		return ( new ContractRepository() )->count_by_status();
+	}
+
+	/**
+	 * The number of contracts matching a list filter - the total behind a list view's
+	 * pagination. Honours the SAME status + search args as {@see self::list()} and ignores
+	 * paging / sort.
+	 *
+	 * @param array<string, mixed> $args Query args (only `status` and `search` are read).
+	 * @return int The matching contract count.
+	 */
+	public static function count( array $args = array() ): int {
+		return ( new ContractRepository() )->count( $args );
+	}
+
+	/**
+	 * The line-item count for a page of contracts - the read behind an admin list's
+	 * "Items" column. One grouped scan over the given ids, returned as a map keyed by
+	 * every requested id (ids with no items are 0), so a list renders an items count
+	 * per row without a per-row query. Ids are de-duplicated and int-cast.
+	 *
+	 * @param array<int, int> $contract_ids Contract ids to count items for.
+	 * @return array<int, int> Contract id => line-item count, one entry per requested id.
+	 */
+	public static function item_counts( array $contract_ids ): array {
+		return ( new ContractRepository() )->count_items_by_contract( $contract_ids );
+	}
+
+	/**
+	 * List a single customer's contracts, newest first - the customer
+	 * portal's owner-scoped list read.
+	 *
+	 * Owner-scoped by construction: the customer id is supplied by the caller (the
+	 * authenticated user at the REST boundary), never inferred, so it never returns
+	 * another customer's contracts. Each view projects the stored contract fields (items
+	 * and addresses not loaded); a caller needing plan terms resolves `selling_plan_id`
+	 * through {@see SellingPlans}.
+	 *
+	 * The status filter applies before paging, so a page holds `$limit` matching contracts.
+	 *
+	 * @param int                  $customer_id Owning customer id.
+	 * @param int                  $limit       Maximum contracts to return.
+	 * @param int                  $offset      Contracts to skip (for paging).
+	 * @param array<string, mixed> $args {
+	 *     Optional. Query args.
+	 *
+	 *     @type string|string[] $status One status or a list of them ({@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus});
+	 *                                   unregistered values are dropped, and the filter is ignored when none remain.
+	 * }
+	 * @return array<int, ContractView> The customer's contracts, newest first.
+	 */
+	public static function list_for_customer( int $customer_id, int $limit = 20, int $offset = 0, array $args = array() ): array {
+		return self::views(
+			( new ContractRepository() )->find_by_customer_id(
+				$customer_id,
+				array(
+					'limit'  => $limit,
+					'offset' => $offset,
+					'status' => $args['status'] ?? array(),
+				)
+			)
+		);
+	}
+
+	/**
+	 * Fetch a contract a customer owns - the customer portal's ownership-checked read.
+	 *
+	 * Returns null for BOTH an unknown id AND a contract owned by another customer (the
+	 * asymmetric not-found rule), so a caller cannot probe for the existence of a
+	 * contract it does not own.
+	 *
+	 * The returned view projects the stored contract fields with items and addresses; a
+	 * caller needing plan terms resolves `selling_plan_id` through {@see SellingPlans}.
+	 *
+	 * @param int $contract_id Contract id.
+	 * @param int $customer_id Customer that must own the contract.
+	 * @return ContractView|null The contract when owned by `$customer_id`, else null.
+	 * @phpstan-impure
+	 */
+	public static function get_for_customer( int $contract_id, int $customer_id ): ?ContractView {
+		return self::view( ( new ContractRepository() )->find_for_customer( $contract_id, $customer_id ), true );
+	}
+
+	/**
+	 * Fetch a window of the contract's billing cycles, newest first.
+	 *
+	 * @param int $contract_id Contract id.
+	 * @param int $limit       Maximum cycles to return.
+	 * @return array<int, CycleView> Cycles newest first.
+	 */
+	public static function get_cycles( int $contract_id, int $limit = 20 ): array {
+		return array_map(
+			static function ( Cycle $cycle ): CycleView {
+				return CycleView::from_cycle( $cycle );
+			},
+			( new ContractRepository() )->find_cycle_history( $contract_id, Cycle::KIND_BILLING, $limit )
+		);
+	}
+
+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- the DomainException comes from the entity setters, not a throw in this method.
+	/**
+	 * Validate the caller's field shapes and apply them to a contract through its setters,
+	 * which enforce the entity invariants. Nothing is written to storage; an invalid value
+	 * throws before any write.
+	 *
+	 * @param Contract             $contract Contract to change.
+	 * @param array<string, mixed> $args     Caller fields (known keys only, no `extension_slug`).
+	 * @throws InvalidArgumentException If a value has the wrong shape.
+	 * @throws DomainException If a value breaks an entity invariant (from the entity setters).
+	 */
+	private static function apply( Contract $contract, array $args ): void {
+		$instrument = $contract->get_payment_instrument();
+		$token_id   = $instrument->get_token_id();
+		$gateway    = $instrument->get_gateway();
+		$title      = $instrument->get_title();
+
+		foreach ( $args as $key => $value ) {
+			switch ( $key ) {
+				case 'customer_id':
+					$contract->set_customer_id( ArgumentValidator::validate_nullable_id( $key, $value ) );
+					break;
+				case 'selling_plan_id':
+					$contract->set_selling_plan_id( ArgumentValidator::validate_nullable_id( $key, $value ) );
+					break;
+				case 'origin_order_id':
+					$contract->set_origin_order_id( ArgumentValidator::validate_nullable_id( $key, $value ) );
+					break;
+				case 'currency':
+					$contract->set_currency( ArgumentValidator::validate_currency( $value ) );
+					break;
+				case 'status':
+					$contract->set_status( ArgumentValidator::validate_string( $key, $value ) );
+					break;
+				case 'schedule_source':
+					$contract->set_schedule_source( ArgumentValidator::validate_string( $key, $value ) );
+					break;
+				case 'start_gmt':
+					$contract->set_start_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
+					break;
+				case 'next_payment_gmt':
+					$contract->set_next_payment_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
+					break;
+				case 'last_payment_gmt':
+					$contract->set_last_payment_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
+					break;
+				case 'last_attempt_gmt':
+					$contract->set_last_attempt_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
+					break;
+				case 'trial_end_gmt':
+					$contract->set_trial_end_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
+					break;
+				case 'end_gmt':
+					$contract->set_end_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
+					break;
+				case 'billing_total':
+					$contract->set_billing_total( ArgumentValidator::validate_money( $key, $value ) );
+					break;
+				case 'discount_total':
+					$contract->set_discount_total( ArgumentValidator::validate_money( $key, $value ) );
+					break;
+				case 'shipping_total':
+					$contract->set_shipping_total( ArgumentValidator::validate_money( $key, $value ) );
+					break;
+				case 'tax_total':
+					$contract->set_tax_total( ArgumentValidator::validate_money( $key, $value ) );
+					break;
+				case 'items':
+					$contract->set_items( ArgumentValidator::validate_contract_items( $value, self::class ) );
+					break;
+				case 'addresses':
+					$contract->set_addresses( ArgumentValidator::validate_contract_addresses( $value, self::class ) );
+					break;
+				case 'payment_token_id':
+					$token_id = ArgumentValidator::validate_nullable_id( $key, $value );
+					break;
+				case 'payment_method':
+					$gateway = ArgumentValidator::validate_nullable_string( $key, $value );
+					break;
+				case 'payment_method_title':
+					$title = ArgumentValidator::validate_nullable_string( $key, $value );
+					break;
+			}
+		}
+
+		$contract->set_payment_instrument( new InstrumentRef( $token_id, $gateway, $title ) );
+		$contract->assert_money_has_currency();
+	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
+
+	/**
+	 * A view of `$contract`, or null.
+	 *
+	 * @param Contract|null $contract      Contract, or null.
+	 * @param bool          $with_children Whether the read loaded items and addresses.
+	 */
+	private static function view( ?Contract $contract, bool $with_children ): ?ContractView {
+		return null === $contract ? null : ContractView::from_contract( $contract, $with_children );
+	}
+
+	/**
+	 * Views of row-only contracts (children not loaded).
+	 *
+	 * @param array<int, Contract> $contracts Contracts.
+	 * @return array<int, ContractView>
+	 */
+	private static function views( array $contracts ): array {
+		return array_map(
+			static function ( Contract $contract ): ContractView {
+				return ContractView::from_contract( $contract, false );
+			},
+			$contracts
+		);
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php
index 07b30b5cea4..1b3f84a85ef 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php
@@ -45,9 +45,10 @@ use WP_REST_Controller;
 use WP_REST_Request;
 use WP_REST_Response;
 use WP_REST_Server;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\Subscriptions;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\RESTPermissions;

 defined( 'ABSPATH' ) || exit;
@@ -205,7 +206,7 @@ final class ContractsController extends WP_REST_Controller {
 		// schema, so it arrives as a real bool; the coercion path covers a caller
 		// invoking the method directly with a raw value.
 		$param         = $request->get_param( 'at_period_end' );
-		$at_period_end = is_bool( $param ) ? $param : rest_sanitize_boolean( ScalarCoercion::coerce_string( $param, 'true' ) );
+		$at_period_end = is_bool( $param ) ? $param : rest_sanitize_boolean( Coercion::coerce_string( $param, 'true' ) );

 		return $this->run_action(
 			$request,
@@ -225,7 +226,7 @@ final class ContractsController extends WP_REST_Controller {
 	 * Domain values only - the id and the resulting status slug - never labels,
 	 * formatted values, or other presentation: consumers own their view shaping.
 	 *
-	 * @param Contract        $item    Contract.
+	 * @param ContractView    $item    Contract view.
 	 * @param WP_REST_Request $request Request.
 	 * @return WP_REST_Response
 	 */
@@ -286,13 +287,13 @@ final class ContractsController extends WP_REST_Controller {
 	 * @return WP_REST_Response|WP_Error
 	 */
 	private function run_action( WP_REST_Request $request, callable $action ) {
-		$contract_id = ScalarCoercion::coerce_int( $request->get_param( 'id' ) );
+		$contract_id = Coercion::coerce_int( $request->get_param( 'id' ) );
 		$customer_id = get_current_user_id();

 		// Guard ownership before acting: the facade's ownership-checked read returns
 		// null for an unknown id and a foreign-owned contract alike, so both map to
 		// the same 404 (anti-IDOR).
-		if ( null === Subscriptions::get_for_customer( $contract_id, $customer_id ) ) {
+		if ( null === Contracts::get_for_customer( $contract_id, $customer_id ) ) {
 			return $this->not_found_error();
 		}

@@ -314,7 +315,7 @@ final class ContractsController extends WP_REST_Controller {

 		// Re-read for the resulting status. The action already succeeded, so a row
 		// vanishing here is a server-side inconsistency - a 500, not a not-found.
-		$refreshed = Subscriptions::get_for_customer( $contract_id, $customer_id );
+		$refreshed = Contracts::get_for_customer( $contract_id, $customer_id );
 		if ( null === $refreshed ) {
 			return new WP_Error(
 				'woocommerce_subscriptions_engine_refresh_failed',
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
index 569ffb4f68f..8e3ef301141 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
@@ -10,7 +10,7 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\SubscriptionsEngine\Api\Rest;

 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\RESTPermissions;
@@ -203,7 +203,7 @@ final class PlansController extends WP_REST_Controller {
 			return $extension_slugs;
 		}

-		$page     = max( 1, ScalarCoercion::coerce_int( $request->get_param( 'page' ), 1 ) );
+		$page     = max( 1, Coercion::coerce_int( $request->get_param( 'page' ), 1 ) );
 		$per_page = $this->resolve_per_page( $request );
 		$args     = array(
 			'limit'           => $per_page,
@@ -251,7 +251,7 @@ final class PlansController extends WP_REST_Controller {
 			return $extension_slug;
 		}

-		$plan = $this->plan_repository->find( ScalarCoercion::coerce_int( $request->get_param( 'id' ) ), $extension_slug );
+		$plan = $this->plan_repository->find( Coercion::coerce_int( $request->get_param( 'id' ) ), $extension_slug );
 		if ( ! $plan instanceof Plan ) {
 			return $this->not_found_error();
 		}
@@ -292,7 +292,7 @@ final class PlansController extends WP_REST_Controller {
 					'pricing_policy' => $this->pricing_policy_from_param( $request->get_param( 'pricing_policy' ), null ),
 					'category'       => $this->string_param( $request, 'category', Plan::DEFAULT_CATEGORY ),
 					'status'         => $this->string_param( $request, 'status', Plan::STATUS_ACTIVE ),
-					'sort_order'     => ScalarCoercion::coerce_int( $request->get_param( 'sort_order' ) ),
+					'sort_order'     => Coercion::coerce_int( $request->get_param( 'sort_order' ) ),
 					'extension_slug' => $extension_slug,
 				)
 			);
@@ -325,7 +325,7 @@ final class PlansController extends WP_REST_Controller {
 			return $extension_slug;
 		}

-		$plan = $this->plan_repository->find( ScalarCoercion::coerce_int( $request->get_param( 'id' ) ), $extension_slug );
+		$plan = $this->plan_repository->find( Coercion::coerce_int( $request->get_param( 'id' ) ), $extension_slug );
 		if ( ! $plan instanceof Plan ) {
 			return $this->not_found_error();
 		}
@@ -367,7 +367,7 @@ final class PlansController extends WP_REST_Controller {
 			}

 			if ( $request->has_param( 'sort_order' ) ) {
-				$plan->set_sort_order( ScalarCoercion::coerce_int( $request->get_param( 'sort_order' ) ) );
+				$plan->set_sort_order( Coercion::coerce_int( $request->get_param( 'sort_order' ) ) );
 			}

 			$errors = $this->validate_with_owner( $plan, $extension_slug );
@@ -409,7 +409,7 @@ final class PlansController extends WP_REST_Controller {
 		$sort_order_by_id = array();
 		$response_ids     = array();
 		foreach ( array_values( $ids ) as $index => $raw_id ) {
-			$id = ScalarCoercion::coerce_nullable_int( $raw_id );
+			$id = Coercion::coerce_nullable_int( $raw_id );
 			if ( null === $id || $id <= 0 ) {
 				return $this->invalid_error( __( 'ids must contain only positive integers.', 'woocommerce-subscriptions-engine' ) );
 			}
@@ -451,7 +451,7 @@ final class PlansController extends WP_REST_Controller {
 			'pricing_policy' => $item->get_pricing_policy(),
 		);

-		$context = ScalarCoercion::coerce_string( $request->get_param( 'context' ), 'view' );
+		$context = Coercion::coerce_string( $request->get_param( 'context' ), 'view' );
 		$context = '' !== $context ? $context : 'view';
 		$data    = $this->add_additional_fields_to_object( $data, $request );
 		$data    = $this->filter_response_by_context( $data, $context );
@@ -587,7 +587,7 @@ final class PlansController extends WP_REST_Controller {
 	 * @param WP_REST_Request $request Request.
 	 */
 	private function resolve_per_page( WP_REST_Request $request ): int {
-		$value = ScalarCoercion::coerce_int( $request->get_param( 'per_page' ), self::DEFAULT_PER_PAGE );
+		$value = Coercion::coerce_int( $request->get_param( 'per_page' ), self::DEFAULT_PER_PAGE );
 		if ( $value < 1 ) {
 			return self::DEFAULT_PER_PAGE;
 		}
@@ -689,7 +689,7 @@ final class PlansController extends WP_REST_Controller {
 		if ( null === $raw ) {
 			return $this->invalid_error( __( 'extension_slug is required.', 'woocommerce-subscriptions-engine' ) );
 		}
-		$raw_string = trim( ScalarCoercion::coerce_string( $raw ) );
+		$raw_string = trim( Coercion::coerce_string( $raw ) );
 		if ( '' === $raw_string ) {
 			return $this->invalid_error( __( 'extension_slug is required.', 'woocommerce-subscriptions-engine' ) );
 		}
@@ -722,7 +722,7 @@ final class PlansController extends WP_REST_Controller {
 		if ( null === $raw ) {
 			return $this->invalid_error( __( 'extension_slug is required.', 'woocommerce-subscriptions-engine' ) );
 		}
-		$raw_string = trim( ScalarCoercion::coerce_string( $raw ) );
+		$raw_string = trim( Coercion::coerce_string( $raw ) );
 		if ( '' === $raw_string ) {
 			return $this->invalid_error( __( 'extension_slug is required.', 'woocommerce-subscriptions-engine' ) );
 		}
@@ -751,7 +751,7 @@ final class PlansController extends WP_REST_Controller {
 	 * @param string          $fallback Fallback.
 	 */
 	private function string_param( WP_REST_Request $request, string $key, string $fallback = '' ): string {
-		return sanitize_text_field( ScalarCoercion::coerce_string( $request->get_param( $key ), $fallback ) );
+		return sanitize_text_field( Coercion::coerce_string( $request->get_param( $key ), $fallback ) );
 	}

 	/**
@@ -761,7 +761,7 @@ final class PlansController extends WP_REST_Controller {
 	 * @param string          $key     Param key.
 	 */
 	private function nullable_string_param( WP_REST_Request $request, string $key ): ?string {
-		$value = ScalarCoercion::coerce_nullable_string( $request->get_param( $key ) );
+		$value = Coercion::coerce_nullable_string( $request->get_param( $key ) );
 		if ( null === $value || '' === $value ) {
 			return null;
 		}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
index ac65d90b134..df5232ab987 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
@@ -1,17 +1,9 @@
 <?php
 /**
- * Subscriptions - the engine's public consumer facade.
+ * Subscriptions - the engine's interim lifecycle and renewal facade.
  *
- * The one surface consumers (a host plugin's admin UI, tests) import to read and act
- * on subscriptions: read the contract and its cycle history, cancel, and run a renewal
- * now. It hides the internal `Core\` / `Integration\` collaborators (the repository,
- * the renewal engine) behind a stable boundary, so the internals stay refactorable.
- *
- * Interim return types: it returns the core entities ({@see Contract}, {@see Cycle})
- * and `WC_Order` directly for now; richer read-model views are a planned follow-up, so
- * consumers also reference those types until the views land. `Api\` is the public
- * surface, not a third internal zone - the two-zone (Core/Integration) model still
- * describes the internals.
+ * Cancel, hold, reactivate, renew now, and read a contract's related orders. It hides
+ * the internal `Core\` / `Integration\` collaborators behind a stable boundary.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Api
  */
@@ -21,8 +13,6 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\SubscriptionsEngine\Api;

 use WC_Order;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\RelatedOrders;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold;
@@ -35,137 +25,13 @@ defined( 'ABSPATH' ) || exit;
 /**
  * Public subscriptions facade.
  *
+ * Interim: removed once lifecycle flows and renewals move to extensions. Add no new
+ * methods here; contract reads and writes live in {@see Contracts}.
+ *
  * Final and static-only: a stateless entry point, not an extension seam.
  */
 final class Subscriptions {

-	/**
-	 * Fetch a subscription contract by id, with its frozen plan terms hydrated.
-	 *
-	 * The returned contract carries its plan snapshot ({@see Contract::get_plan_snapshot()}),
-	 * so a consumer reads the billing cadence straight off the snapshot - no live
-	 * plan-repository join.
-	 *
-	 * @param int $contract_id Contract id.
-	 * @return Contract|null The contract, or null when none exists.
-	 */
-	public static function get( int $contract_id ): ?Contract {
-		return ( new ContractRepository() )->find( $contract_id );
-	}
-
-	/**
-	 * List subscription contracts for an admin list screen - newest first by default, or
-	 * filtered / sorted / paged / searched via a WooCommerce-style args array (cf.
-	 * `wc_get_orders()`). The status + search filter matches {@see self::count()}, so a page
-	 * and its total describe the same set.
-	 *
-	 * @param array<string, mixed> $args {
-	 *     Optional. Query args.
-	 *
-	 *     @type int    $limit   Maximum contracts to return. Default 20.
-	 *     @type int    $offset  Contracts to skip (for paging). Default 0.
-	 *     @type string $status  Filter to one status ({@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus}); ignored when empty or invalid.
-	 *     @type string $orderby One of id, next_payment, total, start; default id.
-	 *     @type string $order   ASC or DESC (case-insensitive); default DESC.
-	 *     @type string $search  Numeric term matches contract id or origin order id; text term matches the owning customer.
-	 * }
-	 * @return array<int, Contract> Contracts in the requested order.
-	 */
-	public static function list( array $args = array() ): array {
-		return ( new ContractRepository() )->query( $args );
-	}
-
-	/**
-	 * The contract count per status - the read behind an admin list's status views bar.
-	 * Keyed by every {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus} value (absent statuses are 0); the `All` total
-	 * is the caller's `array_sum()`. Independent of any search or paging.
-	 *
-	 * @return array<string, int> Status => count, every known status present.
-	 */
-	public static function count_by_status(): array {
-		return ( new ContractRepository() )->count_by_status();
-	}
-
-	/**
-	 * The number of contracts matching a list filter - the total behind a list view's
-	 * pagination. Honours the SAME status + search args as {@see self::list()} and ignores
-	 * paging / sort.
-	 *
-	 * @param array<string, mixed> $args Query args (only `status` and `search` are read).
-	 * @return int The matching contract count.
-	 */
-	public static function count( array $args = array() ): int {
-		return ( new ContractRepository() )->count( $args );
-	}
-
-	/**
-	 * The line-item count for a page of contracts - the read behind an admin list's
-	 * "Items" column. One grouped scan over the given ids, returned as a map keyed by
-	 * every requested id (ids with no items are 0), so a list renders an items count
-	 * per row without a per-row query. Ids are de-duplicated and int-cast.
-	 *
-	 * @param array<int, int> $contract_ids Contract ids to count items for.
-	 * @return array<int, int> Contract id => line-item count, one entry per requested id.
-	 */
-	public static function item_counts( array $contract_ids ): array {
-		return ( new ContractRepository() )->count_items_by_contract( $contract_ids );
-	}
-
-	/**
-	 * List a single customer's subscription contracts, newest first - the customer
-	 * portal's owner-scoped list read.
-	 *
-	 * Owner-scoped by construction: the customer id is supplied by the caller (the
-	 * authenticated user at the REST boundary), never inferred, so it never returns
-	 * another customer's contracts. Returns interim {@see Contract} entities, each with its
-	 * frozen plan terms hydrated ({@see Contract::get_plan_snapshot()}) so a list row's
-	 * cadence is read off the snapshot.
-	 *
-	 * @param int $customer_id Owning customer id.
-	 * @param int $limit       Maximum contracts to return.
-	 * @param int $offset      Contracts to skip (for paging).
-	 * @return array<int, Contract> The customer's contracts, newest first.
-	 */
-	public static function list_for_customer( int $customer_id, int $limit = 20, int $offset = 0 ): array {
-		return ( new ContractRepository() )->find_by_customer_id(
-			$customer_id,
-			array(
-				'limit'  => $limit,
-				'offset' => $offset,
-			)
-		);
-	}
-
-	/**
-	 * Fetch a contract a customer owns - the customer portal's ownership-checked read.
-	 *
-	 * Returns null for BOTH an unknown id AND a contract owned by another customer (the
-	 * asymmetric not-found rule), so a caller cannot probe for the existence of a
-	 * contract it does not own.
-	 *
-	 * The returned contract carries its frozen plan terms ({@see Contract::get_plan_snapshot()}),
-	 * so the cadence is read off the snapshot with no live plan-repository join.
-	 *
-	 * @param int $contract_id Contract id.
-	 * @param int $customer_id Customer that must own the contract.
-	 * @return Contract|null The contract when owned by `$customer_id`, else null.
-	 * @phpstan-impure
-	 */
-	public static function get_for_customer( int $contract_id, int $customer_id ): ?Contract {
-		return ( new ContractRepository() )->find_for_customer( $contract_id, $customer_id );
-	}
-
-	/**
-	 * Fetch a window of the contract's billing cycle history, newest first.
-	 *
-	 * @param int $contract_id Contract id.
-	 * @param int $limit       Maximum cycles to return.
-	 * @return array<int, Cycle> Cycles newest first.
-	 */
-	public static function get_history( int $contract_id, int $limit = 20 ): array {
-		return ( new ContractRepository() )->find_cycle_history( $contract_id, Cycle::KIND_BILLING, $limit );
-	}
-
 	/**
 	 * The orders related to a contract (the origin order, plus renewals / switches /
 	 * resubscribes), newest first - the portal detail's related-orders read kept
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/View/ContractView.php b/packages/php/woocommerce-subscriptions-engine/src/Api/View/ContractView.php
new file mode 100644
index 00000000000..cdc4b749c56
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/View/ContractView.php
@@ -0,0 +1,296 @@
+<?php
+/**
+ * ContractView - a read-only view of a contract at the `Api\` boundary.
+ *
+ * Consumers read contracts through this view instead of the Core entity. Getters may
+ * be added, never removed. Children (`items`, `addresses`) take the shape the
+ * contracts write facade accepts; they are null when the read did not load them (list
+ * reads) and an array when it did.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api\View
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api\View;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Immutable contract view.
+ */
+final class ContractView {
+
+	/**
+	 * Contract row values keyed by field name.
+	 *
+	 * @var array{id: int, status: string, extension_slug: ?string, customer_id: ?int, currency: ?string, selling_plan_id: ?int, origin_order_id: ?int, payment_method: ?string, payment_method_title: ?string, payment_token_id: ?int, start_gmt: ?string, next_payment_gmt: ?string, last_payment_gmt: ?string, last_attempt_gmt: ?string, trial_end_gmt: ?string, end_gmt: ?string, billing_total: string, discount_total: string, shipping_total: string, tax_total: string, schedule_source: string}
+	 */
+	private $fields;
+
+	/**
+	 * Line items, or null when not loaded.
+	 *
+	 * @var array<int, array<string, mixed>>|null
+	 */
+	private $items;
+
+	/**
+	 * Addresses keyed by type, or null when not loaded.
+	 *
+	 * @var array<string, array<string, mixed>>|null
+	 */
+	private $addresses;
+
+	/**
+	 * Use {@see self::from_contract()}.
+	 */
+	private function __construct() {
+	}
+
+	/**
+	 * Build a view of a stored contract.
+	 *
+	 * @internal Built by the engine `Api\` facades only.
+	 *
+	 * @param Contract $contract      Stored contract.
+	 * @param bool     $with_children Whether the read loaded items and addresses.
+	 */
+	public static function from_contract( Contract $contract, bool $with_children ): self {
+		$instrument = $contract->get_payment_instrument();
+
+		$view         = new self();
+		$view->fields = array(
+			'id'                   => (int) $contract->get_id(),
+			'status'               => $contract->get_status(),
+			'extension_slug'       => $contract->get_extension_slug(),
+			'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(),
+			'payment_method'       => $instrument->get_gateway(),
+			'payment_method_title' => $instrument->get_title(),
+			'payment_token_id'     => $instrument->get_token_id(),
+			'start_gmt'            => $contract->get_start_gmt(),
+			'next_payment_gmt'     => $contract->get_next_payment_gmt(),
+			'last_payment_gmt'     => $contract->get_last_payment_gmt(),
+			'last_attempt_gmt'     => $contract->get_last_attempt_gmt(),
+			'trial_end_gmt'        => $contract->get_trial_end_gmt(),
+			'end_gmt'              => $contract->get_end_gmt(),
+			'billing_total'        => $contract->get_billing_total(),
+			'discount_total'       => $contract->get_discount_total(),
+			'shipping_total'       => $contract->get_shipping_total(),
+			'tax_total'            => $contract->get_tax_total(),
+			'schedule_source'      => $contract->get_schedule_source(),
+		);
+
+		$view->items     = $with_children ? array_map( array( self::class, 'item' ), $contract->get_items() ) : null;
+		$view->addresses = $with_children ? array_map( array( self::class, 'address' ), $contract->get_addresses() ) : null;
+
+		return $view;
+	}
+
+	/**
+	 * Project a stored item row onto the item write fields; `taxes` decoded to an array.
+	 *
+	 * @param array<string, mixed> $row Stored item row.
+	 * @return array<string, mixed>
+	 */
+	private static function item( array $row ): array {
+		$item = array();
+		foreach ( Contract::ITEM_FIELDS as $field ) {
+			$item[ $field ] = $row[ $field ] ?? null;
+		}
+
+		foreach ( array( 'product_id', 'variation_id' ) as $field ) {
+			$item[ $field ] = is_numeric( $item[ $field ] ) ? (int) $item[ $field ] : null;
+		}
+
+		$taxes         = is_string( $item['taxes'] ) ? json_decode( $item['taxes'], true ) : $item['taxes'];
+		$item['taxes'] = is_array( $taxes ) ? $taxes : null;
+
+		return $item;
+	}
+
+	/**
+	 * Project a stored address row onto the address write fields.
+	 *
+	 * @param array<string, mixed> $row Stored address row.
+	 * @return array<string, mixed>
+	 */
+	private static function address( array $row ): array {
+		$address = array();
+		foreach ( Contract::ADDRESS_FIELDS as $field ) {
+			$address[ $field ] = $row[ $field ] ?? null;
+		}
+
+		return $address;
+	}
+
+	/**
+	 * Contract id.
+	 */
+	public function get_id(): int {
+		return $this->fields['id'];
+	}
+
+	/**
+	 * Contract status slug.
+	 */
+	public function get_status(): string {
+		return $this->fields['status'];
+	}
+
+	/**
+	 * Owning extension slug, or null.
+	 */
+	public function get_extension_slug(): ?string {
+		return $this->fields['extension_slug'];
+	}
+
+	/**
+	 * Customer id, or null.
+	 */
+	public function get_customer_id(): ?int {
+		return $this->fields['customer_id'];
+	}
+
+	/**
+	 * ISO-4217 currency code, or null.
+	 */
+	public function get_currency(): ?string {
+		return $this->fields['currency'];
+	}
+
+	/**
+	 * Selling plan id, or null.
+	 */
+	public function get_selling_plan_id(): ?int {
+		return $this->fields['selling_plan_id'];
+	}
+
+	/**
+	 * Origin order id, or null.
+	 */
+	public function get_origin_order_id(): ?int {
+		return $this->fields['origin_order_id'];
+	}
+
+	/**
+	 * Payment gateway id, or null.
+	 */
+	public function get_payment_method(): ?string {
+		return $this->fields['payment_method'];
+	}
+
+	/**
+	 * Payment method title, or null.
+	 */
+	public function get_payment_method_title(): ?string {
+		return $this->fields['payment_method_title'];
+	}
+
+	/**
+	 * Payment token id, or null.
+	 */
+	public function get_payment_token_id(): ?int {
+		return $this->fields['payment_token_id'];
+	}
+
+	/**
+	 * Start (GMT `Y-m-d H:i:s`), or null.
+	 */
+	public function get_start_gmt(): ?string {
+		return $this->fields['start_gmt'];
+	}
+
+	/**
+	 * Next-due moment (GMT), or null.
+	 */
+	public function get_next_payment_gmt(): ?string {
+		return $this->fields['next_payment_gmt'];
+	}
+
+	/**
+	 * Last successful payment (GMT), or null.
+	 */
+	public function get_last_payment_gmt(): ?string {
+		return $this->fields['last_payment_gmt'];
+	}
+
+	/**
+	 * Last charge attempt (GMT), or null.
+	 */
+	public function get_last_attempt_gmt(): ?string {
+		return $this->fields['last_attempt_gmt'];
+	}
+
+	/**
+	 * Trial end (GMT), or null.
+	 */
+	public function get_trial_end_gmt(): ?string {
+		return $this->fields['trial_end_gmt'];
+	}
+
+	/**
+	 * End (GMT), or null.
+	 */
+	public function get_end_gmt(): ?string {
+		return $this->fields['end_gmt'];
+	}
+
+	/**
+	 * Billing total (decimal string).
+	 */
+	public function get_billing_total(): string {
+		return $this->fields['billing_total'];
+	}
+
+	/**
+	 * Discount total (decimal string).
+	 */
+	public function get_discount_total(): string {
+		return $this->fields['discount_total'];
+	}
+
+	/**
+	 * Shipping total (decimal string).
+	 */
+	public function get_shipping_total(): string {
+		return $this->fields['shipping_total'];
+	}
+
+	/**
+	 * Tax total (decimal string).
+	 */
+	public function get_tax_total(): string {
+		return $this->fields['tax_total'];
+	}
+
+	/**
+	 * Who runs renewals: `primitive` or `gateway`.
+	 */
+	public function get_schedule_source(): string {
+		return $this->fields['schedule_source'];
+	}
+
+	/**
+	 * Line items, or null when the read did not load them.
+	 *
+	 * @return array<int, array<string, mixed>>|null
+	 */
+	public function get_items(): ?array {
+		return $this->items;
+	}
+
+	/**
+	 * Addresses keyed by type (`billing` / `shipping`), or null when the read did not load them.
+	 *
+	 * @return array<string, array<string, mixed>>|null
+	 */
+	public function get_addresses(): ?array {
+		return $this->addresses;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/View/CycleView.php b/packages/php/woocommerce-subscriptions-engine/src/Api/View/CycleView.php
new file mode 100644
index 00000000000..b00aaf7bdc9
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/View/CycleView.php
@@ -0,0 +1,207 @@
+<?php
+/**
+ * CycleView - a read-only view of a cycle at the `Api\` boundary.
+ *
+ * Consumers read cycles through this view instead of the Core entity. Getters may be
+ * added, never removed.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api\View
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api\View;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Immutable cycle view.
+ */
+final class CycleView {
+
+	/**
+	 * Cycle id.
+	 *
+	 * @var int
+	 */
+	private $id;
+
+	/**
+	 * Contract id.
+	 *
+	 * @var int
+	 */
+	private $contract_id;
+
+	/**
+	 * Chain kind (e.g. `billing`).
+	 *
+	 * @var string
+	 */
+	private $kind;
+
+	/**
+	 * Position in the chain.
+	 *
+	 * @var int
+	 */
+	private $sequence_no;
+
+	/**
+	 * Charge count, or null for a non-counting cycle.
+	 *
+	 * @var int|null
+	 */
+	private $count;
+
+	/**
+	 * Cycle status slug.
+	 *
+	 * @var string
+	 */
+	private $status;
+
+	/**
+	 * Period start (GMT `Y-m-d H:i:s`).
+	 *
+	 * @var string
+	 */
+	private $starts_at_gmt;
+
+	/**
+	 * Period end (GMT `Y-m-d H:i:s`).
+	 *
+	 * @var string
+	 */
+	private $ends_at_gmt;
+
+	/**
+	 * Expected total (decimal string).
+	 *
+	 * @var string
+	 */
+	private $expected_total;
+
+	/**
+	 * ISO-4217 currency code.
+	 *
+	 * @var string
+	 */
+	private $currency;
+
+	/**
+	 * Linked order id, or null.
+	 *
+	 * @var int|null
+	 */
+	private $order_id;
+
+	/**
+	 * Use {@see self::from_cycle()}.
+	 */
+	private function __construct() {
+	}
+
+	/**
+	 * Build a view of a stored cycle.
+	 *
+	 * @internal Built by the engine `Api\` facades only.
+	 *
+	 * @param Cycle $cycle Stored cycle.
+	 */
+	public static function from_cycle( Cycle $cycle ): self {
+		$view                 = new self();
+		$view->id             = (int) $cycle->get_id();
+		$view->contract_id    = $cycle->get_contract_id();
+		$view->kind           = $cycle->get_kind();
+		$view->sequence_no    = $cycle->get_sequence_no();
+		$view->count          = $cycle->get_count();
+		$view->status         = $cycle->get_status()->get_value();
+		$view->starts_at_gmt  = $cycle->get_starts_at_gmt();
+		$view->ends_at_gmt    = $cycle->get_ends_at_gmt();
+		$view->expected_total = $cycle->get_expected_total();
+		$view->currency       = $cycle->get_currency();
+		$view->order_id       = $cycle->get_order_id();
+
+		return $view;
+	}
+
+	/**
+	 * Cycle id.
+	 */
+	public function get_id(): int {
+		return $this->id;
+	}
+
+	/**
+	 * Contract id.
+	 */
+	public function get_contract_id(): int {
+		return $this->contract_id;
+	}
+
+	/**
+	 * Chain kind (e.g. `billing`).
+	 */
+	public function get_kind(): string {
+		return $this->kind;
+	}
+
+	/**
+	 * Position in the chain.
+	 */
+	public function get_sequence_no(): int {
+		return $this->sequence_no;
+	}
+
+	/**
+	 * Charge count, or null for a non-counting cycle.
+	 */
+	public function get_count(): ?int {
+		return $this->count;
+	}
+
+	/**
+	 * Cycle status slug.
+	 */
+	public function get_status(): string {
+		return $this->status;
+	}
+
+	/**
+	 * Period start (GMT `Y-m-d H:i:s`).
+	 */
+	public function get_starts_at_gmt(): string {
+		return $this->starts_at_gmt;
+	}
+
+	/**
+	 * Period end (GMT `Y-m-d H:i:s`).
+	 */
+	public function get_ends_at_gmt(): string {
+		return $this->ends_at_gmt;
+	}
+
+	/**
+	 * Expected total (decimal string).
+	 */
+	public function get_expected_total(): string {
+		return $this->expected_total;
+	}
+
+	/**
+	 * ISO-4217 currency code.
+	 */
+	public function get_currency(): string {
+		return $this->currency;
+	}
+
+	/**
+	 * Linked order id, or null.
+	 */
+	public function get_order_id(): ?int {
+		return $this->order_id;
+	}
+}
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 aca53862b66..69680920ab5 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
@@ -13,10 +13,10 @@
  *
  * 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}.
+ * `origin_order_id` is an optional extension fact. Customer, currency, selling plan and
+ * start are optional until the extension supplies them; a new contract defaults to
+ * `draft`. 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
  */
@@ -27,7 +27,7 @@ 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\Support\Coercion;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\InstrumentRef;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;

@@ -47,6 +47,20 @@ final class Contract {
 	public const ADDRESS_BILLING  = 'billing';
 	public const ADDRESS_SHIPPING = 'shipping';

+	/**
+	 * Fields of a line-item row.
+	 *
+	 * @var array<int, string>
+	 */
+	public const ITEM_FIELDS = array( 'item_name', 'item_type', 'product_id', 'variation_id', 'quantity', 'subtotal', 'total', 'taxes' );
+
+	/**
+	 * Fields of an address.
+	 *
+	 * @var array<int, string>
+	 */
+	public const ADDRESS_FIELDS = array( 'first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'email', 'phone' );
+
 	/**
 	 * Contract id, or null before it is persisted.
 	 *
@@ -62,29 +76,28 @@ final class Contract {
 	private $status;

 	/**
-	 * Owning customer id.
+	 * Owning customer id, or null.
 	 *
-	 * @var int
+	 * @var int|null
 	 */
 	private $customer_id;

 	/**
-	 * ISO-4217 currency code, locked at creation.
+	 * ISO-4217 currency code, or null.
 	 *
-	 * @var string
+	 * @var string|null
 	 */
 	private $currency;

 	/**
-	 * Foreign key to the selling plan.
+	 * Selling plan id, or null.
 	 *
-	 * @var int
+	 * @var int|null
 	 */
 	private $selling_plan_id;

 	/**
-	 * Origin order id, or null for a manual contract. Equals cycle 1's `order_id`
-	 * for a checkout contract.
+	 * Origin order id, or null when the extension recorded none.
 	 *
 	 * @var int|null
 	 */
@@ -119,9 +132,9 @@ final class Contract {
 	private $payment_token_id;

 	/**
-	 * When the contract goes (or went) active. GMT string.
+	 * When the contract goes (or went) active, or null. GMT string.
 	 *
-	 * @var string
+	 * @var string|null
 	 */
 	private $start_gmt;

@@ -234,13 +247,6 @@ final class Contract {
 	 */
 	private $addresses;

-	/**
-	 * Contract meta as key => value.
-	 *
-	 * @var array<string, string>
-	 */
-	private $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.
@@ -248,37 +254,36 @@ final class Contract {
 	 * @param array<string, mixed> $data Raw attributes keyed by property name.
 	 */
 	private function __construct( array $data ) {
-		$this->id                   = ScalarCoercion::coerce_nullable_int( $data['id'] ?? null );
-		$this->status               = ScalarCoercion::coerce_string( $data['status'] ?? null, ContractStatus::ACTIVE );
-		$this->customer_id          = ScalarCoercion::coerce_int( $data['customer_id'] ?? null );
-		$this->currency             = ScalarCoercion::coerce_string( $data['currency'] ?? null );
-		$this->selling_plan_id      = ScalarCoercion::coerce_int( $data['selling_plan_id'] ?? null );
-		$this->origin_order_id      = ScalarCoercion::coerce_nullable_int( $data['origin_order_id'] ?? null );
-		$this->extension_slug       = ScalarCoercion::coerce_nullable_string( $data['extension_slug'] ?? null );
-		$this->payment_method       = ScalarCoercion::coerce_nullable_string( $data['payment_method'] ?? null );
-		$this->payment_method_title = ScalarCoercion::coerce_nullable_string( $data['payment_method_title'] ?? null );
-		$this->payment_token_id     = ScalarCoercion::coerce_nullable_int( $data['payment_token_id'] ?? null );
-		$this->start_gmt            = ScalarCoercion::coerce_string( $data['start_gmt'] ?? null );
-		$this->next_payment_gmt     = ScalarCoercion::coerce_nullable_string( $data['next_payment_gmt'] ?? null );
-		$this->plan_snapshot_id     = ScalarCoercion::coerce_nullable_int( $data['plan_snapshot_id'] ?? null );
-		$this->items_snapshot_id    = ScalarCoercion::coerce_nullable_int( $data['items_snapshot_id'] ?? null );
+		$this->id                   = Coercion::coerce_nullable_int( $data['id'] ?? null );
+		$this->status               = Coercion::coerce_string( $data['status'] ?? null, ContractStatus::DRAFT );
+		$this->customer_id          = Coercion::coerce_nullable_int( $data['customer_id'] ?? null );
+		$this->currency             = Coercion::coerce_nullable_string( $data['currency'] ?? null );
+		$this->selling_plan_id      = Coercion::coerce_nullable_int( $data['selling_plan_id'] ?? null );
+		$this->origin_order_id      = Coercion::coerce_nullable_int( $data['origin_order_id'] ?? null );
+		$this->extension_slug       = Coercion::coerce_nullable_string( $data['extension_slug'] ?? null );
+		$this->payment_method       = Coercion::coerce_nullable_string( $data['payment_method'] ?? null );
+		$this->payment_method_title = Coercion::coerce_nullable_string( $data['payment_method_title'] ?? null );
+		$this->payment_token_id     = Coercion::coerce_nullable_int( $data['payment_token_id'] ?? null );
+		$this->start_gmt            = Coercion::coerce_nullable_string( $data['start_gmt'] ?? null );
+		$this->next_payment_gmt     = Coercion::coerce_nullable_string( $data['next_payment_gmt'] ?? null );
+		$this->plan_snapshot_id     = Coercion::coerce_nullable_int( $data['plan_snapshot_id'] ?? null );
+		$this->items_snapshot_id    = Coercion::coerce_nullable_int( $data['items_snapshot_id'] ?? null );
 		$this->billing_total        = MoneyScale::normalize_money( $data['billing_total'] ?? '0' );
 		$this->discount_total       = MoneyScale::normalize_money( $data['discount_total'] ?? '0' );
 		$this->shipping_total       = MoneyScale::normalize_money( $data['shipping_total'] ?? '0' );
 		$this->tax_total            = MoneyScale::normalize_money( $data['tax_total'] ?? '0' );
-		$this->last_payment_gmt     = ScalarCoercion::coerce_nullable_string( $data['last_payment_gmt'] ?? null );
-		$this->last_attempt_gmt     = ScalarCoercion::coerce_nullable_string( $data['last_attempt_gmt'] ?? null );
-		$this->trial_end_gmt        = ScalarCoercion::coerce_nullable_string( $data['trial_end_gmt'] ?? null );
-		$this->end_gmt              = ScalarCoercion::coerce_nullable_string( $data['end_gmt'] ?? null );
-		$this->schedule_source      = ScalarCoercion::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 );
+		$this->last_payment_gmt     = Coercion::coerce_nullable_string( $data['last_payment_gmt'] ?? null );
+		$this->last_attempt_gmt     = Coercion::coerce_nullable_string( $data['last_attempt_gmt'] ?? null );
+		$this->trial_end_gmt        = Coercion::coerce_nullable_string( $data['trial_end_gmt'] ?? null );
+		$this->end_gmt              = Coercion::coerce_nullable_string( $data['end_gmt'] ?? null );
+		$this->schedule_source      = Coercion::coerce_string( $data['schedule_source'] ?? null, self::SCHEDULE_SOURCE_PRIMITIVE );
+		$this->items                = Coercion::coerce_list_of_arrays( $data['items'] ?? null );
+		$this->addresses            = Coercion::coerce_map_of_arrays( $data['addresses'] ?? null );
 		$this->plan_snapshot        = ( $data['plan_snapshot'] ?? null ) instanceof PlanSnapshot ? $data['plan_snapshot'] : null;
 	}

 	/**
-	 * Build a new, unsaved contract.
+	 * Build a new, unsaved contract. `extension_slug` is required.
 	 *
 	 * @param array<string, mixed> $args Contract attributes.
 	 * @throws DomainException If the contract attributes are not valid.
@@ -289,15 +294,32 @@ final class Contract {

 		$contract = new self( $args );

-		if ( ! ContractStatus::is_registered( $contract->status ) ) {
-			throw new DomainException( sprintf( 'Contract: invalid status "%s".', $contract->status ) );
-		}
+		self::assert_status( $contract->status );
+		self::assert_extension_slug( $contract->extension_slug );
+		self::assert_schedule_source( $contract->schedule_source );
+		$contract->assert_money_has_currency();

-		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 ) );
+		return $contract;
+	}
+
+	/**
+	 * Refuse money without a currency: a contract with no currency must have every total at zero.
+	 * Cross-field, so callers that change several fields check it once after the last change.
+	 * Checked against the state the caller holds: the engine opens no transaction or lock, so
+	 * concurrent writers to the currency and totals of one contract coordinate themselves.
+	 *
+	 * @throws DomainException If a total is non-zero while the currency is unset.
+	 */
+	public function assert_money_has_currency(): void {
+		if ( null !== $this->currency ) {
+			return;
 		}

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

 	/**
@@ -305,22 +327,20 @@ final class Contract {
 	 *
 	 * The frozen plan terms ride second, ahead of the child rows: a contract without
 	 * its plan is pretty pointless, so the snapshot is hydrated on the same footing as
-	 * items / addresses / meta rather than through a separate mutation step.
+	 * items / addresses rather than through a separate mutation step.
 	 *
 	 * @param array<string, mixed>                $row           Contract row.
 	 * @param PlanSnapshot|null                   $plan_snapshot Frozen plan terms for the row's `plan_snapshot_id`, or null.
 	 * @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.
 	 */
-	public static function from_storage( array $row, ?PlanSnapshot $plan_snapshot = null, array $items = array(), array $addresses = array(), array $meta = array() ): self {
+	public static function from_storage( array $row, ?PlanSnapshot $plan_snapshot = null, array $items = array(), array $addresses = array() ): self {
 		$contract = new self(
 			array_merge(
 				$row,
 				array(
 					'items'     => $items,
 					'addresses' => $addresses,
-					'meta'      => $meta,
 				)
 			)
 		);
@@ -370,34 +390,59 @@ final class Contract {
 			return;
 		}

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

 		$this->status = $status;
 	}

 	/**
-	 * Owning customer id.
+	 * Owning customer id, or null.
 	 */
-	public function get_customer_id(): int {
+	public function get_customer_id(): ?int {
 		return $this->customer_id;
 	}

 	/**
-	 * ISO-4217 currency code.
+	 * Set the owning customer id.
+	 *
+	 * @param int|null $customer_id Customer id, or null.
+	 */
+	public function set_customer_id( ?int $customer_id ): void {
+		$this->customer_id = $customer_id;
+	}
+
+	/**
+	 * ISO-4217 currency code, or null.
 	 */
-	public function get_currency(): string {
+	public function get_currency(): ?string {
 		return $this->currency;
 	}

 	/**
-	 * Foreign key to the selling plan.
+	 * Set the ISO-4217 currency code.
+	 *
+	 * @param string|null $currency Currency code, or null.
+	 */
+	public function set_currency( ?string $currency ): void {
+		$this->currency = $currency;
+	}
+
+	/**
+	 * Selling plan id, or null.
 	 */
-	public function get_selling_plan_id(): int {
+	public function get_selling_plan_id(): ?int {
 		return $this->selling_plan_id;
 	}

+	/**
+	 * Set the selling plan id.
+	 *
+	 * @param int|null $selling_plan_id Selling plan id, or null.
+	 */
+	public function set_selling_plan_id( ?int $selling_plan_id ): void {
+		$this->selling_plan_id = $selling_plan_id;
+	}
+
 	/**
 	 * Foreign key to the origin order, or null for a manual/admin contract.
 	 */
@@ -405,6 +450,15 @@ final class Contract {
 		return $this->origin_order_id;
 	}

+	/**
+	 * Set the origin order id.
+	 *
+	 * @param int|null $origin_order_id Order id, or null.
+	 */
+	public function set_origin_order_id( ?int $origin_order_id ): void {
+		$this->origin_order_id = $origin_order_id;
+	}
+
 	/**
 	 * Owning extension slug, or null.
 	 */
@@ -625,12 +679,21 @@ final class Contract {
 	}

 	/**
-	 * Start timestamp (GMT string).
+	 * Start timestamp (GMT string), or null.
 	 */
-	public function get_start_gmt(): string {
+	public function get_start_gmt(): ?string {
 		return $this->start_gmt;
 	}

+	/**
+	 * Set the start timestamp.
+	 *
+	 * @param string|null $start_gmt GMT string or null.
+	 */
+	public function set_start_gmt( ?string $start_gmt ): void {
+		$this->start_gmt = $start_gmt;
+	}
+
 	/**
 	 * Who runs renewals: 'primitive' or 'gateway'.
 	 */
@@ -638,6 +701,18 @@ final class Contract {
 		return $this->schedule_source;
 	}

+	/**
+	 * Set who runs renewals.
+	 *
+	 * @param string $schedule_source 'primitive' or 'gateway'.
+	 * @throws DomainException If `$schedule_source` is neither.
+	 */
+	public function set_schedule_source( string $schedule_source ): void {
+		self::assert_schedule_source( $schedule_source );
+
+		$this->schedule_source = $schedule_source;
+	}
+
 	/**
 	 * Line items.
 	 *
@@ -648,39 +723,30 @@ final class Contract {
 	}

 	/**
-	 * Addresses keyed by type.
+	 * Replace the line items.
 	 *
-	 * @return array<string, array<string, mixed>>
+	 * @param array<int|string, mixed> $items Item rows; non-array elements are skipped.
 	 */
-	public function get_addresses(): array {
-		return $this->addresses;
+	public function set_items( array $items ): void {
+		$this->items = Coercion::coerce_list_of_arrays( $items );
 	}

 	/**
-	 * Contract meta as key => value.
+	 * Addresses keyed by type.
 	 *
-	 * @return array<string, string>
+	 * @return array<string, array<string, mixed>>
 	 */
-	public function get_meta(): array {
-		return $this->meta;
+	public function get_addresses(): array {
+		return $this->addresses;
 	}

 	/**
-	 * Set or remove one meta entry.
+	 * Replace the addresses.
 	 *
-	 * Meta is opaque key/value data; the repository's existing child sync
-	 * persists the map on save.
-	 *
-	 * @param string      $key   Meta key.
-	 * @param string|null $value Meta value, or null to remove the key.
+	 * @param array<int|string, mixed> $addresses Address rows keyed by type; non-array elements are skipped.
 	 */
-	public function set_meta( string $key, ?string $value ): void {
-		if ( null === $value ) {
-			unset( $this->meta[ $key ] );
-			return;
-		}
-
-		$this->meta[ $key ] = $value;
+	public function set_addresses( array $addresses ): void {
+		$this->addresses = Coercion::coerce_map_of_arrays( $addresses );
 	}

 	/**
@@ -716,82 +782,38 @@ final class Contract {
 	}

 	/**
-	 * 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.
+	 * Refuse a status that is not a registered contract status.
 	 *
-	 * @param mixed $value Caller-supplied addresses.
-	 * @return array<string, array<string, mixed>>
+	 * @param string $status Status to check.
+	 * @throws DomainException If `$status` is not registered.
 	 */
-	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 );
-			}
+	private static function assert_status( string $status ): void {
+		if ( ! ContractStatus::is_registered( $status ) ) {
+			throw new DomainException( sprintf( 'Contract: status "%s" is not registered.', $status ) );
 		}
-
-		return $map;
 	}

 	/**
-	 * Shape a caller-supplied value into the meta map (string => string). A non-array
-	 * yields an empty map.
+	 * Refuse a missing or empty owning extension slug.
 	 *
-	 * @param mixed $value Caller-supplied meta.
-	 * @return array<string, string>
+	 * @param string|null $extension_slug Extension slug to check.
+	 * @throws DomainException If `$extension_slug` is null or empty.
 	 */
-	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 ] = ScalarCoercion::coerce_string( $meta_value );
+	private static function assert_extension_slug( ?string $extension_slug ): void {
+		if ( null === $extension_slug || '' === $extension_slug ) {
+			throw new DomainException( 'Contract: extension_slug is required and must be a non-empty string.' );
 		}
-
-		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.
+	 * Refuse a schedule source other than 'primitive' or 'gateway'.
 	 *
-	 * @param array<int|string, mixed> $value Array to re-key.
-	 * @return array<string, mixed>
+	 * @param string $schedule_source Schedule source to check.
+	 * @throws DomainException If `$schedule_source` is neither.
 	 */
-	private static function coerce_string_keyed( array $value ): array {
-		$result = array();
-		foreach ( $value as $key => $entry ) {
-			$result[ (string) $key ] = $entry;
+	private static function assert_schedule_source( string $schedule_source ): void {
+		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 ) );
 		}
-
-		return $result;
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/ContractStatus.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/ContractStatus.php
index 1bb71a5430c..d4cdedc7d91 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/ContractStatus.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/ContractStatus.php
@@ -23,6 +23,7 @@ defined( 'ABSPATH' ) || exit;
  */
 final class ContractStatus {

+	public const DRAFT                = 'draft';
 	public const ACTIVE               = 'active';
 	public const ON_HOLD              = 'on-hold';
 	public const PENDING_CANCELLATION = 'pending-cancellation';
@@ -36,6 +37,7 @@ final class ContractStatus {
 	 */
 	public static function get_defaults(): array {
 		return array(
+			self::DRAFT,
 			self::ACTIVE,
 			self::ON_HOLD,
 			self::PENDING_CANCELLATION,
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
index b43a0ef51ce..9f1f3417e2b 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
@@ -17,8 +17,9 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;

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

@@ -49,9 +50,9 @@ final class Cycle {
 	private $contract_id;

 	/**
-	 * Position within the chain, monotonic from 1.
+	 * Position within the chain, monotonic from 1, or null until the append assigns it.
 	 *
-	 * @var int
+	 * @var int|null
 	 */
 	private $sequence_no;

@@ -179,23 +180,23 @@ final class Cycle {
 	 * @param array<string, mixed> $data Raw attributes keyed by property name.
 	 */
 	private function __construct( array $data ) {
-		$this->id                = ScalarCoercion::coerce_nullable_int( $data['id'] ?? null );
-		$this->contract_id       = ScalarCoercion::coerce_int( $data['contract_id'] ?? null );
-		$this->sequence_no       = ScalarCoercion::coerce_int( $data['sequence_no'] ?? null );
-		$this->count             = isset( $data['count'] ) ? ScalarCoercion::coerce_int( $data['count'] ) : null;
-		$this->kind              = ScalarCoercion::coerce_string( $data['kind'] ?? null, self::KIND_BILLING );
+		$this->id                = Coercion::coerce_nullable_int( $data['id'] ?? null );
+		$this->contract_id       = Coercion::coerce_int( $data['contract_id'] ?? null );
+		$this->sequence_no       = isset( $data['sequence_no'] ) ? Coercion::coerce_int( $data['sequence_no'] ) : null;
+		$this->count             = isset( $data['count'] ) ? Coercion::coerce_int( $data['count'] ) : null;
+		$this->kind              = Coercion::coerce_string( $data['kind'] ?? null, self::KIND_BILLING );
 		$this->status            = self::coerce_status( $data['status'] ?? null );
-		$this->reason            = ScalarCoercion::coerce_nullable_string( $data['reason'] ?? null );
-		$this->starts_at_gmt     = ScalarCoercion::coerce_string( $data['starts_at_gmt'] ?? null );
-		$this->ends_at_gmt       = ScalarCoercion::coerce_string( $data['ends_at_gmt'] ?? null );
+		$this->reason            = Coercion::coerce_nullable_string( $data['reason'] ?? null );
+		$this->starts_at_gmt     = Coercion::coerce_string( $data['starts_at_gmt'] ?? null );
+		$this->ends_at_gmt       = Coercion::coerce_string( $data['ends_at_gmt'] ?? null );
 		$this->expected_total    = MoneyScale::normalize_money( $data['expected_total'] ?? '0' );
-		$this->currency          = ScalarCoercion::coerce_string( $data['currency'] ?? null );
-		$this->plan_snapshot_id  = ScalarCoercion::coerce_nullable_int( $data['plan_snapshot_id'] ?? null );
-		$this->items_snapshot_id = ScalarCoercion::coerce_nullable_int( $data['items_snapshot_id'] ?? null );
-		$this->order_id          = ScalarCoercion::coerce_nullable_int( $data['order_id'] ?? null );
-		$this->extension_slug    = ScalarCoercion::coerce_nullable_string( $data['extension_slug'] ?? null );
-		$this->claimed_until_gmt = ScalarCoercion::coerce_nullable_string( $data['claimed_until'] ?? null );
-		$this->retry_at_gmt      = ScalarCoercion::coerce_nullable_string( $data['retry_at'] ?? null );
+		$this->currency          = Coercion::coerce_string( $data['currency'] ?? null );
+		$this->plan_snapshot_id  = Coercion::coerce_nullable_int( $data['plan_snapshot_id'] ?? null );
+		$this->items_snapshot_id = Coercion::coerce_nullable_int( $data['items_snapshot_id'] ?? null );
+		$this->order_id          = Coercion::coerce_nullable_int( $data['order_id'] ?? null );
+		$this->extension_slug    = Coercion::coerce_nullable_string( $data['extension_slug'] ?? null );
+		$this->claimed_until_gmt = Coercion::coerce_nullable_string( $data['claimed_until'] ?? null );
+		$this->retry_at_gmt      = Coercion::coerce_nullable_string( $data['retry_at'] ?? null );
 		$this->plan_snapshot     = ( $data['plan_snapshot'] ?? null ) instanceof PlanSnapshot ? $data['plan_snapshot'] : null;
 		$this->items_snapshot    = ( $data['items_snapshot'] ?? null ) instanceof ItemsSnapshot ? $data['items_snapshot'] : null;
 	}
@@ -203,10 +204,10 @@ final class Cycle {
 	/**
 	 * 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`,
+	 * Required keys: `contract_id`, `starts_at_gmt`, `ends_at_gmt`, `expected_total`,
+	 * `currency`. Optional: `sequence_no` (absent or null: the append assigns the chain's
+	 * next position), `count` (absent or null for a non-counting cycle; the caller owns
+	 * the numbering), `status` (defaults to `pending`), `kind` (defaults to billing), `reason`,
 	 * `order_id`, `extension_slug`, `plan_snapshot_id`, `items_snapshot_id`,
 	 * `plan_snapshot`, `items_snapshot`.
 	 *
@@ -214,21 +215,21 @@ final class Cycle {
 	 * @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 );
-		self::assert_registered_status( $cycle->status );
+		self::assert_contract_id( $cycle->contract_id );
+		self::assert_kind( $cycle->kind );
+		// Null is allowed here for auto-assign sequence_no during append operation.
+		if ( null !== $cycle->sequence_no ) {
+			self::assert_sequence_no( $cycle->sequence_no );
+		}
+		self::assert_period( $cycle->starts_at_gmt, $cycle->ends_at_gmt );
+		self::assert_currency( $cycle->currency );
+		self::assert_count( $cycle->count );
+		self::assert_status( $cycle->status );

 		return $cycle;
 	}
@@ -236,26 +237,16 @@ final class Cycle {
 	/**
 	 * Hydrate from a stored row.
 	 *
-	 * A well-formed stored status is kept verbatim, registered or not (registration is
-	 * checked only where a status is written), so a value written by a since-deactivated
-	 * extension round-trips unchanged.
+	 * Stored values are not validated (invariants are checked only where a value is
+	 * written), so a value written by a since-deactivated extension round-trips unchanged.
 	 *
 	 * @param array<string, mixed> $row Cycle row.
-	 * @throws DomainException If the stored kind or sequence_no is invalid, or the stored
-	 *                         status is not a well-formed status slug.
+	 * @throws DomainException If the stored status is not a well-formed status slug.
 	 */
 	public static function from_storage( array $row ): self {
-		$kind = ScalarCoercion::coerce_string( $row['kind'] ?? null, self::KIND_BILLING );
-		self::assert_valid_kind( $kind );
-
-		$sequence_no = ScalarCoercion::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 );
 	}

@@ -283,30 +274,40 @@ final class Cycle {
 	}

 	/**
-	 * 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.
+	 * Position within the chain.
 	 *
-	 * @param int $contract_id Owning contract id.
+	 * @throws LogicException If the position is not assigned yet (a new cycle before its append).
 	 */
-	public function set_contract_id( int $contract_id ): void {
-		$this->contract_id = $contract_id;
+	public function get_sequence_no(): int {
+		if ( null === $this->sequence_no ) {
+			throw new LogicException( 'Cycle: sequence_no is assigned on append.' );
+		}
+
+		return $this->sequence_no;
 	}

 	/**
-	 * Position within the chain.
+	 * Whether the append still has to assign the position within the chain.
+	 *
+	 * @internal Used by the repository's append.
 	 */
-	public function get_sequence_no(): int {
-		return $this->sequence_no;
+	public function awaits_sequence_no(): bool {
+		return null === $this->sequence_no;
 	}

 	/**
-	 * Set the position within the chain (assigned by the append path).
+	 * Assign the position within the chain. Only while it is unassigned.
+	 *
+	 * @internal Used by the repository's append.
 	 *
 	 * @param int $sequence_no Position, monotonic from 1.
-	 * @throws DomainException If `$sequence_no` is not positive.
+	 * @throws DomainException If the position is already assigned or `$sequence_no` is not positive.
 	 */
-	public function set_sequence_no( int $sequence_no ): void {
-		self::assert_valid_sequence_no( $sequence_no );
+	public function assign_sequence_no( int $sequence_no ): void {
+		if ( null !== $this->sequence_no ) {
+			throw new DomainException( 'Cycle: sequence_no is already assigned.' );
+		}
+		self::assert_sequence_no( $sequence_no );

 		$this->sequence_no = $sequence_no;
 	}
@@ -348,7 +349,7 @@ final class Cycle {
 			return;
 		}

-		self::assert_registered_status( $status );
+		self::assert_status( $status );

 		$this->status = $status;
 	}
@@ -577,6 +578,18 @@ final class Cycle {
 		}
 	}

+	/**
+	 * Refuse a missing (non-positive) owning contract id.
+	 *
+	 * @param int $contract_id Contract id to check.
+	 * @throws DomainException If `$contract_id` is not positive.
+	 */
+	private static function assert_contract_id( int $contract_id ): void {
+		if ( $contract_id < 1 ) {
+			throw new DomainException( 'Cycle: contract_id is required.' );
+		}
+	}
+
 	/**
 	 * 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.
@@ -584,19 +597,44 @@ final class Cycle {
 	 * @param string $kind Kind to validate.
 	 * @throws DomainException If `$kind` is empty.
 	 */
-	private static function assert_valid_kind( string $kind ): void {
+	private static function assert_kind( string $kind ): void {
 		if ( '' === $kind ) {
 			throw new DomainException( 'Cycle: kind must not be empty.' );
 		}
 	}

+	/**
+	 * Refuse a cycle without a start or end moment.
+	 *
+	 * @param string $starts_at_gmt Start (GMT).
+	 * @param string $ends_at_gmt   End (GMT).
+	 * @throws DomainException If either moment is missing.
+	 */
+	private static function assert_period( string $starts_at_gmt, string $ends_at_gmt ): void {
+		if ( '' === $starts_at_gmt || '' === $ends_at_gmt ) {
+			throw new DomainException( 'Cycle: starts_at_gmt and ends_at_gmt are required.' );
+		}
+	}
+
+	/**
+	 * Refuse a cycle without a currency.
+	 *
+	 * @param string $currency Currency code.
+	 * @throws DomainException If the currency is missing.
+	 */
+	private static function assert_currency( string $currency ): void {
+		if ( '' === $currency ) {
+			throw new DomainException( 'Cycle: currency is required.' );
+		}
+	}
+
 	/**
 	 * 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 {
+	private static function assert_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 )
@@ -612,7 +650,7 @@ final class Cycle {
 	 * @param CycleStatus $status Status to check.
 	 * @throws DomainException If the status is not registered.
 	 */
-	private static function assert_registered_status( CycleStatus $status ): void {
+	private static function assert_status( CycleStatus $status ): void {
 		if ( ! CycleStatus::is_registered( $status->get_value() ) ) {
 			throw new DomainException(
 				sprintf( 'Cycle: status "%s" is not registered.', $status->get_value() )
@@ -639,31 +677,20 @@ final class Cycle {
 			return new CycleStatus( CycleStatus::PENDING );
 		}

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

 	/**
-	 * Normalize and validate a chargeable count.
-	 *
-	 * Null passes through (a non-counting cycle); a present value must be a
-	 * positive integer.
+	 * Validate a chargeable count: null (a non-counting cycle) or a positive integer.
 	 *
-	 * @param mixed $count Raw count value (null, or coercible to int).
-	 * @return int|null
+	 * @param int|null $count Count to validate.
 	 * @throws DomainException If a present count is not positive.
 	 */
-	private static function normalize_count( $count ): ?int {
-		if ( null === $count ) {
-			return null;
-		}
-
-		$count = ScalarCoercion::coerce_int( $count );
-		if ( $count < 1 ) {
+	private static function assert_count( ?int $count ): void {
+		if ( null !== $count && $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/Plan.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
index a27b58456ef..c71a6926a51 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
@@ -13,7 +13,7 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
 use InvalidArgumentException;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\DeliveryPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;

 defined( 'ABSPATH' ) || exit;

@@ -177,16 +177,16 @@ final class Plan {

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

@@ -203,17 +203,17 @@ final class Plan {
 		$pricing_policy = self::assert_object_or_null( $row['pricing_policy'] ?? null );

 		return new self(
-			isset( $row['id'] ) ? ScalarCoercion::coerce_int( $row['id'] ) : null,
-			ScalarCoercion::coerce_string( $row['name'] ?? null ),
-			ScalarCoercion::coerce_nullable_string( $row['description'] ?? null ),
+			isset( $row['id'] ) ? Coercion::coerce_int( $row['id'] ) : null,
+			Coercion::coerce_string( $row['name'] ?? null ),
+			Coercion::coerce_nullable_string( $row['description'] ?? null ),
 			BillingPolicy::from_array( is_array( $row['billing_policy'] ?? null ) ? $row['billing_policy'] : array() ),
 			isset( $row['delivery_policy'] ) && is_array( $row['delivery_policy'] ) ? DeliveryPolicy::from_array( $row['delivery_policy'] ) : null,
 			$pricing_policy,
-			ScalarCoercion::coerce_string( $row['category'] ?? null, self::DEFAULT_CATEGORY ),
-			ScalarCoercion::coerce_string( $row['status'] ?? null, self::DEFAULT_STATUS ),
-			ScalarCoercion::coerce_int( $row['sort_order'] ?? null, 0 ),
-			ScalarCoercion::coerce_nullable_string( $row['merchant_code'] ?? null ),
-			ScalarCoercion::coerce_nullable_string( $row['extension_slug'] ?? null )
+			Coercion::coerce_string( $row['category'] ?? null, self::DEFAULT_CATEGORY ),
+			Coercion::coerce_string( $row['status'] ?? null, self::DEFAULT_STATUS ),
+			Coercion::coerce_int( $row['sort_order'] ?? null, 0 ),
+			Coercion::coerce_nullable_string( $row['merchant_code'] ?? null ),
+			Coercion::coerce_nullable_string( $row['extension_slug'] ?? null )
 		);
 	}

diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ScalarCoercion.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/Coercion.php
similarity index 60%
rename from packages/php/woocommerce-subscriptions-engine/src/Core/Support/ScalarCoercion.php
rename to packages/php/woocommerce-subscriptions-engine/src/Core/Support/Coercion.php
index 107ca7a8b1f..574d60974a4 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ScalarCoercion.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/Coercion.php
@@ -1,9 +1,8 @@
 <?php
 /**
- * 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.
+ * Coercion - shared helpers coercing untyped (mixed) values from storage rows or
+ * argument maps into declared scalar and array shapes. Each guards before casting
+ * and falls back when the value is not coercible. WordPress-free Core zone.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Support
  */
@@ -15,11 +14,11 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Support;
 defined( 'ABSPATH' ) || exit;

 /**
- * Scalar coercion helpers for hydration boundaries.
+ * Scalar and array coercion helpers for hydration and argument boundaries.
  *
  * @internal Engine implementation detail. Not part of the supported extension API.
  */
-final class ScalarCoercion {
+final class Coercion {

 	/**
 	 * Static helper only.
@@ -99,4 +98,67 @@ final class ScalarCoercion {
 	public static function coerce_float( $value, float $fallback = 0.0 ): float {
 		return is_numeric( $value ) ? (float) $value : $fallback;
 	}
+
+	/**
+	 * Re-key an array as string-keyed, recovering `array<string, mixed>` from an
+	 * `int|string`-keyed array.
+	 *
+	 * @param array<int|string, mixed> $value Array to re-key.
+	 * @return array<string, mixed>
+	 * @internal Engine implementation detail. Not part of the supported extension API.
+	 */
+	public static function coerce_string_keyed( array $value ): array {
+		$result = array();
+		foreach ( $value as $key => $entry ) {
+			$result[ (string) $key ] = $entry;
+		}
+
+		return $result;
+	}
+
+	/**
+	 * Coerce a value to a list of string-keyed rows. A non-array yields an empty
+	 * list; non-array rows are skipped.
+	 *
+	 * @param mixed $value The raw value.
+	 * @return array<int, array<string, mixed>>
+	 * @internal Engine implementation detail. Not part of the supported extension API.
+	 */
+	public static function coerce_list_of_arrays( $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;
+	}
+
+	/**
+	 * Coerce a value to a string-keyed map of string-keyed entries. A non-array
+	 * yields an empty map; non-array entries are skipped.
+	 *
+	 * @param mixed $value The raw value.
+	 * @return array<string, array<string, mixed>>
+	 * @internal Engine implementation detail. Not part of the supported extension API.
+	 */
+	public static function coerce_map_of_arrays( $value ): array {
+		if ( ! is_array( $value ) ) {
+			return array();
+		}
+
+		$map = array();
+		foreach ( $value as $key => $entry ) {
+			if ( is_array( $entry ) ) {
+				$map[ (string) $key ] = self::coerce_string_keyed( $entry );
+			}
+		}
+
+		return $map;
+	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/MoneyScale.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/MoneyScale.php
index 234d7612ce9..d9c0a890a57 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/MoneyScale.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/MoneyScale.php
@@ -42,6 +42,6 @@ final class MoneyScale {
 	 * @internal Engine implementation detail. Not part of the supported extension API.
 	 */
 	public static function normalize_money( $value ): string {
-		return number_format( ScalarCoercion::coerce_float( $value ?? '0' ), 8, '.', '' );
+		return number_format( Coercion::coerce_float( $value ?? '0' ), 8, '.', '' );
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
index 130bbd226e5..fe7df76f64c 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
@@ -24,7 +24,7 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
 use DateTimeImmutable;
 use DateTimeZone;
 use DomainException;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;

 defined( 'ABSPATH' ) || exit;

@@ -124,8 +124,8 @@ final class BillingPolicy {
 		return new self(
 			(string) $data['period'],
 			(int) $data['interval'],
-			ScalarCoercion::coerce_nullable_int( $data['min_cycles'] ?? null ),
-			ScalarCoercion::coerce_nullable_int( $data['max_cycles'] ?? null ),
+			Coercion::coerce_nullable_int( $data['min_cycles'] ?? null ),
+			Coercion::coerce_nullable_int( $data['max_cycles'] ?? null ),
 			$trial
 		);
 	}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/OrderRef.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/OrderRef.php
deleted file mode 100644
index ebd96918c47..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/OrderRef.php
+++ /dev/null
@@ -1,59 +0,0 @@
-<?php
-/**
- * OrderRef - an immutable reference to a WooCommerce order by id.
- *
- * The Core zone never loads a live order object; it holds a reference and
- * commands effects through the Orders host binding in the integration layer.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * OrderRef value object.
- *
- * Immutable identity wrapper.
- */
-final class OrderRef {
-
-	/**
-	 * Order id.
-	 *
-	 * @var int
-	 */
-	private $id;
-
-	/**
-	 * Build an order reference.
-	 *
-	 * @param int $id Order id.
-	 * @throws \InvalidArgumentException If the order id is not greater than 0.
-	 */
-	public function __construct( int $id ) {
-		if ( $id <= 0 ) {
-			throw new \InvalidArgumentException( 'Order id must be greater than 0.' );
-		}
-		$this->id = $id;
-	}
-
-	/**
-	 * The referenced order id.
-	 */
-	public function get_id(): int {
-		return $this->id;
-	}
-
-	/**
-	 * Value equality by id.
-	 *
-	 * @param OrderRef $other Reference to compare against.
-	 */
-	public function equals( OrderRef $other ): bool {
-		return $this->id === $other->id;
-	}
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
index 011d72467b7..020b88710ef 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
@@ -17,7 +17,7 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;

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

 defined( 'ABSPATH' ) || exit;

@@ -97,7 +97,7 @@ final class PlanSnapshot {
 	 * 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'] ) ? ScalarCoercion::coerce_int( $this->data['selling_plan_id'] ) : null;
+		return isset( $this->data['selling_plan_id'] ) ? Coercion::coerce_int( $this->data['selling_plan_id'] ) : null;
 	}

 	/**
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
deleted file mode 100644
index d819f4ed291..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
+++ /dev/null
@@ -1,297 +0,0 @@
-<?php
-/**
- * Builds and persists a {@see Contract} (plus its origin {@see Cycle}) from a paid
- * checkout order, and links order <-> contract in both directions. Renewals need no
- * arming beyond this: the contract's `next_payment_gmt` places it on the due index the
- * batch dispatcher scans.
- *
- * Reads a live `WC_Order`; the order never crosses into Core - only the snapshot
- * values pulled off it do.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout;
-
-use DateTimeImmutable;
-use DateTimeZone;
-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;
-
-/**
- * Order -> contract factory.
- */
-final class ContractFactory {
-
-	/**
-	 * The repository the factory persists through.
-	 *
-	 * @var ContractRepository
-	 */
-	private $contracts;
-
-	/**
-	 * Build a factory that persists through the given repository.
-	 *
-	 * @param ContractRepository|null $contracts Repository to persist through; a
-	 *                                           default instance is created when
-	 *                                           omitted.
-	 */
-	public function __construct( ?ContractRepository $contracts = null ) {
-		$this->contracts = $contracts ?? new ContractRepository();
-	}
-
-	/**
-	 * Build, persist, and link a contract for `$order` on `$plan`.
-	 *
-	 * 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: 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 a write fails.
-	 */
-	public function create_from_order( WC_Order $order, Plan $plan, array $overrides = array() ): Contract {
-		$plan_id = $plan->get_id();
-		if ( null === $plan_id ) {
-			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, 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.' );
-		}
-
-		$paid_date = $order->get_date_paid();
-		$anchor    = null !== $paid_date
-			? new DateTimeImmutable( '@' . $paid_date->getTimestamp() )
-			: 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'] )
-			? ScalarCoercion::coerce_string( $overrides['next_payment_gmt'] )
-			: $plan->get_billing_policy()->compute_first_renewal_from( $anchor )->format( 'Y-m-d H:i:s' );
-
-		$expected_total = isset( $overrides['billing_total'] ) ? ScalarCoercion::coerce_string( $overrides['billing_total'] ) : (string) $order->get_total();
-		$currency       = $order->get_currency();
-
-		$contract_defaults = array(
-			'customer_id'          => $order->get_customer_id(),
-			'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 ),
-			'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(),
-			'items'                => $this->map_items( $order ),
-			'addresses'            => $this->map_addresses( $order ),
-			'meta'                 => array(),
-		);
-
-		$contract = Contract::create( array_merge( $contract_defaults, $overrides ) );
-
-		$origin_cycle = $this->build_origin_cycle( $order, $plan, $period_start, $next_payment, $expected_total, $currency );
-
-		$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'         => new CycleStatus( 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.
-	 *
-	 * `pricing_policy` freezes the plan's pricing payload as is. It is an explicit
-	 * `null` when the plan has none, so a reader can tell "no pricing at signup"
-	 * from a snapshot written before the key existed.
-	 *
-	 * @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(),
-				'pricing_policy'  => $plan->get_pricing_policy(),
-			)
-		);
-	}
-
-	/**
-	 * 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 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.
-	 */
-	private function tag_origin_order( WC_Order $order, int $contract_id ): void {
-		try {
-			$order->update_meta_data( OrderLinkage::META_CONTRACT_ID, (string) $contract_id );
-			$order->update_meta_data( OrderLinkage::META_RELATION_TYPE, OrderLinkage::RELATION_PARENT );
-			$order->save();
-		} catch ( Throwable $e ) {
-			wc_get_logger()->warning(
-				sprintf(
-					'ContractFactory: failed to tag origin order %d for contract %d: %s. The contract is persisted; the order-side link can be rebuilt from the contract row.',
-					$order->get_id(),
-					$contract_id,
-					$e->getMessage()
-				),
-				array(
-					'source'      => 'woocommerce-subscriptions-engine',
-					'contract_id' => $contract_id,
-					'order_id'    => $order->get_id(),
-				)
-			);
-		}
-	}
-
-	/**
-	 * Map the order's line items to the contract item-row shape.
-	 *
-	 * 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>>
-	 */
-	private function map_items( WC_Order $order ): array {
-		$items = array();
-
-		foreach ( $order->get_items() as $item ) {
-			if ( ! $item instanceof WC_Order_Item_Product ) {
-				continue;
-			}
-
-			$items[] = array(
-				'item_name'    => $item->get_name(),
-				'item_type'    => 'line_item',
-				'product_id'   => $item->get_product_id(),
-				'variation_id' => $item->get_variation_id(),
-				'quantity'     => (string) $item->get_quantity(),
-				'subtotal'     => (string) $item->get_subtotal(),
-				'total'        => (string) $item->get_total(),
-				'taxes'        => $item->get_taxes(),
-			);
-		}
-
-		return $items;
-	}
-
-	/**
-	 * Map the order's billing and shipping addresses to the contract shape.
-	 *
-	 * @param WC_Order $order The order to read addresses from.
-	 * @return array<string, array<string, mixed>>
-	 */
-	private function map_addresses( WC_Order $order ): array {
-		return array(
-			Contract::ADDRESS_BILLING  => $order->get_address( 'billing' ),
-			Contract::ADDRESS_SHIPPING => $order->get_address( 'shipping' ),
-		);
-	}
-
-	/**
-	 * 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()`); 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.
-	 */
-	private function extract_payment_token_id( WC_Order $order ): ?int {
-		$tokens = $order->get_payment_tokens();
-		if ( ! empty( $tokens ) ) {
-			$id = (int) end( $tokens );
-			if ( $id > 0 ) {
-				return $id;
-			}
-		}
-
-		return null;
-	}
-}
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 9fa91cee1cc..94c975e157d 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php
@@ -1,9 +1,9 @@
 <?php
 /**
- * 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.
+ * Order-side meta keys linking renewal-side orders (renewals, switches, resubscribes)
+ * to contracts, making the relationship queryable from the order side. The origin
+ * order is linked by the contract's `origin_order_id` instead. The engine owns these
+ * keys; consumers read them through this class rather than hard-coding the strings.
  *
  * Written to WooCommerce order meta, which works under both HPOS and the legacy
  * CPT order store.
@@ -15,8 +15,6 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout;

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

 /**
@@ -36,11 +34,6 @@ final class OrderLinkage {
 	 */
 	public const META_RELATION_TYPE = '_subscription_relation_type';

-	/**
-	 * The order whose checkout created the contract (the contract's `origin_order_id`).
-	 */
-	public const RELATION_PARENT = 'parent';
-
 	/**
 	 * A renewal order - created by the renewal engine when a cycle bills.
 	 */
@@ -55,37 +48,4 @@ final class OrderLinkage {
 	 * A resubscribe order - customer restarted a previously-cancelled contract.
 	 */
 	public const RELATION_RESUBSCRIBE = 'resubscribe';
-
-	/**
-	 * All recognized relation types.
-	 *
-	 * @return array<int, string>
-	 */
-	public static function relation_types(): array {
-		return array(
-			self::RELATION_PARENT,
-			self::RELATION_RENEWAL,
-			self::RELATION_SWITCH,
-			self::RELATION_RESUBSCRIBE,
-		);
-	}
-
-	/**
-	 * 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.
-	 */
-	public static function assert_relation( string $relation ): void {
-		if ( ! in_array( $relation, self::relation_types(), true ) ) {
-			throw new InvalidArgumentException(
-				sprintf(
-					'Unknown contract-order relation type: "%s". Expected one of: %s.',
-					esc_html( $relation ),
-					esc_html( implode( ', ', self::relation_types() ) )
-				)
-			);
-		}
-	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/RelatedOrders.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/RelatedOrders.php
index 1f5c4c2bf0e..21f8daaa374 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/RelatedOrders.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/RelatedOrders.php
@@ -1,9 +1,9 @@
 <?php
 /**
- * RelatedOrders - reads the orders linked to a contract from the order side, via the
- * {@see OrderLinkage} meta the engine tags onto every contract-related order (the origin
- * order at checkout, plus renewals / switches / resubscribes). Returns live WC_Order
- * objects newest first; shaping them for presentation is the caller's job.
+ * RelatedOrders - reads the orders related to a contract: the origin order by the
+ * contract's `origin_order_id`, plus the orders tagged with the {@see OrderLinkage} meta
+ * (renewals / switches / resubscribes). Returns live WC_Order objects newest first;
+ * shaping them for presentation is the caller's job.
  *
  * Integration zone: WordPress-native. The flat `meta_key`/`meta_value` lookup round-trips
  * through both the HPOS and legacy order stores.
@@ -16,63 +16,81 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout;

 use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;

 defined( 'ABSPATH' ) || exit;

 /**
- * Order-side read of a contract's related orders.
+ * Read of a contract's related orders.
  */
 final class RelatedOrders {

 	/**
-	 * The orders linked to `$contract_id`, newest first.
+	 * The orders related to `$contract_id`, newest first.
 	 *
-	 * Reads the orders tagged with this contract through the order-side
-	 * {@see OrderLinkage::META_CONTRACT_ID} meta; the contract row carries the reverse
-	 * `origin_order_id`. Returns an empty array when none are linked.
+	 * The origin order is read by the contract's `origin_order_id`; the others through the
+	 * order-side {@see OrderLinkage::META_CONTRACT_ID} meta. An order found both ways appears
+	 * once. Returns an empty array for an unknown contract or when none are related.
 	 *
 	 * The window args exist because a long-running contract accumulates one renewal
-	 * order per period - unbounded reads grow with contract age, so paging consumers
-	 * pass a window. The default stays "all", newest first.
+	 * order per period, so paging consumers pass a window. The default stays "all".
 	 *
 	 * @param int $contract_id Contract id.
 	 * @param int $limit       Maximum orders to return; any negative (default -1) for all, 0 for none.
 	 * @param int $offset      Orders to skip (for paging). Default 0.
-	 * @return array<int, WC_Order> Linked orders, newest first.
+	 * @return array<int, WC_Order> Related orders, newest first.
 	 */
 	public function for_contract( int $contract_id, int $limit = -1, int $offset = 0 ): array {
 		if ( 0 === $limit ) {
 			return array();
 		}

-		// Any other non-positive limit means "all": -1 is the wc_get_orders sentinel,
-		// and an unguarded 0 would fall back to the site's posts-per-page default.
-		$limit = $limit < 0 ? -1 : $limit;
+		$contract = ( new ContractRepository() )->find_summary( $contract_id );
+		if ( null === $contract ) {
+			return array();
+		}

-		$orders = wc_get_orders(
+		$by_id = array();
+
+		$origin_id = $contract->get_origin_order_id();
+		$origin    = null === $origin_id ? false : wc_get_order( $origin_id );
+		if ( $origin instanceof WC_Order ) {
+			$by_id[ $origin->get_id() ] = $origin;
+		}
+
+		// The origin adds at most one entry, so the newest `$offset + $limit` linked orders
+		// always cover the requested page. Order like the merge below: newest first, then id.
+		$linked = wc_get_orders(
 			array(
-				'limit'      => $limit,
-				'offset'     => max( 0, $offset ),
+				'limit'      => $limit < 0 ? -1 : max( 0, $offset ) + $limit,
+				'orderby'    => array(
+					'date' => 'DESC',
+					'ID'   => 'DESC',
+				),
 				'status'     => 'any',
 				'type'       => 'shop_order',
-				'orderby'    => 'date',
-				'order'      => 'DESC',
 				'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
 			)
 		);
-
-		if ( ! is_array( $orders ) ) {
-			return array();
-		}
-
-		$result = array();
-		foreach ( $orders as $order ) {
+		foreach ( is_array( $linked ) ? $linked : array() as $order ) {
 			if ( $order instanceof WC_Order ) {
-				$result[] = $order;
+				$by_id[ $order->get_id() ] = $order;
 			}
 		}

-		return $result;
+		$orders = array_values( $by_id );
+		usort(
+			$orders,
+			static function ( WC_Order $a, WC_Order $b ): int {
+				$a_created = $a->get_date_created();
+				$b_created = $b->get_date_created();
+				$by_date   = ( null === $b_created ? 0 : $b_created->getTimestamp() ) <=> ( null === $a_created ? 0 : $a_created->getTimestamp() );
+
+				return 0 !== $by_date ? $by_date : $b->get_id() <=> $a->get_id();
+			}
+		);
+
+		return array_slice( $orders, max( 0, $offset ), $limit < 0 ? null : $limit );
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
index 6e01b577de1..ad7b0a3723e 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
@@ -66,7 +66,8 @@ final class Cancellation {
 	 * Cancel `$contract`: move it to cancelled, disarm its next-due moment, and close any
 	 * mid-charge cycle.
 	 *
-	 * Only an active, on-hold or pending-cancellation contract can be cancelled; cancelling an
+	 * Only a draft, active, on-hold or pending-cancellation contract can be cancelled (a draft is
+	 * never armed, so there is no due moment to disarm); cancelling an
 	 * already cancelled contract is an idempotent no-op that still succeeds and fires the
 	 * action. Any other status - including one that is not registered - raises a
 	 * `DomainException`. The next-payment date and any hold anchor are cleared so the due scan
@@ -86,15 +87,14 @@ final class Cancellation {
 		}

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

 		if ( ContractStatus::CANCELLED !== $previous ) {
 			$contract->set_status( ContractStatus::CANCELLED );
 			$contract->set_next_payment_gmt( null );
-			$contract->set_meta( Hold::ANCHOR_META_KEY, null );
 		}

 		// Compare-and-set on the status read above: a concurrent transition (another
@@ -104,6 +104,10 @@ final class Cancellation {
 			throw new \DomainException( 'Cancellation::cancel(): the contract state changed concurrently; nothing was written.' );
 		}

+		if ( ContractStatus::CANCELLED !== $previous ) {
+			Hold::clear_anchor( $this->contracts, $id );
+		}
+
 		// Close a charge caught mid-flight: a still-pending head cycle is cancelled so no stale
 		// claim is left open. A settled (billed/failed/cancelled) cycle is left as is.
 		$current = $this->contracts->find_chain_head( $id );
@@ -113,7 +117,7 @@ final class Cancellation {
 		}

 		/**
-		 * Fires after a contract is cancelled.
+		 * Fires after a contract is cancelled. Fires immediately after the write, not after a surrounding transaction commits.
 		 *
 		 * @param Contract $contract The cancelled contract.
 		 */
@@ -163,13 +167,12 @@ final class Cancellation {
 			// up to (not through) it. A held contract's moment lives in the hold anchor.
 			$period_end = $contract->get_next_payment_gmt();
 			if ( null === $period_end && ContractStatus::ON_HOLD === $previous ) {
-				$period_end = Hold::read_anchor( $contract );
+				$period_end = Hold::read_anchor( $this->contracts, $id );
 			}
 			if ( null === $contract->get_end_gmt() && null !== $period_end ) {
 				$contract->set_end_gmt( $period_end );
 			}

-			$contract->set_meta( Hold::ANCHOR_META_KEY, null );
 			$contract->set_next_payment_gmt( null );
 		}

@@ -179,8 +182,13 @@ final class Cancellation {
 			throw new \DomainException( 'Cancellation::cancel_at_period_end(): the contract state changed concurrently; nothing was written.' );
 		}

+		if ( ContractStatus::PENDING_CANCELLATION !== $previous ) {
+			Hold::clear_anchor( $this->contracts, $id );
+		}
+
 		/**
 		 * Fires after a contract is set to wind down at the end of the current period.
+		 * Fires immediately after the write, not after a surrounding transaction commits.
 		 *
 		 * @param Contract $contract The pending-cancellation contract.
 		 */
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php
index 6a9533929c2..786b61dbd98 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php
@@ -106,7 +106,7 @@ final class Hold {
 		}

 		/**
-		 * Fires after a contract is put on hold.
+		 * Fires after a contract is put on hold. Fires immediately after the write, not after a surrounding transaction commits.
 		 *
 		 * @param Contract $contract The held contract.
 		 */
@@ -116,60 +116,90 @@ final class Hold {
 	}

 	/**
-	 * Store the next-due moment as the hold anchor while the contract is still active,
-	 * before the hold disarms it.
+	 * Store the next-due moment as the hold anchor before the hold disarms it.
 	 *
-	 * The repository writes the row and its meta as separate statements (no
-	 * transaction), so the anchor is written and read back first: if it did not
-	 * persist, nothing has been disarmed yet and the hold aborts. A contract with no
-	 * next-due moment stores no anchor (null removes the key). An anchor left behind by
-	 * a hold that then loses its compare-and-set is harmless: the next hold overwrites
-	 * it and cancellation clears it.
+	 * The anchor lives in contract meta, written apart from the row (no transaction),
+	 * so it is written and read back first: if it did not persist, nothing has been
+	 * disarmed yet and the hold aborts. A contract with no next-due moment stores no
+	 * anchor. An anchor left behind by a hold that then loses its compare-and-set is
+	 * harmless: the next hold overwrites it and cancellation clears it.
 	 *
 	 * @param Contract $contract Active contract about to be held. Must have an id.
-	 * @throws \DomainException If the contract stopped being active concurrently.
 	 * @throws RuntimeException If the anchor could not be stored.
 	 */
 	private function persist_anchor( Contract $contract ): void {
+		$id               = (int) $contract->get_id();
 		$next_payment_gmt = $contract->get_next_payment_gmt();
-		$contract->set_meta( self::ANCHOR_META_KEY, $next_payment_gmt );

-		if ( ! $this->contracts->update_if_status( $contract, ContractStatus::ACTIVE ) ) {
-			throw new \DomainException( 'Hold::hold(): the contract state changed concurrently; nothing was written.' );
+		try {
+			if ( null === $next_payment_gmt ) {
+				$this->contracts->delete_meta( $id, self::ANCHOR_META_KEY );
+			} else {
+				$this->contracts->update_meta( $id, self::ANCHOR_META_KEY, $next_payment_gmt );
+			}
+		} catch ( RuntimeException $e ) {
+			throw new RuntimeException( 'Hold::hold(): the hold anchor could not be stored; the contract was not held.', 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the previous exception is not output.
 		}

-		$stored = $this->contracts->find( (int) $contract->get_id() );
-		$anchor = null === $stored ? null : ( $stored->get_meta()[ self::ANCHOR_META_KEY ] ?? null );
+		$stored = $this->contracts->get_meta( $id, self::ANCHOR_META_KEY, true );
+		$anchor = '' === $stored ? null : $stored;
 		if ( $anchor !== $next_payment_gmt ) {
 			throw new RuntimeException( 'Hold::hold(): the hold anchor could not be stored; the contract was not held.' );
 		}
 	}

 	/**
-	 * The hold anchor stored on `$contract`, or null when there is none.
+	 * Clear a contract's hold anchor after a status write has committed.
+	 *
+	 * Best effort: a failed delete is logged and swallowed, so the caller still finishes
+	 * the transition it already wrote (cycle close, lifecycle action). A leftover anchor
+	 * is harmless: the next hold overwrites it and only an on-hold contract reads it.
+	 *
+	 * @param ContractRepository $contracts   Contract repository.
+	 * @param int                $contract_id Contract id.
+	 */
+	public static function clear_anchor( ContractRepository $contracts, int $contract_id ): void {
+		try {
+			$contracts->delete_meta( $contract_id, self::ANCHOR_META_KEY );
+		} catch ( RuntimeException $e ) {
+			wc_get_logger()->warning(
+				sprintf( 'Hold: the hold anchor of contract %d could not be cleared: %s', $contract_id, $e->getMessage() ),
+				array(
+					'source'      => 'woocommerce-subscriptions-engine',
+					'contract_id' => $contract_id,
+				)
+			);
+		}
+	}
+
+	/**
+	 * The hold anchor stored for a contract, or null when there is none.
 	 *
 	 * The one reader of {@see self::ANCHOR_META_KEY}: a value that is not a well-formed
 	 * GMT datetime (`Y-m-d H:i:s`) counts as absent and is logged, since a flow resuming
 	 * or ending from it would otherwise act on garbage.
 	 *
-	 * @param Contract $contract Contract to read.
+	 * @param ContractRepository $contracts   Contract repository.
+	 * @param int                $contract_id Contract id.
 	 */
-	public static function read_anchor( Contract $contract ): ?string {
-		$anchor = $contract->get_meta()[ self::ANCHOR_META_KEY ] ?? '';
-		if ( '' === $anchor ) {
+	public static function read_anchor( ContractRepository $contracts, int $contract_id ): ?string {
+		$anchor = $contracts->get_meta( $contract_id, self::ANCHOR_META_KEY, true );
+		if ( '' === $anchor || null === $anchor ) {
 			return null;
 		}

-		$parsed = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $anchor, new DateTimeZone( 'UTC' ) );
-		if ( false !== $parsed && $parsed->format( 'Y-m-d H:i:s' ) === $anchor ) {
-			return $anchor;
+		if ( is_string( $anchor ) ) {
+			$parsed = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $anchor, new DateTimeZone( 'UTC' ) );
+			if ( false !== $parsed && $parsed->format( 'Y-m-d H:i:s' ) === $anchor ) {
+				return $anchor;
+			}
 		}

 		wc_get_logger()->warning(
-			sprintf( 'Hold: contract %d has a malformed hold anchor; it is ignored.', (int) $contract->get_id() ),
+			sprintf( 'Hold: contract %d has a malformed hold anchor; it is ignored.', $contract_id ),
 			array(
 				'source'      => 'woocommerce-subscriptions-engine',
-				'contract_id' => $contract->get_id(),
+				'contract_id' => $contract_id,
 			)
 		);

diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
index e7629263066..5f9bba82130 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
@@ -122,10 +122,9 @@ final class Reactivation {

 		// A next-due moment set while held was re-armed deliberately (hold clears it), so
 		// it wins; otherwise resume from the hold anchor, ignoring a malformed one.
-		$anchor = $contract->get_next_payment_gmt() ?? Hold::read_anchor( $contract );
+		$anchor = $contract->get_next_payment_gmt() ?? Hold::read_anchor( $this->contracts, $id );

 		$contract->set_next_payment_gmt( $this->recompute_next_payment( $contract, $anchor, $now, $this->billing_policy( $contract ) ) );
-		$contract->set_meta( Hold::ANCHOR_META_KEY, null );
 		$contract->set_status( ContractStatus::ACTIVE );

 		// Compare-and-set on the ON_HOLD status read above: a concurrent transition
@@ -135,9 +134,11 @@ final class Reactivation {
 			throw new DomainException( 'Reactivation::reactivate(): the contract state changed concurrently; nothing was written.' );
 		}

+		Hold::clear_anchor( $this->contracts, $id );
+
 		/**
 		 * Fires after a held contract is reactivated: its renewal is re-armed, or left
-		 * unscheduled when there was no next-due moment to resume from.
+		 * unscheduled when there was no next-due moment to resume from. Fires immediately after the write, not after a surrounding transaction commits.
 		 *
 		 * @param Contract $contract The reactivated contract.
 		 */
@@ -236,7 +237,12 @@ final class Reactivation {
 			}
 		}

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

 		return $plan instanceof Plan ? $plan->get_billing_policy() : null;
 	}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
index fe021b7e95d..fc5960553f5 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
@@ -42,7 +42,7 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal\RenewalCalculator;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
@@ -217,11 +217,11 @@ final class RenewalEngine {
 	 * most once even under overlapping runs. Order reconciliation follows the claim, so the
 	 * cycle chain - not the mutable order - is the idempotency authority.
 	 *
-	 * Throws {@see RenewalNotProcessable} for a pre-flight impossibility (no chain, an
-	 * unresolvable plan, a non-adjacent count, a gateway that cannot charge renewals) so the
-	 * scheduled caller can park and a manual caller can return null; returns null for an
-	 * idempotent no-op (a non-active contract, a live claim, an already-settled cycle, an
-	 * unbuildable order).
+	 * Throws {@see RenewalNotProcessable} for a pre-flight impossibility (no currency, no
+	 * chain, an unresolvable plan, a non-adjacent count, a gateway that cannot charge
+	 * renewals) so the scheduled caller can park and a manual caller can return null; returns
+	 * null for an idempotent no-op (a non-active contract, a live claim, an already-settled
+	 * cycle, an unbuildable order). A contract without a customer renews as a guest order.
 	 *
 	 * @param RenewalIntent     $intent The contract and cycle count to bill.
 	 * @param DateTimeImmutable $now    The processing moment (the lease clock for a claim).
@@ -271,6 +271,13 @@ final class RenewalEngine {
 			return null;
 		}

+		// A renewal input an extension may not have supplied yet: without it no order can
+		// be built, so the scheduled caller parks the contract out of the due set. A null
+		// customer is not one: the renewal order is built as a guest order.
+		if ( null === $contract->get_currency() ) {
+			throw new RenewalNotProcessable( 'the contract has no currency' );
+		}
+
 		// Pre-flight capability gate, ahead of the claim so an unchargeable renewal never
 		// claims a cycle or creates an order. Without it the charge hook would fire into
 		// nothing and the cycle would park `processing` - a stall that misreads as an
@@ -358,6 +365,13 @@ final class RenewalEngine {
 			// reclaimed stall resuming an earlier attempt - already announced its creation, so
 			// re-firing would double one-time side effects (customer emails, analytics).
 			if ( $order_created ) {
+				/**
+				 * Fires after a renewal order is created, before it is charged.
+				 * Fires immediately after the write, not after a surrounding transaction commits.
+				 *
+				 * @param WC_Order $renewal_order The new renewal order.
+				 * @param Contract $contract      The contract being renewed.
+				 */
 				do_action( self::RENEWAL_ORDER_CREATED_ACTION, $renewal_order, $contract );
 			}
 			$this->attempt_charge( $renewal_order, $contract );
@@ -402,7 +416,12 @@ final class RenewalEngine {
 			}
 		}

-		$plan = $this->plans->find( $contract->get_selling_plan_id() );
+		$plan_id = $contract->get_selling_plan_id();
+		if ( null === $plan_id ) {
+			return null;
+		}
+
+		$plan = $this->plans->find( $plan_id );
 		return $plan instanceof Plan ? $plan->get_billing_policy() : null;
 	}

@@ -450,7 +469,7 @@ final class RenewalEngine {
 				'count'             => $cycle_count,
 				'period_start'      => $head->get_ends_at_gmt(),
 				'expected_total'    => $contract->get_billing_total(),
-				'currency'          => $contract->get_currency(),
+				'currency'          => (string) $contract->get_currency(),
 				'extension_slug'    => $contract->get_extension_slug(),
 				'plan_snapshot_id'  => $contract->get_plan_snapshot_id(),
 				'items_snapshot_id' => $contract->get_items_snapshot_id(),
@@ -638,7 +657,7 @@ final class RenewalEngine {
 			return;
 		}

-		$contract_id = ScalarCoercion::coerce_int( $order->get_meta( OrderLinkage::META_CONTRACT_ID ) );
+		$contract_id = Coercion::coerce_int( $order->get_meta( OrderLinkage::META_CONTRACT_ID ) );
 		if ( $contract_id <= 0 ) {
 			return;
 		}
@@ -758,6 +777,7 @@ final class RenewalEngine {

 			/**
 			 * Fires after a renewal cycle is billed and the contract schedule advanced.
+			 * Fires immediately after the write, not after a surrounding transaction commits.
 			 *
 			 * @param Contract $contract The renewed contract.
 			 * @param Cycle    $cycle    The newly-billed cycle.
@@ -823,7 +843,7 @@ final class RenewalEngine {

 		$renewal_order = wc_create_order(
 			array(
-				'customer_id' => $contract->get_customer_id(),
+				'customer_id' => (int) $contract->get_customer_id(),
 				'status'      => OrderStatus::CHECKOUT_DRAFT,
 				'created_via' => 'woocommerce_subscriptions_engine_renewal',
 			)
@@ -842,7 +862,7 @@ final class RenewalEngine {

 		$instrument = $contract->get_payment_instrument();

-		$renewal_order->set_currency( $contract->get_currency() );
+		$renewal_order->set_currency( (string) $contract->get_currency() );
 		if ( null !== $instrument->get_gateway() ) {
 			$renewal_order->set_payment_method( (string) $instrument->get_gateway() );
 		}
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 4240f5d5fea..a4358b88b20 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
@@ -1,15 +1,18 @@
 <?php
 /**
- * Persistence for the live {@see Contract} (row + items / addresses / meta) plus
- * targeted cycle access. Owns the $wpdb access across the contract-side tables.
+ * Persistence for the live {@see Contract} (row + items / addresses) plus contract
+ * meta and targeted cycle access. Owns the $wpdb access across the contract-side tables.
  *
  * 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_chain_head()}, {@see self::max_count()},
+ * through purpose-built reads ({@see self::find_chain_head()},
  * 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}.
+ * Meta is read and written only through the meta methods ({@see self::add_meta()} etc.),
+ * so a whole-contract write never rewrites meta. It opens no transactions and keeps no
+ * object cache; a caller may wrap several calls in its own transaction.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage
  */
@@ -24,7 +27,7 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;

@@ -35,25 +38,6 @@ defined( 'ABSPATH' ) || exit;
  */
 final class ContractRepository {

-	/**
-	 * Address columns persisted to the addresses table.
-	 *
-	 * @var array<int, string>
-	 */
-	private const ADDRESS_COLUMNS = array(
-		'first_name',
-		'last_name',
-		'company',
-		'address_1',
-		'address_2',
-		'city',
-		'state',
-		'postcode',
-		'country',
-		'email',
-		'phone',
-	);
-
 	/**
 	 * Logger source tag.
 	 */
@@ -77,7 +61,7 @@ final class ContractRepository {
 	}

 	/**
-	 * Insert a new contract and its items, addresses, and meta.
+	 * Insert a new contract and its items and addresses.
 	 *
 	 * 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
@@ -86,7 +70,7 @@ final class ContractRepository {
 	 *
 	 * @param Contract $contract Contract to insert.
 	 * @return int The new contract id.
-	 * @throws \RuntimeException If the contract insert fails.
+	 * @throws \RuntimeException If the contract row or a child row insert fails.
 	 */
 	public function insert( Contract $contract ): int {
 		global $wpdb;
@@ -115,56 +99,21 @@ final class ContractRepository {

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

 		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 reconciles items / addresses / meta only
-	 * when they differ - so the common renewal-cache write (status, next_payment_gmt)
+	 * Updates the contract row in place, then reconciles items / addresses only when
+	 * they differ (meta is never touched) - 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.
+	 * @throws \RuntimeException If the contract has no id, its row no longer exists, or a write fails.
 	 */
 	public function update( Contract $contract ): bool {
 		$id = $contract->get_id();
@@ -180,6 +129,65 @@ final class ContractRepository {
 		return true;
 	}

+	/**
+	 * Write only the named contract columns (plus `date_updated_gmt`) from the entity,
+	 * and replace items / addresses when named. Unnamed columns keep their stored
+	 * values, so a concurrent write to them is not reverted. Existence is checked only
+	 * when the row write changes nothing, before any child write.
+	 *
+	 * @param Contract           $contract Contract carrying the values. Must have an id.
+	 * @param array<int, string> $fields   Contract column names, plus `items` / `addresses`.
+	 * @return bool False when the contract row no longer exists (nothing is written).
+	 * @throws \InvalidArgumentException If a field is not a writable contract column.
+	 * @throws \RuntimeException If the contract has no id or the write fails.
+	 */
+	public function update_fields( Contract $contract, array $fields ): 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.' );
+		}
+
+		$storage = $contract->to_storage();
+		$columns = array();
+		foreach ( $fields as $field ) {
+			if ( 'items' === $field || 'addresses' === $field ) {
+				continue;
+			}
+			if ( 'extension_slug' === $field || ! array_key_exists( $field, $storage ) ) {
+				throw new \InvalidArgumentException( esc_html( sprintf( 'Cannot update contract field "%s".', $field ) ) );
+			}
+			$columns[ $field ] = $storage[ $field ];
+		}
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$updated = $wpdb->update(
+			SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ),
+			array_merge( $columns, array( 'date_updated_gmt' => gmdate( 'Y-m-d H:i:s' ) ) ),
+			array( 'id' => (int) $id )
+		);
+
+		if ( false === $updated ) {
+			throw new \RuntimeException( 'Failed to update contract.' );
+		}
+
+		// Zero changed rows: a missing row, or identical values written within the same second.
+		if ( 0 === $updated && ! $this->exists( $id ) ) {
+			return false;
+		}
+
+		if ( in_array( 'items', $fields, true ) ) {
+			$this->replace_items( $id, $contract->get_items() );
+		}
+
+		if ( in_array( 'addresses', $fields, true ) ) {
+			$this->replace_addresses( $id, $contract->get_addresses() );
+		}
+
+		return true;
+	}
+
 	/**
 	 * Update the contract row (and children) ONLY while its stored status still matches
 	 * `$expected_status` - the optimistic compare-and-set for status-sensitive writes,
@@ -244,8 +252,8 @@ final class ContractRepository {
 	}

 	/**
-	 * Fetch a contract by id, hydrating the live entity with its items / addresses /
-	 * meta, plus its frozen plan terms ({@see Contract::get_plan_snapshot()}) from
+	 * Fetch a contract by id, hydrating the live entity with its items / addresses,
+	 * plus its frozen plan terms ({@see Contract::get_plan_snapshot()}) from
 	 * `plan_snapshot_id` - so every full read carries the billing cadence off the
 	 * snapshot, with no live {@see PlanRepository} join. Cycles are NOT hydrated - they
 	 * are reached on demand through the targeted cycle reads. For list / guard paths
@@ -287,24 +295,23 @@ final class ContractRepository {
 			return null;
 		}

-		return $this->hydrate_row( self::as_string_keyed( $row ) );
+		return $this->hydrate_row( Coercion::coerce_string_keyed( $row ) );
 	}

 	/**
 	 * Hydrate a fetched contract row into the full live entity: frozen plan terms,
-	 * items, addresses, and meta - the one full-read construction path.
+	 * items and addresses - the one full-read construction path.
 	 *
 	 * @param array<string, mixed> $row Contract row.
 	 */
 	private function hydrate_row( array $row ): Contract {
-		$id = ScalarCoercion::coerce_int( $row['id'] ?? 0 );
+		$id = Coercion::coerce_int( $row['id'] ?? 0 );

 		return Contract::from_storage(
 			$row,
-			$this->find_plan_snapshot( ScalarCoercion::coerce_nullable_int( $row['plan_snapshot_id'] ?? null ) ),
+			$this->find_plan_snapshot( Coercion::coerce_nullable_int( $row['plan_snapshot_id'] ?? null ) ),
 			$this->find_items( $id ),
-			$this->find_addresses( $id ),
-			$this->find_meta( $id )
+			$this->find_addresses( $id )
 		);
 	}

@@ -525,11 +532,11 @@ final class ContractRepository {
 			if ( ! is_array( $row ) ) {
 				continue;
 			}
-			$status = ScalarCoercion::coerce_string( $row['status'] ?? '' );
+			$status = Coercion::coerce_string( $row['status'] ?? '' );
 			// A row whose stored status is not registered is ignored, not added as a
 			// stray key - the map stays exactly ContractStatus::get_all().
 			if ( array_key_exists( $status, $counts ) ) {
-				$counts[ $status ] = ScalarCoercion::coerce_int( $row['total'] ?? 0 );
+				$counts[ $status ] = Coercion::coerce_int( $row['total'] ?? 0 );
 			}
 		}

@@ -561,7 +568,7 @@ final class ContractRepository {
 			$total = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}", $params ) );
 		}

-		return ScalarCoercion::coerce_int( $total );
+		return Coercion::coerce_int( $total );
 	}

 	/**
@@ -607,9 +614,9 @@ final class ContractRepository {
 			if ( ! is_array( $row ) ) {
 				continue;
 			}
-			$cid = ScalarCoercion::coerce_int( $row['contract_id'] ?? 0 );
+			$cid = Coercion::coerce_int( $row['contract_id'] ?? 0 );
 			if ( array_key_exists( $cid, $counts ) ) {
-				$counts[ $cid ] = ScalarCoercion::coerce_int( $row['total'] ?? 0 );
+				$counts[ $cid ] = Coercion::coerce_int( $row['total'] ?? 0 );
 			}
 		}

@@ -628,13 +635,13 @@ final class ContractRepository {
 		$clean_rows = array();
 		foreach ( $rows as $row ) {
 			if ( is_array( $row ) ) {
-				$clean_rows[] = self::as_string_keyed( $row );
+				$clean_rows[] = Coercion::coerce_string_keyed( $row );
 			}
 		}

 		$snapshot_ids = array();
 		foreach ( $clean_rows as $row ) {
-			$snapshot_id = ScalarCoercion::coerce_nullable_int( $row['plan_snapshot_id'] ?? null );
+			$snapshot_id = Coercion::coerce_nullable_int( $row['plan_snapshot_id'] ?? null );
 			if ( null !== $snapshot_id ) {
 				$snapshot_ids[ $snapshot_id ] = $snapshot_id;
 			}
@@ -643,7 +650,7 @@ final class ContractRepository {

 		$contracts = array();
 		foreach ( $clean_rows as $row ) {
-			$snapshot_id = ScalarCoercion::coerce_nullable_int( $row['plan_snapshot_id'] ?? null );
+			$snapshot_id = Coercion::coerce_nullable_int( $row['plan_snapshot_id'] ?? null );
 			$contracts[] = Contract::from_storage( $row, null !== $snapshot_id ? ( $snapshots[ $snapshot_id ] ?? null ) : null );
 		}

@@ -756,10 +763,10 @@ final class ContractRepository {
 			}
 			$head_count = $row['head_count'] ?? null;
 			$result[]   = new RenewalCandidate(
-				ScalarCoercion::coerce_int( $row['contract_id'] ?? 0 ),
-				null === $head_count ? null : ScalarCoercion::coerce_int( $head_count ),
-				ScalarCoercion::coerce_string( $row['head_status'] ?? '' ),
-				ScalarCoercion::coerce_string( $row['head_ends_at_gmt'] ?? '' )
+				Coercion::coerce_int( $row['contract_id'] ?? 0 ),
+				null === $head_count ? null : Coercion::coerce_int( $head_count ),
+				Coercion::coerce_string( $row['head_status'] ?? '' ),
+				Coercion::coerce_string( $row['head_ends_at_gmt'] ?? '' )
 			);
 		}

@@ -779,7 +786,7 @@ final class ContractRepository {
 	 * args, so the shape can widen without a signature change. Ordered by id DESC (monotonic
 	 * with creation) so the list is newest-first and stable for paging.
 	 *
-	 * Each row is row-only (no items / addresses / meta), but its frozen plan terms are
+	 * Each row is row-only (no items / addresses), but its frozen plan terms are
 	 * hydrated ({@see Contract::get_plan_snapshot()}) so the list rows carry the billing
 	 * cadence off the snapshot - batch-loaded in ONE `IN()` read for the whole page, not
 	 * one read per row.
@@ -790,17 +797,28 @@ final class ContractRepository {
 	 *
 	 *     @type int    $limit  Maximum contracts to return. Default 20.
 	 *     @type int    $offset Rows to skip (for paging). Default 0.
-	 *     @type string $status Optional status filter (one of {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus}).
+	 *     @type string|string[] $status Optional status filter: one status or a list of them
+	 *                                   ({@see ContractStatus}). Unregistered values are dropped;
+	 *                                   ignored when none remain.
 	 * }
 	 * @return array<int, Contract> Contracts the customer owns, newest first.
 	 */
 	public function find_by_customer_id( int $customer_id, ?array $args = null ): array {
 		global $wpdb;

-		$args   = $args ?? array();
-		$limit  = isset( $args['limit'] ) && is_numeric( $args['limit'] ) ? (int) $args['limit'] : 20;
-		$offset = isset( $args['offset'] ) && is_numeric( $args['offset'] ) ? (int) $args['offset'] : 0;
-		$status = isset( $args['status'] ) && is_string( $args['status'] ) && '' !== $args['status'] ? $args['status'] : null;
+		$args     = $args ?? array();
+		$limit    = isset( $args['limit'] ) && is_numeric( $args['limit'] ) ? (int) $args['limit'] : 20;
+		$offset   = isset( $args['offset'] ) && is_numeric( $args['offset'] ) ? (int) $args['offset'] : 0;
+		$statuses = array_values(
+			array_unique(
+				array_filter(
+					(array) ( $args['status'] ?? array() ),
+					static function ( $status ): bool {
+						return is_string( $status ) && ContractStatus::is_registered( $status );
+					}
+				)
+			)
+		);

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

@@ -809,9 +827,9 @@ final class ContractRepository {
 		// interpolated).
 		$where  = 'customer_id = %d';
 		$params = array( $customer_id );
-		if ( null !== $status ) {
-			$where   .= ' AND status = %s';
-			$params[] = $status;
+		if ( array() !== $statuses ) {
+			$where .= ' AND status IN (' . implode( ', ', array_fill( 0, count( $statuses ), '%s' ) ) . ')';
+			$params = array_merge( $params, $statuses );
 		}
 		$params[] = $limit;
 		$params[] = $offset;
@@ -834,6 +852,24 @@ final class ContractRepository {
 		return $this->contracts_from_rows( is_array( $rows ) ? $rows : array() );
 	}

+	/**
+	 * Contracts whose `origin_order_id` is `$order_id`, oldest first. Row-only reads
+	 * (no items / addresses) with their frozen plan terms batch-hydrated.
+	 *
+	 * @param int $order_id Origin order id.
+	 * @return array<int, Contract>
+	 */
+	public function find_by_origin_order( int $order_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
+		$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE origin_order_id = %d ORDER BY id ASC", $order_id ), ARRAY_A );
+
+		return $this->contracts_from_rows( is_array( $rows ) ? $rows : array() );
+	}
+
 	/**
 	 * Whether a contract row exists for `$id`.
 	 *
@@ -878,12 +914,167 @@ final class ContractRepository {
 		return (bool) $deleted;
 	}

+	/**
+	 * Add a meta row for a contract, like `add_post_meta()`.
+	 *
+	 * @param int    $contract_id Contract id.
+	 * @param string $key         Meta key.
+	 * @param mixed  $value       Meta value; serialized when not scalar.
+	 * @param bool   $unique      When true, add nothing if the key already exists. Advisory:
+	 *                            checked before the insert with no unique index.
+	 * @return int|null The new meta row id, or null when `$unique` and the key exists.
+	 * @throws \InvalidArgumentException If `$key` is empty.
+	 * @throws \RuntimeException If the insert fails.
+	 */
+	public function add_meta( int $contract_id, string $key, $value, bool $unique = false ): ?int {
+		global $wpdb;
+
+		if ( '' === $key ) {
+			throw new \InvalidArgumentException( 'Contract meta key must not be empty.' );
+		}
+
+		if ( $unique && array() !== $this->find_meta_values( $contract_id, $key ) ) {
+			return null;
+		}
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+		$inserted = $wpdb->insert(
+			SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ),
+			array(
+				'contract_id' => $contract_id,
+				'meta_key'    => $key,
+				'meta_value'  => maybe_serialize( $value ),
+			)
+		);
+
+		if ( false === $inserted ) {
+			throw new \RuntimeException( sprintf( 'Failed to add contract meta "%s" for contract %d: %s', esc_html( $key ), (int) $contract_id, esc_html( $wpdb->last_error ) ) );
+		}
+
+		return (int) $wpdb->insert_id;
+	}
+
+	/**
+	 * Update a contract's meta rows for `$key`, like `update_post_meta()`: adds a row when
+	 * the key is absent, else rewrites every row for the key, or only the rows holding
+	 * `$prev_value`. The absent-key check runs before the write with no unique index.
+	 *
+	 * @param int    $contract_id Contract id.
+	 * @param string $key         Meta key.
+	 * @param mixed  $value       New value; serialized when not scalar.
+	 * @param mixed  $prev_value  Only update rows holding this value; null updates all rows for the key.
+	 *                            Any other value ('' and false included) matches literally.
+	 * @return bool True when a row was added or at least one row changed.
+	 * @throws \InvalidArgumentException If `$key` is empty.
+	 * @throws \RuntimeException If a write fails.
+	 */
+	public function update_meta( int $contract_id, string $key, $value, $prev_value = null ): bool {
+		global $wpdb;
+
+		if ( '' === $key ) {
+			throw new \InvalidArgumentException( 'Contract meta key must not be empty.' );
+		}
+
+		if ( array() === $this->find_meta_values( $contract_id, $key ) ) {
+			$this->add_meta( $contract_id, $key, $value );
+			return true;
+		}
+
+		$where = array(
+			'contract_id' => $contract_id,
+			'meta_key'    => $key,
+		);
+		if ( null !== $prev_value ) {
+			$where['meta_value'] = maybe_serialize( $prev_value );
+		}
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+		$updated = $wpdb->update(
+			SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ),
+			array( 'meta_value' => maybe_serialize( $value ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+			$where
+		);
+
+		if ( false === $updated ) {
+			throw new \RuntimeException( sprintf( 'Failed to update contract meta "%s" for contract %d: %s', esc_html( $key ), (int) $contract_id, esc_html( $wpdb->last_error ) ) );
+		}
+
+		return $updated > 0;
+	}
+
+	/**
+	 * Delete a contract's meta rows for `$key`, like `delete_post_meta()`.
+	 *
+	 * @param int    $contract_id Contract id.
+	 * @param string $key         Meta key.
+	 * @param mixed  $value       Only delete rows holding this value; null deletes every row for the key.
+	 *                            Any other value ('' and false included) matches literally.
+	 * @return bool True when at least one row was deleted.
+	 * @throws \InvalidArgumentException If `$key` is empty.
+	 * @throws \RuntimeException If the delete fails.
+	 */
+	public function delete_meta( int $contract_id, string $key, $value = null ): bool {
+		global $wpdb;
+
+		if ( '' === $key ) {
+			throw new \InvalidArgumentException( 'Contract meta key must not be empty.' );
+		}
+
+		$where = array(
+			'contract_id' => $contract_id,
+			'meta_key'    => $key,
+		);
+		if ( null !== $value ) {
+			$where['meta_value'] = maybe_serialize( $value );
+		}
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$deleted = $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ), $where );
+
+		if ( false === $deleted ) {
+			throw new \RuntimeException( sprintf( 'Failed to delete contract meta "%s" for contract %d: %s', esc_html( $key ), (int) $contract_id, esc_html( $wpdb->last_error ) ) );
+		}
+
+		return $deleted > 0;
+	}
+
+	/**
+	 * Read contract meta (WordPress `get_post_meta()` semantics), values unserialized,
+	 * oldest row first.
+	 *
+	 * @param int    $contract_id Contract id.
+	 * @param string $key         Meta key; empty for every key.
+	 * @param bool   $single      With a key: return the first value only.
+	 * @return mixed Empty key: `array<string, array<int, mixed>>` of all keys. Key + `$single`:
+	 *               the first value, or '' when absent. Key only: the list of values (`[]` when absent).
+	 */
+	public function get_meta( int $contract_id, string $key = '', bool $single = false ) {
+		if ( '' === $key ) {
+			$all = array();
+			foreach ( $this->find_meta_rows( $contract_id, null ) as $row ) {
+				$all[ $row['meta_key'] ][] = maybe_unserialize( $row['meta_value'] );
+			}
+
+			return $all;
+		}
+
+		$values = $this->find_meta_values( $contract_id, $key );
+
+		if ( $single ) {
+			return array() === $values ? '' : $values[0];
+		}
+
+		return $values;
+	}
+
 	/**
 	 * 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.
+	 * Assigns an unassigned `sequence_no` (the chain head's plus one, or 1), 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 chain's
+	 * UNIQUE indexes guard concurrent appends and duplicate counts. 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
@@ -894,10 +1085,25 @@ final class ContractRepository {
 	 *                          other reason (the database error is in the message).
 	 */
 	public function append_cycle( Cycle $cycle, ?Cycle $previous = null ): void {
+		$this->assign_next_sequence_no( $cycle );
 		$this->resolve_cycle_snapshots( $cycle, $previous );
 		$this->insert_cycle( $cycle );
 	}

+	/**
+	 * Assign the chain's next `sequence_no` to a cycle that awaits one.
+	 *
+	 * @param Cycle $cycle Cycle about to be appended.
+	 */
+	private function assign_next_sequence_no( Cycle $cycle ): void {
+		if ( ! $cycle->awaits_sequence_no() ) {
+			return;
+		}
+
+		$head = $this->find_chain_head( $cycle->get_contract_id(), $cycle->get_kind() );
+		$cycle->assign_sequence_no( null === $head ? 1 : $head->get_sequence_no() + 1 );
+	}
+
 	/**
 	 * Update an existing cycle row.
 	 *
@@ -1069,33 +1275,13 @@ final class ContractRepository {
 		$cycles = array();
 		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
 			if ( is_array( $row ) ) {
-				$cycles[] = $this->hydrate_cycle( self::as_string_keyed( $row ) );
+				$cycles[] = $this->hydrate_cycle( Coercion::coerce_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
@@ -1115,7 +1301,7 @@ final class ContractRepository {
 		$cycles = array();
 		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
 			if ( is_array( $row ) ) {
-				$cycles[] = $this->hydrate_cycle( self::as_string_keyed( $row ) );
+				$cycles[] = $this->hydrate_cycle( Coercion::coerce_string_keyed( $row ) );
 			}
 		}

@@ -1273,7 +1459,7 @@ final class ContractRepository {
 			return null;
 		}

-		return PlanSnapshot::from_payload( self::as_string_keyed( $decoded['payload'] ), $decoded['schema_version'] );
+		return PlanSnapshot::from_payload( Coercion::coerce_string_keyed( $decoded['payload'] ), $decoded['schema_version'] );
 	}

 	/**
@@ -1313,11 +1499,11 @@ final class ContractRepository {
 			if ( ! is_array( $row ) ) {
 				continue;
 			}
-			$payload = json_decode( ScalarCoercion::coerce_string( $row['payload'] ?? null ), true );
+			$payload = json_decode( Coercion::coerce_string( $row['payload'] ?? null ), true );

-			$snapshots[ ScalarCoercion::coerce_int( $row['id'] ?? 0 ) ] = PlanSnapshot::from_payload(
-				self::as_string_keyed( is_array( $payload ) ? $payload : array() ),
-				ScalarCoercion::coerce_int( $row['schema_version'] ?? 0 )
+			$snapshots[ Coercion::coerce_int( $row['id'] ?? 0 ) ] = PlanSnapshot::from_payload(
+				Coercion::coerce_string_keyed( is_array( $payload ) ? $payload : array() ),
+				Coercion::coerce_int( $row['schema_version'] ?? 0 )
 			);
 		}

@@ -1336,7 +1522,7 @@ final class ContractRepository {
 			return null;
 		}

-		return ItemsSnapshot::from_payload( self::as_item_rows( $decoded['payload'] ), $decoded['schema_version'] );
+		return ItemsSnapshot::from_payload( Coercion::coerce_list_of_arrays( $decoded['payload'] ), $decoded['schema_version'] );
 	}

 	/**
@@ -1369,42 +1555,6 @@ final class ContractRepository {
 		);
 	}

-	/**
-	 * 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.
 	 *
@@ -1467,7 +1617,7 @@ final class ContractRepository {
 	}

 	/**
-	 * Reconcile a contract's items, addresses, and meta rows only when they differ.
+	 * Reconcile a contract's items and address 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
@@ -1475,6 +1625,7 @@ final class ContractRepository {
 	 * 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.
+	 * @throws \RuntimeException If a child row write fails.
 	 */
 	private function sync_children( Contract $contract ): void {
 		$id = (int) $contract->get_id();
@@ -1486,10 +1637,6 @@ final class ContractRepository {
 		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() );
-		}
 	}

 	/**
@@ -1497,12 +1644,17 @@ final class ContractRepository {
 	 *
 	 * @param int                              $contract_id Contract id.
 	 * @param array<int, array<string, mixed>> $items       Item rows.
+	 * @throws \RuntimeException If the delete or an insert fails.
 	 */
 	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 ) );
+		$deleted = $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ITEMS ), array( 'contract_id' => $contract_id ) );
+		if ( false === $deleted ) {
+			throw new \RuntimeException( sprintf( 'Failed to delete item rows for contract %d: %s', (int) $contract_id, esc_html( $wpdb->last_error ) ) );
+		}
+
 		$this->insert_items( $contract_id, $items );
 	}

@@ -1511,29 +1663,18 @@ final class ContractRepository {
 	 *
 	 * @param int                                 $contract_id Contract id.
 	 * @param array<string, array<string, mixed>> $addresses   Address rows keyed by type.
+	 * @throws \RuntimeException If the delete or an insert fails.
 	 */
 	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
-		if ( false === $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ), array( 'contract_id' => $contract_id ) ) ) {
-			$this->log_meta_write_failure( $contract_id, 'delete' );
+		$deleted = $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ADDRESSES ), array( 'contract_id' => $contract_id ) );
+		if ( false === $deleted ) {
+			throw new \RuntimeException( sprintf( 'Failed to delete address rows for contract %d: %s', (int) $contract_id, esc_html( $wpdb->last_error ) ) );
 		}
-		$this->insert_meta( $contract_id, $meta );
+
+		$this->insert_addresses( $contract_id, $addresses );
 	}

 	/**
@@ -1541,26 +1682,31 @@ final class ContractRepository {
 	 *
 	 * @param int                              $contract_id Contract id.
 	 * @param array<int, array<string, mixed>> $items       Item rows.
+	 * @throws \RuntimeException If an insert fails.
 	 */
 	private function insert_items( int $contract_id, array $items ): void {
 		global $wpdb;

 		foreach ( $items as $item ) {
 			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-			$wpdb->insert(
+			$inserted = $wpdb->insert(
 				SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ITEMS ),
 				array(
 					'contract_id'  => $contract_id,
-					'item_name'    => ScalarCoercion::coerce_string( $item['item_name'] ?? null ),
-					'item_type'    => ScalarCoercion::coerce_string( $item['item_type'] ?? null, 'line_item' ),
-					'product_id'   => isset( $item['product_id'] ) ? ScalarCoercion::coerce_int( $item['product_id'] ) : null,
-					'variation_id' => isset( $item['variation_id'] ) ? ScalarCoercion::coerce_int( $item['variation_id'] ) : null,
-					'quantity'     => ScalarCoercion::coerce_string( $item['quantity'] ?? null, '1' ),
-					'subtotal'     => ScalarCoercion::coerce_string( $item['subtotal'] ?? null, '0' ),
-					'total'        => ScalarCoercion::coerce_string( $item['total'] ?? null, '0' ),
+					'item_name'    => Coercion::coerce_string( $item['item_name'] ?? null ),
+					'item_type'    => Coercion::coerce_string( $item['item_type'] ?? null, 'line_item' ),
+					'product_id'   => isset( $item['product_id'] ) ? Coercion::coerce_int( $item['product_id'] ) : null,
+					'variation_id' => isset( $item['variation_id'] ) ? Coercion::coerce_int( $item['variation_id'] ) : null,
+					'quantity'     => Coercion::coerce_string( $item['quantity'] ?? null, '1' ),
+					'subtotal'     => Coercion::coerce_string( $item['subtotal'] ?? null, '0' ),
+					'total'        => Coercion::coerce_string( $item['total'] ?? null, '0' ),
 					'taxes'        => isset( $item['taxes'] ) ? wp_json_encode( $item['taxes'] ) : null,
 				)
 			);
+
+			if ( false === $inserted ) {
+				throw new \RuntimeException( sprintf( 'Failed to insert item row for contract %d: %s', (int) $contract_id, esc_html( $wpdb->last_error ) ) );
+			}
 		}
 	}

@@ -1569,6 +1715,7 @@ final class ContractRepository {
 	 *
 	 * @param int                                 $contract_id Contract id.
 	 * @param array<string, array<string, mixed>> $addresses   Address rows keyed by type.
+	 * @throws \RuntimeException If an insert fails.
 	 */
 	private function insert_addresses( int $contract_id, array $addresses ): void {
 		global $wpdb;
@@ -1579,63 +1726,18 @@ final class ContractRepository {
 				'address_type' => (string) $type,
 			);

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

 			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-			$wpdb->insert( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ADDRESSES ), $record );
-		}
-	}
-
-	/**
-	 * Insert meta for a contract.
-	 *
-	 * @param int                   $contract_id Contract id.
-	 * @param array<string, string> $meta        Meta as key => value.
-	 */
-	private function insert_meta( int $contract_id, array $meta ): void {
-		global $wpdb;
-
-		foreach ( $meta as $key => $value ) {
-			// 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
-			$inserted = $wpdb->insert(
-				SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ),
-				array(
-					'contract_id' => $contract_id,
-					'meta_key'    => (string) $key,
-					'meta_value'  => (string) $value,
-				)
-			);
+			$inserted = $wpdb->insert( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ADDRESSES ), $record );
 			if ( false === $inserted ) {
-				$this->log_meta_write_failure( $contract_id, 'insert', (string) $key );
+				throw new \RuntimeException( sprintf( 'Failed to insert %s address row for contract %d: %s', esc_html( (string) $type ), (int) $contract_id, esc_html( $wpdb->last_error ) ) );
 			}
 		}
 	}

-	/**
-	 * Log a failed contract-meta write. Meta writes follow the row write without a
-	 * transaction, so a failure here leaves the row and its meta out of step; logging it
-	 * makes that visible.
-	 *
-	 * @param int    $contract_id Contract id.
-	 * @param string $operation   The failed operation (`delete` or `insert`).
-	 * @param string $meta_key    The meta key being inserted, if any.
-	 */
-	private function log_meta_write_failure( int $contract_id, string $operation, string $meta_key = '' ): void {
-		global $wpdb;
-
-		wc_get_logger()->error(
-			sprintf( 'ContractRepository: contract meta %s failed for contract %d%s - %s', $operation, $contract_id, '' === $meta_key ? '' : sprintf( ' (key %s)', $meta_key ), $wpdb->last_error ),
-			array(
-				'source'      => self::LOG_SOURCE,
-				'contract_id' => $contract_id,
-			)
-		);
-	}
-
 	/**
 	 * Load line items for a contract.
 	 *
@@ -1650,7 +1752,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 );

-		return self::as_item_rows( is_array( $rows ) ? $rows : array() );
+		return Coercion::coerce_list_of_arrays( $rows );
 	}

 	/**
@@ -1670,7 +1772,7 @@ final class ContractRepository {
 		$by_type = array();
 		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
 			if ( is_array( $row ) ) {
-				$by_type[ ScalarCoercion::coerce_string( $row['address_type'] ?? null ) ] = self::as_string_keyed( $row );
+				$by_type[ Coercion::coerce_string( $row['address_type'] ?? null ) ] = Coercion::coerce_string_keyed( $row );
 			}
 		}

@@ -1678,29 +1780,54 @@ final class ContractRepository {
 	}

 	/**
-	 * Load meta for a contract as key => value.
+	 * Unserialized values stored under `$key` for a contract, oldest first.
 	 *
-	 * @param int $contract_id Contract id.
-	 * @return array<string, string>
+	 * @param int    $contract_id Contract id.
+	 * @param string $key         Meta key.
+	 * @return array<int, mixed>
+	 */
+	private function find_meta_values( int $contract_id, string $key ): array {
+		$values = array();
+		foreach ( $this->find_meta_rows( $contract_id, $key ) as $row ) {
+			$values[] = maybe_unserialize( $row['meta_value'] );
+		}
+
+		return $values;
+	}
+
+	/**
+	 * Raw meta rows for a contract, optionally for one key, by id ascending.
+	 *
+	 * @param int         $contract_id Contract id.
+	 * @param string|null $key         Meta key, or null for every key.
+	 * @return array<int, array{meta_key: string, meta_value: string}>
 	 */
-	private function find_meta( int $contract_id ): array {
+	private function find_meta_rows( int $contract_id, ?string $key ): array {
 		global $wpdb;

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

-		// 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 );
+		if ( null === $key ) {
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+			$rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, meta_value FROM {$table} WHERE contract_id = %d ORDER BY id ASC", $contract_id ), ARRAY_A );
+		} else {
+			// 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
+			$rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, meta_value FROM {$table} WHERE contract_id = %d AND meta_key = %s ORDER BY id ASC", $contract_id, $key ), ARRAY_A );
+		}

-		$meta = array();
+		$result = array();
 		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
 			if ( is_array( $row ) ) {
-				$meta[ ScalarCoercion::coerce_string( $row['meta_key'] ?? null ) ] = ScalarCoercion::coerce_string( $row['meta_value'] ?? null );
+				$result[] = array(
+					'meta_key'   => Coercion::coerce_string( $row['meta_key'] ?? null ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+					'meta_value' => Coercion::coerce_string( $row['meta_value'] ?? null ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+				);
 			}
 		}

-		return $meta;
+		return $result;
 	}

 	/**
@@ -1717,13 +1844,13 @@ final class ContractRepository {

 		foreach ( $items as $item ) {
 			$signature[] = array(
-				'item_name'    => ScalarCoercion::coerce_string( $item['item_name'] ?? null ),
-				'item_type'    => ScalarCoercion::coerce_string( $item['item_type'] ?? null, 'line_item' ),
-				'product_id'   => isset( $item['product_id'] ) ? (string) ScalarCoercion::coerce_int( $item['product_id'] ) : null,
-				'variation_id' => isset( $item['variation_id'] ) ? (string) ScalarCoercion::coerce_int( $item['variation_id'] ) : null,
-				'quantity'     => number_format( ScalarCoercion::coerce_float( $item['quantity'] ?? 1 ), 4, '.', '' ),
-				'subtotal'     => number_format( ScalarCoercion::coerce_float( $item['subtotal'] ?? 0 ), 8, '.', '' ),
-				'total'        => number_format( ScalarCoercion::coerce_float( $item['total'] ?? 0 ), 8, '.', '' ),
+				'item_name'    => Coercion::coerce_string( $item['item_name'] ?? null ),
+				'item_type'    => Coercion::coerce_string( $item['item_type'] ?? null, 'line_item' ),
+				'product_id'   => isset( $item['product_id'] ) ? (string) Coercion::coerce_int( $item['product_id'] ) : null,
+				'variation_id' => isset( $item['variation_id'] ) ? (string) Coercion::coerce_int( $item['variation_id'] ) : null,
+				'quantity'     => number_format( Coercion::coerce_float( $item['quantity'] ?? 1 ), 4, '.', '' ),
+				'subtotal'     => number_format( Coercion::coerce_float( $item['subtotal'] ?? 0 ), 8, '.', '' ),
+				'total'        => number_format( Coercion::coerce_float( $item['total'] ?? 0 ), 8, '.', '' ),
 				'taxes'        => $this->taxes_signature( $item['taxes'] ?? null ),
 			);
 		}
@@ -1746,8 +1873,8 @@ final class ContractRepository {

 		foreach ( $addresses as $type => $address ) {
 			$record = array();
-			foreach ( self::ADDRESS_COLUMNS as $column ) {
-				$value             = isset( $address[ $column ] ) ? ScalarCoercion::coerce_string( $address[ $column ] ) : '';
+			foreach ( Contract::ADDRESS_FIELDS as $column ) {
+				$value             = isset( $address[ $column ] ) ? Coercion::coerce_string( $address[ $column ] ) : '';
 				$record[ $column ] = '' !== $value ? $value : null;
 			}

@@ -1759,24 +1886,6 @@ final class ContractRepository {
 		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.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php
index bb5bd1c11de..dead97a62cb 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php
@@ -10,7 +10,7 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage;

 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;

 defined( 'ABSPATH' ) || exit;

@@ -151,8 +151,8 @@ final class PlanRepository {

 		$table  = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
 		$order  = $this->build_order_clause( $args );
-		$limit  = max( 1, ScalarCoercion::coerce_int( $args['limit'] ?? null, 50 ) );
-		$offset = max( 0, ScalarCoercion::coerce_int( $args['offset'] ?? null, 0 ) );
+		$limit  = max( 1, Coercion::coerce_int( $args['limit'] ?? null, 50 ) );
+		$offset = max( 0, Coercion::coerce_int( $args['offset'] ?? null, 0 ) );

 		// phpcs:ignore Generic.Arrays.DisallowShortArraySyntax.Found
 		[
@@ -311,7 +311,7 @@ final class PlanRepository {
 			? array_unique(
 				array_map(
 					static function ( $matched_id ): int {
-						return ScalarCoercion::coerce_int( $matched_id );
+						return Coercion::coerce_int( $matched_id );
 					},
 					$matched_ids
 				)
@@ -353,7 +353,7 @@ final class PlanRepository {
 		$clauses = array();
 		$params  = array();

-		$status = ScalarCoercion::coerce_string( $args['status'] ?? null );
+		$status = Coercion::coerce_string( $args['status'] ?? null );
 		if ( '' !== $status ) {
 			$clauses[] = 'status = %s';
 			$params[]  = $status;
@@ -402,7 +402,7 @@ final class PlanRepository {
 				$ids       = array();
 				$all_valid = true;
 				foreach ( array_values( $args['ids'] ) as $possible_id ) {
-					$plan_id = ScalarCoercion::coerce_int( $possible_id );
+					$plan_id = Coercion::coerce_int( $possible_id );
 					if ( $plan_id <= 0 ) {
 						$all_valid = false;
 						break;
@@ -425,7 +425,7 @@ final class PlanRepository {
 			}
 		}

-		$search = ScalarCoercion::coerce_string( $args['search'] ?? null );
+		$search = Coercion::coerce_string( $args['search'] ?? null );
 		if ( '' !== $search ) {
 			$like      = '%' . $wpdb->esc_like( $search ) . '%';
 			$clauses[] = '(name LIKE %s OR description LIKE %s)';
@@ -452,11 +452,11 @@ final class PlanRepository {
 	 * @param array<string, mixed> $args Query args.
 	 */
 	private function build_order_clause( array $args ): string {
-		$orderby_arg = ScalarCoercion::coerce_string( $args['orderby'] ?? null );
+		$orderby_arg = Coercion::coerce_string( $args['orderby'] ?? null );
 		$orderby     = isset( self::ORDERBY_COLUMNS[ $orderby_arg ] )
 			? self::ORDERBY_COLUMNS[ $orderby_arg ]
 			: 'sort_order';
-		$order       = 'desc' === strtolower( ScalarCoercion::coerce_string( $args['order'] ?? null ) ) ? 'DESC' : 'ASC';
+		$order       = 'desc' === strtolower( Coercion::coerce_string( $args['order'] ?? null ) ) ? 'DESC' : 'ASC';

 		if ( 'sort_order' === $orderby ) {
 			return "ORDER BY sort_order {$order}, id ASC";
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 522815db895..c2bf1c54a99 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
@@ -43,13 +43,16 @@ final class SchemaInstaller {
 	 *         `due_contract (status, next_payment_gmt)` and makes the contracts
 	 *         `extension_slug` index redundant (dropped); pre-freeze, existing tables must be
 	 *         recreated to drop the old indexes.
+	 * 2.5.0 - contracts `customer_id`, `currency`, `selling_plan_id`, `start_gmt` nullable;
+	 *         contract_meta indexes `meta_key_value` and `contract_meta_key_value` (HPOS
+	 *         shape) replace `contract_key`; pre-freeze, recreate the tables.
 	 *
 	 * Pre-freeze, tables are recreated rather than migrated. dbDelta adds columns but
 	 * does not change an existing column's nullability or drop unused ones, so a dev box
 	 * on an earlier schema must drop and recreate the tables (and clear VERSION_OPTION)
 	 * to pick up such changes - in-place ALTERs and backfills arrive with the freeze.
 	 */
-	private const VERSION = '2.4.0';
+	private const VERSION = '2.5.0';

 	/**
 	 * Option key tracking the installed schema version.
@@ -229,7 +232,8 @@ final class SchemaInstaller {
 		// values, not caches of cycles. The `due_owner (extension_slug, next_payment_gmt)` index
 		// keys the batch dispatcher's scan (owner equality, then a range on the due date);
 		// `due` is retained for next-bill-cache lookups keyed the other way. `origin_order_id`
-		// is NULLABLE (a manual/admin contract has no origin order). There is no generic
+		// is NULLABLE (a manual/admin contract has no origin order); `customer_id`, `currency`,
+		// `selling_plan_id` and `start_gmt` are NULLABLE until the extension supplies them. 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
@@ -237,15 +241,15 @@ final class SchemaInstaller {
 		$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,
+  customer_id BIGINT UNSIGNED NULL,
+  currency CHAR(3) NULL,
+  selling_plan_id BIGINT UNSIGNED 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,
+  start_gmt DATETIME NULL,
   next_payment_gmt DATETIME NULL,
   plan_snapshot_id BIGINT UNSIGNED NULL,
   items_snapshot_id BIGINT UNSIGNED NULL,
@@ -301,13 +305,17 @@ final class SchemaInstaller {
   PRIMARY KEY  (contract_id, address_type)
 ) {$collate};";

+		// Mirrors the HPOS orders meta table indexes, including its meta_value prefix length.
+		$meta_value_index_length = max( min( absint( apply_filters( 'woocommerce_database_max_index_length', 191 ) ), 767 ) - 8 - 100 - 1, 20 );
+
 		$contract_meta_sql = "CREATE TABLE {$contract_meta} (
   id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
   contract_id BIGINT UNSIGNED NOT NULL,
   meta_key VARCHAR(255) NOT NULL,
   meta_value LONGTEXT NULL,
   PRIMARY KEY  (id),
-  KEY contract_key (contract_id, meta_key(100))
+  KEY meta_key_value (meta_key(50), meta_value(20)),
+  KEY contract_meta_key_value (contract_id, meta_key(100), meta_value({$meta_value_index_length}))
 ) {$collate};";

 		// Immutable billing records. A chain is the pair `(contract_id, kind)` - there is
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php
new file mode 100644
index 00000000000..ad8aa3a6cae
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php
@@ -0,0 +1,236 @@
+<?php
+/**
+ * Argument validators shared by the public write facades.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Support
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Support;
+
+use DateTimeImmutable;
+use DateTimeInterface;
+use DateTimeZone;
+use InvalidArgumentException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\MoneyScale;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Validate caller argument values and return them normalized.
+ *
+ * @internal Engine implementation detail shared by the `Api\` write facades, not part of the public API.
+ */
+final class ArgumentValidator {
+
+	/**
+	 * Keep the keys in `$allowed`; each other key raises a `_doing_it_wrong()` notice and is dropped.
+	 *
+	 * @param string                   $method  Public facade method, for the notice.
+	 * @param array<int|string, mixed> $args    Caller arguments.
+	 * @param array<string, true>      $allowed Accepted keys, as a key map.
+	 * @param string                   $what    What the keys belong to, for the notice.
+	 * @return array<string, mixed> The arguments with known keys only.
+	 */
+	public static function filter_known_keys( string $method, array $args, array $allowed, string $what = 'key' ): array {
+		$filtered = array();
+		foreach ( $args as $key => $value ) {
+			if ( isset( $allowed[ $key ] ) ) {
+				$filtered[ (string) $key ] = $value;
+				continue;
+			}
+
+			_doing_it_wrong(
+				esc_html( $method ),
+				sprintf( 'Unknown %s "%s" ignored.', esc_html( $what ), esc_html( (string) $key ) ),
+				'0.0.1'
+			);
+		}
+
+		return $filtered;
+	}
+
+	/**
+	 * Validate and return a currency code, or null.
+	 *
+	 * @param mixed $value Caller value.
+	 * @throws InvalidArgumentException If the value is not null or a three-letter uppercase code.
+	 */
+	public static function validate_currency( $value ): ?string {
+		if ( null !== $value && ( ! is_string( $value ) || 1 !== preg_match( '/^[A-Z]{3}$/', $value ) ) ) {
+			throw new InvalidArgumentException( '"currency" must be null or a three-letter uppercase ISO-4217 code.' );
+		}
+
+		return $value;
+	}
+
+	/**
+	 * Validate and return a string.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 * @throws InvalidArgumentException If the value is not a string.
+	 */
+	public static function validate_string( string $key, $value ): string {
+		if ( ! is_string( $value ) ) {
+			throw new InvalidArgumentException( sprintf( '"%s" must be a string.', esc_html( $key ) ) );
+		}
+
+		return $value;
+	}
+
+	/**
+	 * Validate and return a string, or null.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 * @throws InvalidArgumentException If the value is not null or a string.
+	 */
+	public static function validate_nullable_string( string $key, $value ): ?string {
+		if ( null !== $value && ! is_string( $value ) ) {
+			throw new InvalidArgumentException( sprintf( '"%s" must be null or a string.', esc_html( $key ) ) );
+		}
+
+		return $value;
+	}
+
+	/**
+	 * Validate and return a positive integer id (a digit string is cast), or null.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 * @throws InvalidArgumentException If the value is not null or a positive integer.
+	 */
+	public static function validate_nullable_id( string $key, $value ): ?int {
+		if ( null === $value ) {
+			return null;
+		}
+
+		if ( is_string( $value ) && 1 === preg_match( '/^[0-9]+$/', $value ) ) {
+			$value = (int) $value;
+		}
+
+		if ( ! is_int( $value ) || $value <= 0 ) {
+			throw new InvalidArgumentException( sprintf( '"%s" must be null or a positive integer.', esc_html( $key ) ) );
+		}
+
+		return $value;
+	}
+
+	/**
+	 * Validate a GMT datetime, or null, and return it as a UTC `Y-m-d H:i:s` string.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value `DateTimeInterface`, GMT `Y-m-d H:i:s` string, or null.
+	 * @throws InvalidArgumentException If the value is not a valid datetime.
+	 */
+	public static function validate_nullable_date( string $key, $value ): ?string {
+		if ( null === $value ) {
+			return null;
+		}
+
+		if ( $value instanceof DateTimeInterface ) {
+			return ( new DateTimeImmutable( '@' . $value->getTimestamp() ) )->format( 'Y-m-d H:i:s' );
+		}
+
+		if ( is_string( $value ) ) {
+			$parsed = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $value, new DateTimeZone( 'UTC' ) );
+			if ( false !== $parsed && $parsed->format( 'Y-m-d H:i:s' ) === $value ) {
+				return $value;
+			}
+		}
+
+		throw new InvalidArgumentException( sprintf( '"%s" must be null, a DateTimeInterface, or a GMT "Y-m-d H:i:s" string.', esc_html( $key ) ) );
+	}
+
+	/**
+	 * Validate a money value and return it normalized to the storage scale; null is 0.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Number, numeric string, or null.
+	 * @throws InvalidArgumentException If the value is not numeric.
+	 */
+	public static function validate_money( string $key, $value ): string {
+		if ( null === $value ) {
+			return MoneyScale::normalize_money( 0 );
+		}
+
+		if ( ! is_int( $value ) && ! is_float( $value ) && ! ( is_string( $value ) && is_numeric( $value ) ) ) {
+			throw new InvalidArgumentException( sprintf( '"%s" must be a number or a numeric string.', esc_html( $key ) ) );
+		}
+
+		return MoneyScale::normalize_money( $value );
+	}
+
+	/**
+	 * Validate a list of arrays and return it with string-keyed rows.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 * @return array<int, array<string, mixed>>
+	 * @throws InvalidArgumentException If the value is not a list of arrays.
+	 */
+	public static function validate_list_of_arrays( string $key, $value ): array {
+		if ( ! is_array( $value ) || ( array() !== $value && array_keys( $value ) !== range( 0, count( $value ) - 1 ) ) ) {
+			throw new InvalidArgumentException( sprintf( '"%s" must be a list of arrays.', esc_html( $key ) ) );
+		}
+
+		$rows = array();
+		foreach ( $value as $row ) {
+			if ( ! is_array( $row ) ) {
+				throw new InvalidArgumentException( sprintf( '"%s" must be a list of arrays.', esc_html( $key ) ) );
+			}
+			$rows[] = Coercion::coerce_string_keyed( $row );
+		}
+
+		return $rows;
+	}
+
+	/**
+	 * Validate contract item rows; unknown row keys are dropped with a notice.
+	 *
+	 * @param mixed  $value         Caller value.
+	 * @param string $function_name Function named in the notice; defaults to this method.
+	 * @return array<int, array<string, mixed>>
+	 * @throws InvalidArgumentException If the value is not a list of item rows.
+	 */
+	public static function validate_contract_items( $value, string $function_name = __METHOD__ ): array {
+		$allowed = array_fill_keys( Contract::ITEM_FIELDS, true );
+		$rows    = array();
+		foreach ( self::validate_list_of_arrays( 'items', $value ) as $row ) {
+			$rows[] = self::filter_known_keys( $function_name, $row, $allowed, 'item key' );
+		}
+
+		return $rows;
+	}
+
+	/**
+	 * Validate contract addresses keyed `billing` / `shipping`; unknown address keys are
+	 * dropped with a notice.
+	 *
+	 * @param mixed  $value         Caller value.
+	 * @param string $function_name Function named in the notice; defaults to this method.
+	 * @return array<string, array<string, mixed>>
+	 * @throws InvalidArgumentException If the map is not keyed `billing` / `shipping` with array values.
+	 */
+	public static function validate_contract_addresses( $value, string $function_name = __METHOD__ ): array {
+		if ( ! is_array( $value ) ) {
+			throw new InvalidArgumentException( '"addresses" must be an array keyed "billing" / "shipping".' );
+		}
+
+		$allowed   = array_fill_keys( Contract::ADDRESS_FIELDS, true );
+		$addresses = array();
+		foreach ( $value as $type => $address ) {
+			if ( ! in_array( $type, array( Contract::ADDRESS_BILLING, Contract::ADDRESS_SHIPPING ), true ) || ! is_array( $address ) ) {
+				throw new InvalidArgumentException( '"addresses" must be an array keyed "billing" / "shipping" with array values.' );
+			}
+
+			$addresses[ $type ] = self::filter_known_keys( $function_name, $address, $allowed, 'address key' );
+		}
+
+		return $addresses;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
new file mode 100644
index 00000000000..e7ff1730f5c
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
@@ -0,0 +1,1435 @@
+<?php
+/**
+ * Integration tests for the public Contracts facade.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use DomainException;
+use EngineIntegrationTestCase;
+use InvalidArgumentException;
+use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\CycleView;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts
+ */
+class ContractsTest extends EngineIntegrationTestCase {
+
+	private const EXTENSION_SLUG = 'acme-subs';
+
+	public function tear_down(): void {
+		StatusRegistry::reset();
+		parent::tear_down();
+	}
+
+	/**
+	 * Read a contract back as a view, asserting it exists.
+	 *
+	 * @param int $id Contract id.
+	 */
+	private function view( int $id ): ContractView {
+		$view = Contracts::get( $id );
+		$this->assertInstanceOf( ContractView::class, $view );
+
+		return $view;
+	}
+
+	/**
+	 * Read a contract back as the entity, asserting it exists.
+	 *
+	 * @param int $id Contract id.
+	 */
+	private function entity( int $id ): Contract {
+		$contract = ( new ContractRepository() )->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+
+		return $contract;
+	}
+
+	public function test_an_extension_slug_only_create_is_an_empty_draft(): void {
+		$created = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) );
+		$id      = $created->get_id();
+
+		$view = $this->view( $id );
+		$this->assertEquals( $view, $created, 'The returned view matches a fresh read.' );
+		$this->assertSame( ContractStatus::DRAFT, $view->get_status() );
+		$this->assertSame( self::EXTENSION_SLUG, $view->get_extension_slug() );
+		$this->assertNull( $view->get_customer_id() );
+		$this->assertNull( $view->get_currency() );
+		$this->assertNull( $view->get_selling_plan_id() );
+		$this->assertNull( $view->get_start_gmt() );
+		$this->assertNull( $view->get_next_payment_gmt() );
+		$this->assertSame( '0.00000000', $view->get_billing_total() );
+		$this->assertSame( '0.00000000', $view->get_tax_total() );
+		$this->assertSame( array(), $view->get_items() );
+	}
+
+	public function test_create_stores_and_returns_every_field(): void {
+		$created = Contracts::create(
+			array(
+				'extension_slug'       => self::EXTENSION_SLUG,
+				'status'               => ContractStatus::ACTIVE,
+				'customer_id'          => '12',
+				'currency'             => 'EUR',
+				'selling_plan_id'      => 3,
+				'origin_order_id'      => 4,
+				'payment_method'       => 'dummy',
+				'payment_method_title' => 'Dummy',
+				'payment_token_id'     => 5,
+				'start_gmt'            => '2026-01-01 00:00:00',
+				'next_payment_gmt'     => '2026-02-01 00:00:00',
+				'last_payment_gmt'     => '2026-01-02 00:00:00',
+				'last_attempt_gmt'     => '2026-01-03 00:00:00',
+				'trial_end_gmt'        => '2026-01-04 00:00:00',
+				'end_gmt'              => '2027-01-01 00:00:00',
+				'schedule_source'      => Contract::SCHEDULE_SOURCE_GATEWAY,
+				'billing_total'        => 20,
+				'discount_total'       => '1.5',
+				'shipping_total'       => 5.25,
+				'tax_total'            => '2',
+				'items'                => array(
+					array(
+						'item_name'  => 'Coffee',
+						'product_id' => 9,
+						'quantity'   => '2',
+						'total'      => '20',
+					),
+				),
+				'addresses'            => array(
+					'billing' => array(
+						'first_name' => 'Ada',
+						'country'    => 'PT',
+					),
+				),
+			)
+		);
+
+		$id = $created->get_id();
+		$this->assertGreaterThan( 0, $id );
+
+		foreach ( array( $created, $this->view( $id ) ) as $view ) {
+			$this->assertSame( ContractStatus::ACTIVE, $view->get_status() );
+			$this->assertSame( 12, $view->get_customer_id() );
+			$this->assertSame( 'EUR', $view->get_currency() );
+			$this->assertSame( 3, $view->get_selling_plan_id() );
+			$this->assertSame( 4, $view->get_origin_order_id() );
+			$this->assertSame( 'dummy', $view->get_payment_method() );
+			$this->assertSame( 'Dummy', $view->get_payment_method_title() );
+			$this->assertSame( 5, $view->get_payment_token_id() );
+			$this->assertSame( '2026-01-01 00:00:00', $view->get_start_gmt() );
+			$this->assertSame( '2026-02-01 00:00:00', $view->get_next_payment_gmt() );
+			$this->assertSame( '2026-01-02 00:00:00', $view->get_last_payment_gmt() );
+			$this->assertSame( '2026-01-03 00:00:00', $view->get_last_attempt_gmt() );
+			$this->assertSame( '2026-01-04 00:00:00', $view->get_trial_end_gmt() );
+			$this->assertSame( '2027-01-01 00:00:00', $view->get_end_gmt() );
+			$this->assertSame( Contract::SCHEDULE_SOURCE_GATEWAY, $view->get_schedule_source() );
+			$this->assertSame( '20.00000000', $view->get_billing_total() );
+			$this->assertSame( '1.50000000', $view->get_discount_total() );
+			$this->assertSame( '5.25000000', $view->get_shipping_total() );
+			$this->assertSame( '2.00000000', $view->get_tax_total() );
+
+			$items = $view->get_items();
+			$this->assertIsArray( $items );
+			$this->assertCount( 1, $items );
+			$this->assertSame( 'Coffee', $items[0]['item_name'] );
+
+			$addresses = $view->get_addresses();
+			$this->assertIsArray( $addresses );
+			$this->assertSame( 'Ada', $addresses['billing']['first_name'] ?? null );
+		}
+	}
+
+	public function test_items_and_addresses_read_back_in_the_written_shape(): void {
+		$items     = array(
+			array(
+				'item_name'    => 'Coffee',
+				'item_type'    => 'line_item',
+				'product_id'   => 9,
+				'variation_id' => 10,
+				'quantity'     => '2.0000',
+				'subtotal'     => '20.00000000',
+				'total'        => '18.00000000',
+				'taxes'        => array(
+					'total'    => array( 1 => '1.80' ),
+					'subtotal' => array( 1 => '2.00' ),
+				),
+			),
+		);
+		$billing   = array_fill_keys( Contract::ADDRESS_FIELDS, null );
+		$addresses = array(
+			'billing'  => array_merge(
+				$billing,
+				array(
+					'first_name' => 'Ada',
+					'country'    => 'PT',
+				)
+			),
+			'shipping' => array_merge( $billing, array( 'city' => 'Porto' ) ),
+		);
+
+		$created = Contracts::create(
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'items'          => $items,
+				'addresses'      => $addresses,
+			)
+		);
+		$id      = $created->get_id();
+
+		$view = $this->view( $id );
+		$this->assertEquals( $view, $created, 'The returned view matches a fresh read.' );
+		$this->assertSame( $items, $view->get_items() );
+		$this->assertEquals( $addresses, $view->get_addresses() );
+
+		$listed = Contracts::list( array( 'search' => (string) $id ) );
+		$this->assertSame( array( $id ), array_map( static fn( ContractView $row ): int => $row->get_id(), $listed ) );
+		$this->assertNull( $listed[0]->get_items() );
+		$this->assertNull( $listed[0]->get_addresses() );
+	}
+
+	public function test_datetime_objects_are_stored_as_utc_strings(): void {
+		$id = Contracts::create(
+			array(
+				'extension_slug'   => self::EXTENSION_SLUG,
+				'start_gmt'        => new DateTimeImmutable( '2026-01-01 02:00:00', new DateTimeZone( 'Europe/Lisbon' ) ),
+				'next_payment_gmt' => new DateTimeImmutable( '2026-07-01 02:00:00', new DateTimeZone( 'Europe/Lisbon' ) ),
+			)
+		)->get_id();
+
+		$view = $this->view( $id );
+		$this->assertSame( '2026-01-01 02:00:00', $view->get_start_gmt() );
+		$this->assertSame( '2026-07-01 01:00:00', $view->get_next_payment_gmt() );
+	}
+
+	public function test_an_unknown_key_is_ignored_with_a_notice(): void {
+		$this->setExpectedIncorrectUsage( Contracts::class . '::create' );
+		$messages = array();
+		add_action(
+			'doing_it_wrong_run',
+			static function ( $function_name, $message ) use ( &$messages ): void {
+				$messages[] = $message;
+			},
+			10,
+			2
+		);
+
+		$id = Contracts::create(
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'custmer_id'     => 1,
+				'customer_id'    => 7,
+			)
+		)->get_id();
+
+		$this->assertSame( array( 'Unknown key "custmer_id" ignored.' ), $messages );
+		$this->assertSame( 7, $this->view( $id )->get_customer_id(), 'The known key beside the unknown one is written.' );
+	}
+
+	public function test_unknown_item_and_address_keys_are_ignored_with_a_notice(): void {
+		$this->setExpectedIncorrectUsage( Contracts::class );
+
+		$id = Contracts::create(
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'items'          => array(
+					array(
+						'item_name' => 'Coffee',
+						'price'     => '1',
+					),
+				),
+				'addresses'      => array(
+					'billing' => array(
+						'first_name' => 'Ada',
+						'zip'        => '1',
+					),
+				),
+			)
+		)->get_id();
+
+		$contract = $this->entity( $id );
+		$items    = $contract->get_items();
+		$this->assertCount( 1, $items );
+		$this->assertSame( 'Coffee', $items[0]['item_name'] );
+		$this->assertArrayNotHasKey( 'price', $items[0] );
+		$billing = $contract->get_addresses()['billing'];
+		$this->assertSame( 'Ada', $billing['first_name'] );
+		$this->assertArrayNotHasKey( 'zip', $billing );
+	}
+
+	/**
+	 * @dataProvider provide_bad_extension_slugs
+	 *
+	 * @param array<string, mixed> $args Create args.
+	 */
+	public function test_a_missing_or_empty_extension_slug_is_rejected( array $args ): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		Contracts::create( $args );
+	}
+
+	/**
+	 * @return array<string, array{0: array<string, mixed>}>
+	 */
+	public function provide_bad_extension_slugs(): array {
+		return array(
+			'missing'    => array( array() ),
+			'empty'      => array( array( 'extension_slug' => '' ) ),
+			'not string' => array( array( 'extension_slug' => 5 ) ),
+		);
+	}
+
+	public function test_an_entity_invariant_failure_is_reported_as_invalid_input(): void {
+		try {
+			Contracts::create(
+				array(
+					'extension_slug' => self::EXTENSION_SLUG,
+					'status'         => 'nonsense',
+				)
+			);
+			$this->fail( 'Expected an InvalidArgumentException.' );
+		} catch ( InvalidArgumentException $e ) {
+			$this->assertInstanceOf( DomainException::class, $e->getPrevious() );
+			$this->assertSame( $e->getPrevious()->getMessage(), $e->getMessage() );
+		}
+	}
+
+	/**
+	 * @dataProvider provide_invalid_fields
+	 *
+	 * @param array<string, mixed> $fields Invalid fields.
+	 */
+	public function test_invalid_values_are_rejected_without_a_write( array $fields ): void {
+		$before = Contracts::count();
+
+		try {
+			Contracts::create( array_merge( array( 'extension_slug' => self::EXTENSION_SLUG ), $fields ) );
+			$this->fail( 'Expected an InvalidArgumentException.' );
+		} catch ( InvalidArgumentException $e ) {
+			$this->assertSame( $before, Contracts::count(), 'Nothing is written.' );
+		}
+	}
+
+	/**
+	 * @return array<string, array{0: array<string, mixed>}>
+	 */
+	public function provide_invalid_fields(): array {
+		return array(
+			'unregistered status'    => array( array( 'status' => 'nonsense' ) ),
+			'lowercase currency'     => array( array( 'currency' => 'usd' ) ),
+			'long currency'          => array( array( 'currency' => 'USDX' ) ),
+			'zero customer'          => array( array( 'customer_id' => 0 ) ),
+			'negative plan'          => array( array( 'selling_plan_id' => -3 ) ),
+			'non-numeric token'      => array( array( 'payment_token_id' => 'abc' ) ),
+			'malformed date'         => array( array( 'start_gmt' => '2026-01-01' ) ),
+			'impossible date'        => array( array( 'end_gmt' => '2026-02-30 00:00:00' ) ),
+			'non-numeric money'      => array(
+				array(
+					'currency'      => 'USD',
+					'billing_total' => 'ten',
+				),
+			),
+			'money without currency' => array( array( 'billing_total' => '10' ) ),
+			'bad schedule source'    => array( array( 'schedule_source' => 'cron' ) ),
+			'non-string method'      => array( array( 'payment_method' => 5 ) ),
+			'items not a list'       => array( array( 'items' => array( 'a' => array() ) ) ),
+			'unknown address type'   => array( array( 'addresses' => array( 'home' => array() ) ) ),
+		);
+	}
+
+	/**
+	 * A rejected update writes none of its keys, including the valid ones beside the bad one.
+	 *
+	 * @dataProvider provide_invalid_update_fields
+	 *
+	 * @param array<string, mixed> $fields Invalid fields.
+	 */
+	public function test_a_rejected_update_writes_none_of_its_keys( array $fields ): void {
+		$id = Contracts::create(
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'currency'       => 'USD',
+				'payment_method' => 'dummy',
+				'billing_total'  => '10',
+			)
+		)->get_id();
+
+		try {
+			Contracts::update(
+				$id,
+				array_merge(
+					array(
+						'payment_method' => 'other',
+						'billing_total'  => '25',
+					),
+					$fields
+				)
+			);
+			$this->fail( 'Expected an InvalidArgumentException.' );
+		} catch ( InvalidArgumentException $e ) {
+			$view = $this->view( $id );
+			$this->assertSame( 'dummy', $view->get_payment_method(), 'The valid key beside the bad one is not written.' );
+			$this->assertSame( '10.00000000', $view->get_billing_total() );
+		}
+	}
+
+	/**
+	 * @return array<string, array{0: array<string, mixed>}>
+	 */
+	public function provide_invalid_update_fields(): array {
+		return array(
+			'unregistered status' => array( array( 'status' => 'nonsense' ) ),
+			'malformed date'      => array( array( 'end_gmt' => '2026-01-01' ) ),
+		);
+	}
+
+	public function test_money_without_currency_is_rejected_on_update(): void {
+		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+
+		$this->expectException( InvalidArgumentException::class );
+
+		Contracts::update( $id, array( 'billing_total' => '10' ) );
+	}
+
+	public function test_a_null_money_value_resets_to_zero_without_a_currency(): void {
+		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+
+		$this->assertInstanceOf( ContractView::class, Contracts::update( $id, array( 'billing_total' => null ) ) );
+		$this->assertSame( '0.00000000', $this->view( $id )->get_billing_total() );
+	}
+
+	public function test_the_currency_cannot_be_cleared_while_a_total_is_non_zero(): void {
+		$id = Contracts::create(
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'currency'       => 'USD',
+				'billing_total'  => '10',
+			)
+		)->get_id();
+
+		try {
+			Contracts::update( $id, array( 'currency' => null ) );
+			$this->fail( 'Expected an InvalidArgumentException.' );
+		} catch ( InvalidArgumentException $e ) {
+			$this->assertSame( 'USD', $this->view( $id )->get_currency() );
+		}
+
+		// Clearing the totals in the same call is allowed.
+		$this->assertInstanceOf(
+			ContractView::class,
+			Contracts::update(
+				$id,
+				array(
+					'currency'      => null,
+					'billing_total' => null,
+				)
+			)
+		);
+		$this->assertNull( $this->view( $id )->get_currency() );
+	}
+
+	public function test_progressive_update_builds_the_contract(): void {
+		$customer = self::factory()->user->create();
+		$this->assertIsInt( $customer );
+
+		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+
+		$this->assertInstanceOf( ContractView::class, Contracts::update( $id, array( 'customer_id' => $customer ) ) );
+		$this->assertInstanceOf(
+			ContractView::class,
+			Contracts::update(
+				$id,
+				array(
+					'currency'      => 'USD',
+					'billing_total' => '19.99',
+				)
+			)
+		);
+		$updated = Contracts::update( $id, array( 'status' => ContractStatus::ACTIVE ) );
+		$this->assertInstanceOf( ContractView::class, $updated );
+		$this->assertEquals( $this->view( $id ), $updated, 'The returned view matches a fresh read.' );
+
+		$view = $this->view( $id );
+		$this->assertSame( $customer, $view->get_customer_id() );
+		$this->assertSame( 'USD', $view->get_currency() );
+		$this->assertSame( '19.99000000', $view->get_billing_total() );
+		$this->assertSame( ContractStatus::ACTIVE, $view->get_status() );
+		$this->assertSame( self::EXTENSION_SLUG, $view->get_extension_slug() );
+	}
+
+	public function test_update_keeps_unmentioned_payment_fields(): void {
+		$id = Contracts::create(
+			array(
+				'extension_slug'       => self::EXTENSION_SLUG,
+				'payment_method'       => 'dummy',
+				'payment_method_title' => 'Dummy',
+				'payment_token_id'     => 5,
+			)
+		)->get_id();
+
+		Contracts::update( $id, array( 'payment_token_id' => 6 ) );
+
+		$view = $this->view( $id );
+		$this->assertSame( 'dummy', $view->get_payment_method() );
+		$this->assertSame( 'Dummy', $view->get_payment_method_title() );
+		$this->assertSame( 6, $view->get_payment_token_id() );
+	}
+
+	public function test_update_with_no_fields_returns_the_view(): void {
+		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+
+		$updated = Contracts::update( $id, array() );
+
+		$this->assertInstanceOf( ContractView::class, $updated );
+		$this->assertSame( $id, $updated->get_id() );
+	}
+
+	public function test_update_returns_null_when_the_contract_is_deleted_before_the_write(): void {
+		global $wpdb;
+
+		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+
+		// A concurrent delete lands after the facade read the contract and before its write.
+		$table    = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$injected = false;
+		$race     = static function ( string $query ) use ( &$injected, $table, $id, $wpdb ): string {
+			if ( ! $injected && 0 === strpos( $query, "UPDATE `{$table}`" ) ) {
+				$injected = true;
+				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+				$wpdb->delete( $table, array( 'id' => $id ) );
+			}
+
+			return $query;
+		};
+		add_filter( 'query', $race );
+
+		try {
+			$updated = Contracts::update( $id, array( 'payment_method_title' => 'X' ) );
+		} finally {
+			remove_filter( 'query', $race );
+		}
+
+		$this->assertTrue( $injected );
+		$this->assertNull( $updated );
+	}
+
+	public function test_update_does_not_revert_a_concurrent_write_to_other_fields(): void {
+		global $wpdb;
+
+		$id = Contracts::create(
+			array(
+				'extension_slug'   => self::EXTENSION_SLUG,
+				'status'           => ContractStatus::ACTIVE,
+				'next_payment_gmt' => '2026-02-01 00:00:00',
+			)
+		)->get_id();
+
+		// A concurrent writer (e.g. a cancel compare-and-set) lands after the facade
+		// read the contract and before its own write.
+		$table    = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$injected = false;
+		$race     = static function ( string $query ) use ( &$injected, $table, $id, $wpdb ): string {
+			if ( ! $injected && 0 === strpos( $query, "UPDATE `{$table}`" ) ) {
+				$injected = true;
+				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+				$wpdb->update(
+					$table,
+					array(
+						'status'           => ContractStatus::CANCELLED,
+						'next_payment_gmt' => null,
+					),
+					array( 'id' => $id )
+				);
+			}
+
+			return $query;
+		};
+		add_filter( 'query', $race );
+
+		try {
+			$updated = Contracts::update( $id, array( 'payment_method_title' => 'X' ) );
+		} finally {
+			remove_filter( 'query', $race );
+		}
+
+		$this->assertTrue( $injected );
+		$this->assertInstanceOf( ContractView::class, $updated );
+		$this->assertSame( 'X', $updated->get_payment_method_title() );
+		$this->assertSame( ContractStatus::ACTIVE, $updated->get_status(), 'The returned view keeps the pre-write read of other columns.' );
+		$view = $this->view( $id );
+		$this->assertSame( 'X', $view->get_payment_method_title() );
+		$this->assertSame( ContractStatus::CANCELLED, $view->get_status() );
+		$this->assertNull( $view->get_next_payment_gmt() );
+	}
+
+	public function test_items_and_addresses_are_replaced_as_a_whole(): void {
+		$id = Contracts::create(
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'items'          => array( array( 'item_name' => 'Coffee' ), array( 'item_name' => 'Tea' ) ),
+				'addresses'      => array(
+					'billing'  => array( 'city' => 'Lisbon' ),
+					'shipping' => array( 'city' => 'Porto' ),
+				),
+			)
+		)->get_id();
+
+		$updated = Contracts::update(
+			$id,
+			array(
+				'items'     => array( array( 'item_name' => 'Cocoa' ) ),
+				'addresses' => array( 'billing' => array( 'city' => 'Faro' ) ),
+			)
+		);
+		$this->assertInstanceOf( ContractView::class, $updated );
+
+		foreach ( array( $updated, $this->view( $id ) ) as $view ) {
+			$items = $view->get_items();
+			$this->assertIsArray( $items );
+			$this->assertSame( array( 'Cocoa' ), array_column( $items, 'item_name' ) );
+			$addresses = $view->get_addresses();
+			$this->assertIsArray( $addresses );
+			$this->assertSame( array( 'billing' ), array_keys( $addresses ) );
+			$this->assertSame( 'Faro', $addresses['billing']['city'] ?? null );
+		}
+	}
+
+	/**
+	 * Run a contract write while every insert into a child table fails, asserting it throws.
+	 *
+	 * @param string   $child_table Child table constant of SchemaInstaller.
+	 * @param callable $write       The facade write; must throw rather than return.
+	 */
+	private function assert_write_throws_when_child_inserts_fail( string $child_table, callable $write ): void {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( $child_table );
+		$break = static function ( string $query ) use ( $table ): string {
+			return 0 === strpos( $query, "INSERT INTO `{$table}`" ) ? 'SELECT broken syntax (' : $query;
+		};
+		add_filter( 'query', $break );
+		$suppressed = $wpdb->suppress_errors( true );
+
+		try {
+			$write();
+			$this->fail( 'Expected the write to throw instead of returning a view.' );
+		} catch ( \RuntimeException $e ) {
+			$this->assertStringContainsString( 'Failed to insert', $e->getMessage() );
+		} finally {
+			$wpdb->suppress_errors( $suppressed );
+			remove_filter( 'query', $break );
+		}
+	}
+
+	public function test_create_throws_when_an_item_insert_fails(): void {
+		$this->assert_write_throws_when_child_inserts_fail(
+			SchemaInstaller::TABLE_CONTRACT_ITEMS,
+			static function () {
+				return Contracts::create(
+					array(
+						'extension_slug' => self::EXTENSION_SLUG,
+						'items'          => array( array( 'item_name' => 'Coffee' ) ),
+					)
+				);
+			}
+		);
+	}
+
+	public function test_update_throws_when_an_item_insert_fails(): void {
+		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+
+		$this->assert_write_throws_when_child_inserts_fail(
+			SchemaInstaller::TABLE_CONTRACT_ITEMS,
+			static function () use ( $id ) {
+				return Contracts::update( $id, array( 'items' => array( array( 'item_name' => 'Cocoa' ) ) ) );
+			}
+		);
+	}
+
+	public function test_update_throws_when_an_address_insert_fails(): void {
+		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+
+		$this->assert_write_throws_when_child_inserts_fail(
+			SchemaInstaller::TABLE_CONTRACT_ADDRESSES,
+			static function () use ( $id ) {
+				return Contracts::update( $id, array( 'addresses' => array( 'billing' => array( 'city' => 'Faro' ) ) ) );
+			}
+		);
+	}
+
+	public function test_null_clears_nullable_fields(): void {
+		$id = Contracts::create(
+			array(
+				'extension_slug'   => self::EXTENSION_SLUG,
+				'selling_plan_id'  => 3,
+				'next_payment_gmt' => '2026-02-01 00:00:00',
+			)
+		)->get_id();
+
+		Contracts::update(
+			$id,
+			array(
+				'selling_plan_id'  => null,
+				'next_payment_gmt' => null,
+			)
+		);
+
+		$view = $this->view( $id );
+		$this->assertNull( $view->get_selling_plan_id() );
+		$this->assertNull( $view->get_next_payment_gmt() );
+	}
+
+	public function test_update_on_an_unknown_contract_returns_null(): void {
+		$this->assertNull( Contracts::update( 999999, array( 'status' => ContractStatus::ACTIVE ) ) );
+	}
+
+	public function test_an_unknown_update_key_is_ignored_with_a_notice(): void {
+		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		$this->setExpectedIncorrectUsage( Contracts::class . '::update' );
+		$this->setExpectedIncorrectUsage( Contracts::class );
+
+		$this->assertInstanceOf(
+			ContractView::class,
+			Contracts::update(
+				$id,
+				array(
+					'nonsense'       => 1,
+					'payment_method' => 'dummy',
+					'items'          => array(
+						array(
+							'item_name' => 'Coffee',
+							'price'     => '1',
+						),
+					),
+				)
+			)
+		);
+
+		$this->assertSame( 'dummy', $this->view( $id )->get_payment_method(), 'The known key beside the unknown one is written.' );
+		$items = $this->entity( $id )->get_items();
+		$this->assertSame( 'Coffee', $items[0]['item_name'] );
+		$this->assertArrayNotHasKey( 'price', $items[0] );
+	}
+
+	public function test_extension_slug_is_not_an_update_key(): void {
+		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		$this->setExpectedIncorrectUsage( Contracts::class . '::update' );
+
+		Contracts::update(
+			$id,
+			array(
+				'extension_slug' => 'other',
+				'customer_id'    => 7,
+			)
+		);
+
+		$view = $this->view( $id );
+		$this->assertSame( self::EXTENSION_SLUG, $view->get_extension_slug(), 'The extension slug is not rewritten.' );
+		$this->assertSame( 7, $view->get_customer_id() );
+	}
+
+	public function test_snapshot_keys_are_ignored_with_a_notice(): void {
+		$this->setExpectedIncorrectUsage( Contracts::class . '::create' );
+		$messages = array();
+		add_action(
+			'doing_it_wrong_run',
+			static function ( $function_name, $message ) use ( &$messages ): void {
+				$messages[] = $message;
+			},
+			10,
+			2
+		);
+
+		$id = Contracts::create(
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'plan_snapshot'  => array( 'selling_plan_id' => 3 ),
+				'items_snapshot' => array( array( 'item_name' => 'Coffee' ) ),
+			)
+		)->get_id();
+
+		$this->assertSame(
+			array( 'Unknown key "plan_snapshot" ignored.', 'Unknown key "items_snapshot" ignored.' ),
+			$messages
+		);
+		$this->assertNull( $this->entity( $id )->get_plan_snapshot_id() );
+		$this->assertNull( $this->entity( $id )->get_items_snapshot_id() );
+	}
+
+	public function test_a_registered_extension_status_is_accepted(): void {
+		StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+
+		$id = Contracts::create(
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'status'         => 'paused-by-merchant',
+			)
+		)->get_id();
+
+		$this->assertSame( 'paused-by-merchant', $this->view( $id )->get_status() );
+	}
+
+	/**
+	 * Create a contract carrying a currency, ready for cycles.
+	 */
+	private function contract_with_currency(): int {
+		return Contracts::create(
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'currency'       => 'USD',
+			)
+		)->get_id();
+	}
+
+	/**
+	 * Fetch a stored cycle by id.
+	 *
+	 * @param int $contract_id Contract id.
+	 * @param int $cycle_id    Cycle id.
+	 */
+	private function cycle( int $contract_id, int $cycle_id ): Cycle {
+		foreach ( ( new ContractRepository() )->find_cycle_history( $contract_id ) as $cycle ) {
+			if ( $cycle->get_id() === $cycle_id ) {
+				return $cycle;
+			}
+		}
+
+		$this->fail( "Cycle {$cycle_id} not found." );
+	}
+
+	/**
+	 * Minimal valid cycle args.
+	 *
+	 * @param array<string, mixed> $overrides Extra or replacement keys.
+	 * @return array<string, mixed>
+	 */
+	private function cycle_args( array $overrides = array() ): array {
+		return array_merge(
+			array(
+				'status'        => CycleStatus::BILLED,
+				'starts_at_gmt' => '2026-01-01 00:00:00',
+				'ends_at_gmt'   => '2026-02-01 00:00:00',
+				'currency'      => 'USD',
+			),
+			$overrides
+		);
+	}
+
+	public function test_the_first_cycle_takes_the_chain_defaults(): void {
+		$id = $this->contract_with_currency();
+
+		$cycle_id = Contracts::add_cycle( $id, $this->cycle_args( array( 'order_id' => 77 ) ) )->get_id();
+		$cycle    = $this->cycle( $id, $cycle_id );
+
+		$this->assertSame( Cycle::KIND_BILLING, $cycle->get_kind() );
+		$this->assertSame( 1, $cycle->get_sequence_no() );
+		$this->assertNull( $cycle->get_count(), 'The engine never assigns a count.' );
+		$this->assertSame( 'USD', $cycle->get_currency() );
+		$this->assertSame( '0.00000000', $cycle->get_expected_total() );
+		$this->assertSame( 77, $cycle->get_order_id() );
+		$this->assertNull( $cycle->get_extension_slug(), 'The contract is not read, so its owner is not copied.' );
+		$this->assertNull( $cycle->get_plan_snapshot_id() );
+		$this->assertNull( $cycle->get_items_snapshot_id() );
+	}
+
+	public function test_the_next_cycle_defaults_to_the_next_position(): void {
+		$id = $this->contract_with_currency();
+		Contracts::add_cycle( $id, $this->cycle_args() );
+
+		$cycle_id = Contracts::add_cycle(
+			$id,
+			$this->cycle_args(
+				array(
+					'status'         => CycleStatus::PENDING,
+					'starts_at_gmt'  => '2026-02-01 00:00:00',
+					'ends_at_gmt'    => '2026-03-01 00:00:00',
+					'expected_total' => '19.99',
+				)
+			)
+		)->get_id();
+		$cycle    = $this->cycle( $id, $cycle_id );
+
+		$this->assertSame( 2, $cycle->get_sequence_no() );
+		$this->assertNull( $cycle->get_count() );
+		$this->assertSame( '19.99000000', $cycle->get_expected_total() );
+	}
+
+	public function test_a_non_counting_cycle_takes_a_null_count(): void {
+		$id = $this->contract_with_currency();
+
+		$cycle_id = Contracts::add_cycle( $id, $this->cycle_args( array( 'count' => null ) ) )->get_id();
+
+		$this->assertNull( $this->cycle( $id, $cycle_id )->get_count() );
+	}
+
+	public function test_a_taken_position_is_refused(): void {
+		$id = $this->contract_with_currency();
+		Contracts::add_cycle( $id, $this->cycle_args() );
+
+		$this->expectException( DomainException::class );
+
+		Contracts::add_cycle( $id, $this->cycle_args( array( 'sequence_no' => 1 ) ) );
+	}
+
+	public function test_a_null_sequence_no_takes_the_next_position(): void {
+		$id = $this->contract_with_currency();
+		Contracts::add_cycle( $id, $this->cycle_args() );
+
+		$view = Contracts::add_cycle( $id, $this->cycle_args( array( 'sequence_no' => null ) ) );
+
+		$this->assertSame( 2, $view->get_sequence_no() );
+	}
+
+	public function test_a_taken_count_is_refused(): void {
+		$id = $this->contract_with_currency();
+		Contracts::add_cycle( $id, $this->cycle_args( array( 'count' => 1 ) ) );
+
+		$this->expectException( DomainException::class );
+
+		Contracts::add_cycle( $id, $this->cycle_args( array( 'count' => 1 ) ) );
+	}
+
+	public function test_an_explicit_position_is_kept(): void {
+		$id = $this->contract_with_currency();
+
+		$cycle_id = Contracts::add_cycle(
+			$id,
+			$this->cycle_args(
+				array(
+					'sequence_no' => 5,
+					'count'       => 3,
+				)
+			)
+		)->get_id();
+		$cycle    = $this->cycle( $id, $cycle_id );
+
+		$this->assertSame( 5, $cycle->get_sequence_no() );
+		$this->assertSame( 3, $cycle->get_count() );
+	}
+
+	public function test_another_kind_starts_its_own_chain(): void {
+		$id = $this->contract_with_currency();
+		Contracts::add_cycle( $id, $this->cycle_args() );
+		Contracts::add_cycle( $id, $this->cycle_args() );
+
+		$view = Contracts::add_cycle( $id, $this->cycle_args( array( 'kind' => 'shipping' ) ) );
+
+		$this->assertSame( 'shipping', $view->get_kind() );
+		$this->assertSame( 1, $view->get_sequence_no() );
+	}
+
+	/**
+	 * @dataProvider provide_invalid_cycle_args
+	 *
+	 * @param array<string, mixed> $args Cycle args.
+	 */
+	public function test_invalid_cycle_args_are_rejected( array $args ): void {
+		$id = $this->contract_with_currency();
+
+		try {
+			Contracts::add_cycle( $id, $args );
+			$this->fail( 'Expected an InvalidArgumentException.' );
+		} catch ( InvalidArgumentException $e ) {
+			$this->assertSame( array(), Contracts::get_cycles( $id ), 'No cycle is written.' );
+		}
+	}
+
+	/**
+	 * @return array<string, array{0: array<string, mixed>}>
+	 */
+	public function provide_invalid_cycle_args(): array {
+		return array(
+			'unregistered status'  => array(
+				array(
+					'status'        => 'nonsense',
+					'starts_at_gmt' => '2026-01-01 00:00:00',
+					'ends_at_gmt'   => '2026-02-01 00:00:00',
+				),
+			),
+			'missing starts_at'    => array(
+				array(
+					'status'      => CycleStatus::BILLED,
+					'ends_at_gmt' => '2026-02-01 00:00:00',
+				),
+			),
+			'missing ends_at'      => array(
+				array(
+					'status'        => CycleStatus::BILLED,
+					'starts_at_gmt' => '2026-01-01 00:00:00',
+				),
+			),
+			'zero sequence number' => array(
+				array(
+					'status'        => CycleStatus::BILLED,
+					'starts_at_gmt' => '2026-01-01 00:00:00',
+					'ends_at_gmt'   => '2026-02-01 00:00:00',
+					'sequence_no'   => 0,
+				),
+			),
+			'non-string status'    => array(
+				array(
+					'status'        => array( 'billed' ),
+					'starts_at_gmt' => '2026-01-01 00:00:00',
+					'ends_at_gmt'   => '2026-02-01 00:00:00',
+					'currency'      => 'USD',
+				),
+			),
+			'empty kind'           => array(
+				array(
+					'status'        => CycleStatus::BILLED,
+					'kind'          => '',
+					'starts_at_gmt' => '2026-01-01 00:00:00',
+					'ends_at_gmt'   => '2026-02-01 00:00:00',
+				),
+			),
+		);
+	}
+
+	public function test_add_cycle_defaults_the_status_to_pending(): void {
+		$id = $this->contract_with_currency();
+
+		$cycle = Contracts::add_cycle(
+			$id,
+			array(
+				'starts_at_gmt' => '2026-01-01 00:00:00',
+				'ends_at_gmt'   => '2026-02-01 00:00:00',
+				'currency'      => 'USD',
+			)
+		);
+
+		$this->assertSame( CycleStatus::PENDING, $cycle->get_status() );
+	}
+
+	public function test_an_unknown_cycle_key_is_ignored_with_a_notice(): void {
+		$id = $this->contract_with_currency();
+		$this->setExpectedIncorrectUsage( Contracts::class . '::add_cycle' );
+
+		$cycle_id = Contracts::add_cycle(
+			$id,
+			$this->cycle_args(
+				array(
+					'reason'   => 'x',
+					'order_id' => 77,
+				)
+			)
+		)->get_id();
+
+		$this->assertSame( 77, $this->cycle( $id, $cycle_id )->get_order_id(), 'The known key beside the unknown one is written.' );
+	}
+
+	public function test_a_cycle_needs_a_currency(): void {
+		$id = $this->contract_with_currency();
+
+		$this->expectException( InvalidArgumentException::class );
+
+		Contracts::add_cycle( $id, $this->cycle_args( array( 'currency' => null ) ) );
+	}
+
+	public function test_the_cycle_currency_is_the_given_one(): void {
+		$id = $this->contract_with_currency();
+
+		$cycle_id = Contracts::add_cycle( $id, $this->cycle_args( array( 'currency' => 'EUR' ) ) )->get_id();
+
+		$this->assertSame( 'EUR', $this->cycle( $id, $cycle_id )->get_currency() );
+	}
+
+	public function test_get_cycles_returns_the_appended_cycle_as_a_view(): void {
+		$id      = $this->contract_with_currency();
+		$created = Contracts::add_cycle( $id, $this->cycle_args( array( 'order_id' => 77 ) ) );
+
+		$history = Contracts::get_cycles( $id );
+
+		$this->assertCount( 1, $history );
+		$this->assertEquals( $history[0], $created, 'The returned view matches a fresh read.' );
+		$cycle_id = $created->get_id();
+		$this->assertGreaterThan( 0, $cycle_id );
+		$this->assertInstanceOf( CycleView::class, $history[0] );
+		$this->assertSame( $cycle_id, $history[0]->get_id() );
+		$this->assertSame( CycleStatus::BILLED, $history[0]->get_status() );
+		$this->assertSame( 77, $history[0]->get_order_id() );
+	}
+
+	public function test_meta_keeps_several_values_under_one_key(): void {
+		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+
+		$this->assertIsInt( Contracts::add_meta( $id, 'note', 'one' ) );
+		$this->assertIsInt( Contracts::add_meta( $id, 'note', array( 'two' ) ) );
+
+		$this->assertSame( array( 'one', array( 'two' ) ), Contracts::get_meta( $id, 'note' ) );
+		$this->assertSame( 'one', Contracts::get_meta( $id, 'note', true ) );
+		$this->assertSame( array( 'note' => array( 'one', array( 'two' ) ) ), Contracts::get_meta( $id ) );
+	}
+
+	public function test_a_unique_meta_add_refuses_an_existing_key(): void {
+		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		Contracts::add_meta( $id, 'note', 'one' );
+
+		$this->assertNull( Contracts::add_meta( $id, 'note', 'two', true ) );
+		$this->assertSame( array( 'one' ), Contracts::get_meta( $id, 'note' ) );
+	}
+
+	public function test_update_meta_with_a_previous_value_and_delete_one_value(): void {
+		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		Contracts::add_meta( $id, 'note', 'one' );
+		Contracts::add_meta( $id, 'note', 'two' );
+
+		$this->assertTrue( Contracts::update_meta( $id, 'note', 'three', 'two' ) );
+		$this->assertSame( array( 'one', 'three' ), Contracts::get_meta( $id, 'note' ) );
+
+		$this->assertTrue( Contracts::delete_meta( $id, 'note', 'one' ) );
+		$this->assertSame( array( 'three' ), Contracts::get_meta( $id, 'note' ) );
+	}
+
+	public function test_only_null_matches_any_meta_value(): void {
+		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		Contracts::add_meta( $id, 'note', 'one' );
+		Contracts::add_meta( $id, 'note', '' );
+
+		$this->assertTrue( Contracts::update_meta( $id, 'note', 'blank', '' ) );
+		$this->assertSame( array( 'one', 'blank' ), Contracts::get_meta( $id, 'note' ), 'An empty previous value matches literally.' );
+
+		$this->assertFalse( Contracts::delete_meta( $id, 'note', '' ) );
+		$this->assertSame( array( 'one', 'blank' ), Contracts::get_meta( $id, 'note' ), 'An empty value deletes only empty values.' );
+
+		$this->assertTrue( Contracts::delete_meta( $id, 'note' ) );
+		$this->assertSame( array(), Contracts::get_meta( $id, 'note' ) );
+	}
+
+	public function test_meta_reads_for_an_unknown_contract_are_empty(): void {
+		$this->assertFalse( Contracts::delete_meta( 999999, 'note' ) );
+		$this->assertSame( '', Contracts::get_meta( 999999, 'note', true ) );
+		$this->assertSame( array(), Contracts::get_meta( 999999, 'note' ) );
+	}
+
+	public function test_deleting_a_contract_removes_its_meta(): void {
+		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		Contracts::add_meta( $id, 'note', 'one' );
+
+		$this->assertTrue( ( new ContractRepository() )->delete( $id ) );
+
+		$this->assertSame( array(), Contracts::get_meta( $id, 'note' ) );
+	}
+
+	public function test_an_empty_meta_key_is_rejected(): void {
+		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+
+		$this->expectException( InvalidArgumentException::class );
+
+		Contracts::add_meta( $id, '', 'one' );
+	}
+
+	public function test_meta_survives_a_contract_update(): void {
+		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		Contracts::add_meta( $id, 'note', 'kept' );
+
+		Contracts::update(
+			$id,
+			array(
+				'status' => ContractStatus::ACTIVE,
+				'items'  => array( array( 'item_name' => 'Coffee' ) ),
+			)
+		);
+
+		$this->assertSame( array( 'kept' ), Contracts::get_meta( $id, 'note' ) );
+	}
+
+	/**
+	 * Sign up a contract through the contracts facade (cycle 1 billed). The monthly plan's
+	 * cadence is frozen onto the contract's plan snapshot at signup.
+	 *
+	 * @param int $customer_id Owning customer id; 0 gives the order a new customer.
+	 * @return Contract The persisted contract with cycle 1 billed.
+	 */
+	private function sign_up_contract( int $customer_id = 0 ): Contract {
+		$plan = Plan::create(
+			array(
+				'name'           => 'Monthly',
+				'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
+				'category'       => Plan::DEFAULT_CATEGORY,
+				'extension_slug' => 'engine-tests',
+			)
+		);
+		( new PlanRepository() )->insert( $plan );
+
+		$order = new WC_Order();
+		$order->set_currency( 'USD' );
+		$order->set_payment_method( 'dummy' );
+		$order->set_total( '19.99' );
+		$order->set_date_paid( '2026-01-15 00:00:00' );
+		if ( $customer_id > 0 ) {
+			$order->set_customer_id( $customer_id );
+		}
+		$order->save();
+
+		$contract = ( new ContractRepository() )->find( $this->sign_up_from_order( $order, $plan ) );
+		$this->assertInstanceOf( Contract::class, $contract );
+
+		return $contract;
+	}
+
+	/**
+	 * Seed a bare contract at a status and billing total for the list-query tests,
+	 * returning its id.
+	 *
+	 * @param string $status        Contract status.
+	 * @param string $billing_total Billing total (decimal string).
+	 */
+	private function seed_list_contract( string $status = ContractStatus::ACTIVE, string $billing_total = '19.99' ): int {
+		$contract = Contract::create(
+			array(
+				'extension_slug'   => 'engine-tests',
+				'customer_id'      => 42,
+				'status'           => $status,
+				'currency'         => 'USD',
+				'selling_plan_id'  => 1,
+				'start_gmt'        => '2026-01-01 00:00:00',
+				'next_payment_gmt' => '2099-02-01 00:00:00',
+				'billing_total'    => $billing_total,
+			)
+		);
+
+		return ( new ContractRepository() )->insert( $contract );
+	}
+
+	/**
+	 * Seed a contract for a customer at a status, returning its id.
+	 *
+	 * @param int    $customer_id Owning customer.
+	 * @param string $status      Contract status.
+	 */
+	private function seed_for_customer( int $customer_id, string $status = ContractStatus::ACTIVE ): int {
+		$contract = Contract::create(
+			array(
+				'extension_slug'   => 'engine-tests',
+				'customer_id'      => $customer_id,
+				'status'           => $status,
+				'currency'         => 'USD',
+				'selling_plan_id'  => 1,
+				'start_gmt'        => '2026-01-01 00:00:00',
+				'next_payment_gmt' => '2099-02-01 00:00:00',
+				'billing_total'    => '19.99',
+			)
+		);
+
+		return ( new ContractRepository() )->insert( $contract );
+	}
+
+	/**
+	 * @testdox get returns the contract, and null for an unknown id.
+	 */
+	public function test_get_round_trips_a_contract(): void {
+		$contract    = $this->sign_up_contract();
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$loaded = Contracts::get( $contract_id );
+		$this->assertInstanceOf( ContractView::class, $loaded );
+		$this->assertSame( $contract_id, $loaded->get_id() );
+		$this->assertIsArray( $loaded->get_items(), 'A single read loads children.' );
+		$this->assertIsArray( $loaded->get_addresses(), 'A single read loads children.' );
+
+		$this->assertNull( Contracts::get( 999999 ) );
+	}
+
+	/**
+	 * @testdox find_by_origin_order returns views of the contracts created from an order.
+	 */
+	public function test_find_by_origin_order_returns_views(): void {
+		$contract = $this->sign_up_contract();
+		$order_id = $contract->get_origin_order_id();
+		$this->assertNotNull( $order_id );
+
+		$found = Contracts::find_by_origin_order( $order_id );
+
+		$this->assertCount( 1, $found );
+		$this->assertInstanceOf( ContractView::class, $found[0] );
+		$this->assertSame( $contract->get_id(), $found[0]->get_id() );
+		$this->assertSame( $order_id, $found[0]->get_origin_order_id() );
+		$this->assertNull( $found[0]->get_items() );
+		$this->assertSame( array(), Contracts::find_by_origin_order( 999999 ) );
+	}
+
+	/**
+	 * @testdox list_for_customer does not load children.
+	 */
+	public function test_list_for_customer_does_not_load_children(): void {
+		$customer_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+		$this->assertIsInt( $customer_id );
+
+		$this->sign_up_contract( $customer_id );
+
+		$contracts = Contracts::list_for_customer( $customer_id );
+		$this->assertCount( 1, $contracts );
+		$this->assertNull( $contracts[0]->get_items(), 'List reads do not load children.' );
+	}
+
+	/**
+	 * @testdox list does not load children.
+	 */
+	public function test_list_does_not_load_children(): void {
+		$this->sign_up_contract();
+
+		$contracts = Contracts::list( array( 'limit' => 1 ) );
+		$this->assertCount( 1, $contracts );
+		$this->assertNull( $contracts[0]->get_items(), 'List reads do not load children.' );
+		$this->assertNull( $contracts[0]->get_addresses(), 'List reads do not load children.' );
+	}
+
+	/**
+	 * @testdox list returns recent contracts newest first.
+	 */
+	public function test_list_returns_recent_contracts(): void {
+		$first  = $this->sign_up_contract();
+		$second = $this->sign_up_contract();
+
+		$contracts = Contracts::list();
+		$ids       = array_map( static fn ( ContractView $c ) => $c->get_id(), $contracts );
+
+		// Newest first, and both signups are present.
+		$this->assertSame( array( $second->get_id(), $first->get_id() ), array_slice( $ids, 0, 2 ) );
+		$this->assertInstanceOf( ContractView::class, $contracts[0] );
+	}
+
+	/**
+	 * @testdox list passes status / sort / search / paging args through to the query.
+	 */
+	public function test_list_passes_query_args_through(): void {
+		$active_low  = $this->seed_list_contract( ContractStatus::ACTIVE, '10.00' );
+		$active_high = $this->seed_list_contract( ContractStatus::ACTIVE, '20.00' );
+		$this->seed_list_contract( ContractStatus::CANCELLED, '30.00' );
+
+		// Status filter + sort compose through the facade.
+		$ids = array_map(
+			static fn ( ContractView $c ) => (int) $c->get_id(),
+			Contracts::list(
+				array(
+					'status'  => ContractStatus::ACTIVE,
+					'orderby' => 'total',
+					'order'   => 'ASC',
+				)
+			)
+		);
+		$this->assertSame( array( $active_low, $active_high ), $ids );
+
+		// Paging windows the same filtered set.
+		$page = Contracts::list(
+			array(
+				'status' => ContractStatus::ACTIVE,
+				'limit'  => 1,
+				'offset' => 1,
+			)
+		);
+		$this->assertCount( 1, $page );
+	}
+
+	/**
+	 * @testdox count_by_status returns a full status map and count honours the same filter.
+	 */
+	public function test_count_by_status_and_count(): void {
+		$this->seed_list_contract( ContractStatus::ACTIVE );
+		$this->seed_list_contract( ContractStatus::ACTIVE );
+		$this->seed_list_contract( ContractStatus::ON_HOLD );
+
+		$by_status = Contracts::count_by_status();
+		$this->assertSame( ContractStatus::get_all(), array_keys( $by_status ) );
+		$this->assertSame( 2, $by_status[ ContractStatus::ACTIVE ] );
+		$this->assertSame( 1, $by_status[ ContractStatus::ON_HOLD ] );
+		$this->assertSame( 0, $by_status[ ContractStatus::CANCELLED ] );
+
+		// The grand total and a status-filtered total agree with the map.
+		$this->assertSame( 3, Contracts::count() );
+		$this->assertSame( 2, Contracts::count( array( 'status' => ContractStatus::ACTIVE ) ) );
+	}
+
+	/**
+	 * @testdox get_cycles returns the billing cycles newest first.
+	 */
+	public function test_get_cycles_returns_cycles(): void {
+		$contract    = $this->sign_up_contract();
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$history = Contracts::get_cycles( $contract_id );
+		$this->assertCount( 1, $history );
+		$this->assertInstanceOf( CycleView::class, $history[0] );
+		$this->assertSame( 1, $history[0]->get_count() );
+		$this->assertSame( CycleStatus::BILLED, $history[0]->get_status() );
+	}
+
+	/**
+	 * @testdox list_for_customer returns only the requested customer's contracts.
+	 */
+	public function test_list_for_customer_is_owner_scoped(): void {
+		$mine_a = $this->seed_for_customer( 41 );
+		$mine_b = $this->seed_for_customer( 41 );
+		$theirs = $this->seed_for_customer( 42 );
+
+		$ids = array_map(
+			static fn ( ContractView $c ) => (int) $c->get_id(),
+			Contracts::list_for_customer( 41 )
+		);
+
+		$this->assertContains( $mine_a, $ids );
+		$this->assertContains( $mine_b, $ids );
+		$this->assertNotContains( $theirs, $ids );
+		$this->assertCount( 2, $ids );
+	}
+
+	/**
+	 * @testdox list_for_customer filters by status before paging.
+	 */
+	public function test_list_for_customer_filters_by_status_before_paging(): void {
+		$active_old = $this->seed_for_customer( 44 );
+		$this->seed_for_customer( 44, ContractStatus::DRAFT );
+		$on_hold = $this->seed_for_customer( 44, ContractStatus::ON_HOLD );
+		$this->seed_for_customer( 44, ContractStatus::DRAFT );
+
+		$ids  = static fn ( array $views ): array => array_map( static fn ( ContractView $c ): int => (int) $c->get_id(), $views );
+		$args = array( 'status' => array( ContractStatus::ACTIVE, ContractStatus::ON_HOLD ) );
+
+		$this->assertSame( array( $on_hold ), $ids( Contracts::list_for_customer( 44, 1, 0, $args ) ) );
+		$this->assertSame( array( $active_old ), $ids( Contracts::list_for_customer( 44, 1, 1, $args ) ) );
+		$this->assertSame( array( $on_hold ), $ids( Contracts::list_for_customer( 44, 20, 0, array( 'status' => ContractStatus::ON_HOLD ) ) ), 'A single status is accepted.' );
+		$this->assertCount( 4, Contracts::list_for_customer( 44, 20, 0, array( 'status' => array( 'nonsense' ) ) ), 'An unregistered status is dropped, leaving no filter.' );
+	}
+
+	/**
+	 * @testdox get_for_customer returns the contract when the customer owns it.
+	 */
+	public function test_get_for_customer_returns_the_owned_contract(): void {
+		$id = $this->seed_for_customer( 43 );
+
+		$contract = Contracts::get_for_customer( $id, 43 );
+
+		$this->assertInstanceOf( ContractView::class, $contract );
+		$this->assertSame( $id, $contract->get_id() );
+	}
+
+	/**
+	 * @testdox get_for_customer is null for both a foreign owner and an unknown id (asymmetric).
+	 */
+	public function test_get_for_customer_is_null_for_foreign_and_unknown(): void {
+		$id = $this->seed_for_customer( 44 );
+
+		$this->assertNull( Contracts::get_for_customer( $id, 45 ), 'foreign-owned reads as not found' );
+		$this->assertNull( Contracts::get_for_customer( 987654, 44 ), 'unknown id reads as not found' );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php
index 20b863c8e76..89c629be8f6 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php
@@ -86,6 +86,7 @@ class ContractsControllerTest extends EngineIntegrationTestCase {
 	private function seed( int $customer_id, string $status = ContractStatus::ACTIVE ): int {
 		$contract = Contract::create(
 			array(
+				'extension_slug'       => 'engine-tests',
 				'customer_id'          => $customer_id,
 				'status'               => $status,
 				'currency'             => 'USD',
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
index bd78adfb26f..112b9b1d09a 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
@@ -1,6 +1,6 @@
 <?php
 /**
- * Integration tests for the public Subscriptions facade.
+ * Integration tests for the interim Subscriptions lifecycle and renewal facade.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine
  */
@@ -11,17 +11,16 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api;

 use EngineIntegrationTestCase;
 use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\Subscriptions;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\ContractFactory;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;

@@ -48,10 +47,10 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * Sign up a contract via the checkout factory (cycle 1 billed). The monthly plan's
+	 * Sign up a contract through the contracts facade (cycle 1 billed). The monthly plan's
 	 * cadence is frozen onto the contract's plan snapshot at signup.
 	 *
-	 * @param int $customer_id Owning customer id; 0 leaves the order customer unset.
+	 * @param int $customer_id Owning customer id; 0 gives the order a new customer.
 	 * @return Contract The persisted contract with cycle 1 billed.
 	 */
 	private function sign_up_contract( int $customer_id = 0 ): Contract {
@@ -75,77 +74,44 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
 		}
 		$order->save();

-		return ( new ContractFactory() )->create_from_order( $order, $plan );
-	}
-
-	/**
-	 * Repoint the live plan a contract was created under to a different cadence, to prove
-	 * a read sources cadence from the frozen snapshot rather than the live plan.
-	 *
-	 * @param Contract      $contract The contract whose live plan to mutate.
-	 * @param BillingPolicy $policy   The new live cadence.
-	 */
-	private function repoint_live_plan( Contract $contract, BillingPolicy $policy ): void {
-		$plans = new PlanRepository();
-		$plan  = $plans->find( $contract->get_selling_plan_id() );
-		$this->assertInstanceOf( Plan::class, $plan );
+		$contract = ( new ContractRepository() )->find( $this->sign_up_from_order( $order, $plan ) );
+		$this->assertInstanceOf( Contract::class, $contract );

-		$plan->set_billing_policy( $policy );
-		$plans->update( $plan );
+		return $contract;
 	}

 	/**
-	 * @testdox get returns the contract, and null for an unknown id.
+	 * @testdox get_related_orders returns the contract's linked orders (the origin order).
 	 */
-	public function test_get_round_trips_a_contract(): void {
+	public function test_get_related_orders_returns_the_linked_orders(): void {
 		$contract    = $this->sign_up_contract();
 		$contract_id = $contract->get_id();
 		$this->assertNotNull( $contract_id );

-		$loaded = Subscriptions::get( $contract_id );
-		$this->assertInstanceOf( Contract::class, $loaded );
-		$this->assertSame( $contract_id, $loaded->get_id() );
+		$orders = Subscriptions::get_related_orders( $contract_id );

-		$this->assertNull( Subscriptions::get( 999999 ) );
+		$this->assertCount( 1, $orders );
+		$this->assertInstanceOf( WC_Order::class, $orders[0] );
+		$this->assertSame( $contract->get_origin_order_id(), $orders[0]->get_id() );
 	}

 	/**
-	 * @testdox get hydrates the contract's frozen plan terms, and the snapshot wins over a changed live plan.
+	 * @testdox get_related_orders lists an origin order that also carries the contract meta once.
 	 */
-	public function test_get_hydrates_the_plan_snapshot_and_snapshot_wins(): void {
+	public function test_get_related_orders_lists_a_meta_tagged_origin_once(): void {
 		$contract    = $this->sign_up_contract();
 		$contract_id = $contract->get_id();
 		$this->assertNotNull( $contract_id );

-		// Edit the live plan AFTER signup: the frozen snapshot must not move with it.
-		$this->repoint_live_plan( $contract, new BillingPolicy( 'year', 2, null, null, null ) );
-
-		$loaded = Subscriptions::get( $contract_id );
-		$this->assertInstanceOf( Contract::class, $loaded );
-
-		$snapshot = $loaded->get_plan_snapshot();
-		$this->assertInstanceOf( PlanSnapshot::class, $snapshot, 'get() must hydrate the plan snapshot.' );
-
-		$policy = $snapshot->get_billing_policy();
-		$this->assertInstanceOf( BillingPolicy::class, $policy );
-		// Frozen monthly cadence, NOT the live plan's edited yearly cadence.
-		$this->assertSame( 'month', $policy->get_period() );
-		$this->assertSame( 1, $policy->get_interval() );
-	}
-
-	/**
-	 * @testdox get_related_orders returns the contract's linked orders (the origin order).
-	 */
-	public function test_get_related_orders_returns_the_linked_orders(): void {
-		$contract    = $this->sign_up_contract();
-		$contract_id = $contract->get_id();
-		$this->assertNotNull( $contract_id );
+		$origin = wc_get_order( (int) $contract->get_origin_order_id() );
+		$this->assertInstanceOf( WC_Order::class, $origin );
+		$origin->update_meta_data( OrderLinkage::META_CONTRACT_ID, (string) $contract_id );
+		$origin->save();

 		$orders = Subscriptions::get_related_orders( $contract_id );

 		$this->assertCount( 1, $orders );
-		$this->assertInstanceOf( WC_Order::class, $orders[0] );
-		$this->assertSame( $contract->get_origin_order_id(), $orders[0]->get_id() );
+		$this->assertSame( $origin->get_id(), $orders[0]->get_id() );
 	}

 	/**
@@ -198,115 +164,53 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox list_for_customer hydrates each row's frozen plan terms.
+	 * @testdox get_related_orders bounds the linked-order query to the page and still pages like an unbounded read.
 	 */
-	public function test_list_for_customer_hydrates_the_plan_snapshot(): void {
-		$customer_id = self::factory()->user->create( array( 'role' => 'customer' ) );
-		$this->assertIsInt( $customer_id );
-
-		$this->sign_up_contract( $customer_id );
-
-		$contracts = Subscriptions::list_for_customer( $customer_id );
-		$this->assertCount( 1, $contracts );
-
-		$snapshot = $contracts[0]->get_plan_snapshot();
-		$this->assertInstanceOf( PlanSnapshot::class, $snapshot, 'list_for_customer must hydrate each row\'s plan snapshot.' );
-		$policy = $snapshot->get_billing_policy();
-		$this->assertInstanceOf( BillingPolicy::class, $policy );
-		$this->assertSame( 'month', $policy->get_period() );
-	}
-
-	/**
-	 * @testdox list hydrates each row's frozen plan terms, like the customer list.
-	 */
-	public function test_list_hydrates_the_plan_snapshot(): void {
-		$this->sign_up_contract();
-
-		$contracts = Subscriptions::list( array( 'limit' => 1 ) );
-		$this->assertCount( 1, $contracts );
-
-		$snapshot = $contracts[0]->get_plan_snapshot();
-		$this->assertInstanceOf( PlanSnapshot::class, $snapshot, 'list must hydrate each row\'s plan snapshot.' );
-	}
-
-	/**
-	 * @testdox list returns recent contracts newest first.
-	 */
-	public function test_list_returns_recent_contracts(): void {
-		$first  = $this->sign_up_contract();
-		$second = $this->sign_up_contract();
-
-		$contracts = Subscriptions::list();
-		$ids       = array_map( static fn ( Contract $c ) => $c->get_id(), $contracts );
-
-		// Newest first, and both signups are present.
-		$this->assertSame( array( $second->get_id(), $first->get_id() ), array_slice( $ids, 0, 2 ) );
-		$this->assertInstanceOf( Contract::class, $contracts[0] );
-	}
-
-	/**
-	 * @testdox list passes status / sort / search / paging args through to the query.
-	 */
-	public function test_list_passes_query_args_through(): void {
-		$active_low  = $this->seed_list_contract( ContractStatus::ACTIVE, '10.00' );
-		$active_high = $this->seed_list_contract( ContractStatus::ACTIVE, '20.00' );
-		$this->seed_list_contract( ContractStatus::CANCELLED, '30.00' );
-
-		// Status filter + sort compose through the facade.
-		$ids = array_map(
-			static fn ( Contract $c ) => (int) $c->get_id(),
-			Subscriptions::list(
-				array(
-					'status'  => ContractStatus::ACTIVE,
-					'orderby' => 'total',
-					'order'   => 'ASC',
-				)
-			)
-		);
-		$this->assertSame( array( $active_low, $active_high ), $ids );
-
-		// Paging windows the same filtered set.
-		$page = Subscriptions::list(
-			array(
-				'status' => ContractStatus::ACTIVE,
-				'limit'  => 1,
-				'offset' => 1,
-			)
-		);
-		$this->assertCount( 1, $page );
-	}
-
-	/**
-	 * @testdox count_by_status returns a full status map and count honours the same filter.
-	 */
-	public function test_count_by_status_and_count(): void {
-		$this->seed_list_contract( ContractStatus::ACTIVE );
-		$this->seed_list_contract( ContractStatus::ACTIVE );
-		$this->seed_list_contract( ContractStatus::ON_HOLD );
-
-		$by_status = Subscriptions::count_by_status();
-		$this->assertSame( ContractStatus::get_all(), array_keys( $by_status ) );
-		$this->assertSame( 2, $by_status[ ContractStatus::ACTIVE ] );
-		$this->assertSame( 1, $by_status[ ContractStatus::ON_HOLD ] );
-		$this->assertSame( 0, $by_status[ ContractStatus::CANCELLED ] );
-
-		// The grand total and a status-filtered total agree with the map.
-		$this->assertSame( 3, Subscriptions::count() );
-		$this->assertSame( 2, Subscriptions::count( array( 'status' => ContractStatus::ACTIVE ) ) );
-	}
-
-	/**
-	 * @testdox get_history returns the billing cycles newest first.
-	 */
-	public function test_get_history_returns_cycles(): void {
+	public function test_get_related_orders_bounds_the_query_to_the_page(): void {
 		$contract    = $this->sign_up_contract();
 		$contract_id = $contract->get_id();
 		$this->assertNotNull( $contract_id );

-		$history = Subscriptions::get_history( $contract_id );
-		$this->assertCount( 1, $history );
-		$this->assertInstanceOf( Cycle::class, $history[0] );
-		$this->assertSame( 1, $history[0]->get_count() );
+		// Five renewal orders dated after the origin, newest last created.
+		foreach ( array( 1, 2, 3, 4, 5 ) as $days_ahead ) {
+			$order = wc_create_order();
+			$this->assertInstanceOf( WC_Order::class, $order );
+			$order->set_date_created( gmdate( 'Y-m-d H:i:s', time() + ( $days_ahead * DAY_IN_SECONDS ) ) );
+			$order->update_meta_data( OrderLinkage::META_CONTRACT_ID, (string) $contract_id );
+			$order->update_meta_data( OrderLinkage::META_RELATION_TYPE, OrderLinkage::RELATION_RENEWAL );
+			$order->save();
+		}
+
+		$all_ids = array_map(
+			static function ( WC_Order $order ): int {
+				return $order->get_id();
+			},
+			Subscriptions::get_related_orders( $contract_id )
+		);
+		$this->assertCount( 6, $all_ids );
+
+		$limits = array();
+		$record = static function ( array $args ) use ( &$limits ): array {
+			$limits[] = $args['limit'] ?? null;
+			return $args;
+		};
+		add_filter( 'woocommerce_order_query_args', $record );
+
+		try {
+			foreach ( array( 0, 2, 4 ) as $offset ) {
+				$page_ids = array_map(
+					static function ( WC_Order $order ): int {
+						return $order->get_id();
+					},
+					Subscriptions::get_related_orders( $contract_id, 2, $offset )
+				);
+				$this->assertSame( array_slice( $all_ids, $offset, 2 ), $page_ids, "Page at offset {$offset} matches the unbounded read." );
+			}
+		} finally {
+			remove_filter( 'woocommerce_order_query_args', $record );
+		}
+
+		$this->assertSame( array( 2, 4, 6 ), $limits, 'The linked-order query is bounded to offset + limit.' );
 	}

 	/**
@@ -339,114 +243,28 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
 		$this->assertInstanceOf( WC_Order::class, $renewal_order );
 		$this->assertTrue( $renewal_order->is_paid() );

-		$history = Subscriptions::get_history( $contract_id );
+		$history = Contracts::get_cycles( $contract_id );
 		$this->assertCount( 2, $history );

 		// Newest first: cycle 2 is billed, linked to the renewal order.
 		$cycle_two = $history[0];
 		$this->assertSame( 2, $cycle_two->get_count() );
-		$this->assertTrue( $cycle_two->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
+		$this->assertSame( CycleStatus::BILLED, $cycle_two->get_status() );
 		$this->assertSame( $renewal_order->get_id(), $cycle_two->get_order_id() );

 		// The schedule advanced one cadence (cycle 1 ended 2026-02-15 + 1 month).
-		$after_renew = Subscriptions::get( $contract_id );
-		$this->assertInstanceOf( Contract::class, $after_renew );
+		$after_renew = Contracts::get( $contract_id );
+		$this->assertInstanceOf( ContractView::class, $after_renew );
 		$this->assertSame( '2026-03-15 00:00:00', $after_renew->get_next_payment_gmt() );

 		// Cancel: the contract goes terminal.
 		$this->assertTrue( Subscriptions::cancel( $contract_id ) );

-		$after_cancel = Subscriptions::get( $contract_id );
-		$this->assertInstanceOf( Contract::class, $after_cancel );
+		$after_cancel = Contracts::get( $contract_id );
+		$this->assertInstanceOf( ContractView::class, $after_cancel );
 		$this->assertSame( ContractStatus::CANCELLED, $after_cancel->get_status() );
 	}

-	/**
-	 * Seed a bare contract at a status and billing total for the list-query tests,
-	 * returning its id.
-	 *
-	 * @param string $status        Contract status.
-	 * @param string $billing_total Billing total (decimal string).
-	 */
-	private function seed_list_contract( string $status = ContractStatus::ACTIVE, string $billing_total = '19.99' ): int {
-		$contract = Contract::create(
-			array(
-				'customer_id'      => 42,
-				'status'           => $status,
-				'currency'         => 'USD',
-				'selling_plan_id'  => 1,
-				'start_gmt'        => '2026-01-01 00:00:00',
-				'next_payment_gmt' => '2099-02-01 00:00:00',
-				'billing_total'    => $billing_total,
-			)
-		);
-
-		return ( new ContractRepository() )->insert( $contract );
-	}
-
-	/**
-	 * Seed a contract for a customer at a status, returning its id.
-	 *
-	 * @param int    $customer_id Owning customer.
-	 * @param string $status      Contract status.
-	 */
-	private function seed_for_customer( int $customer_id, string $status = ContractStatus::ACTIVE ): int {
-		$contract = Contract::create(
-			array(
-				'customer_id'      => $customer_id,
-				'status'           => $status,
-				'currency'         => 'USD',
-				'selling_plan_id'  => 1,
-				'start_gmt'        => '2026-01-01 00:00:00',
-				'next_payment_gmt' => '2099-02-01 00:00:00',
-				'billing_total'    => '19.99',
-			)
-		);
-
-		return ( new ContractRepository() )->insert( $contract );
-	}
-
-	/**
-	 * @testdox list_for_customer returns only the requested customer's contracts.
-	 */
-	public function test_list_for_customer_is_owner_scoped(): void {
-		$mine_a = $this->seed_for_customer( 41 );
-		$mine_b = $this->seed_for_customer( 41 );
-		$theirs = $this->seed_for_customer( 42 );
-
-		$ids = array_map(
-			static fn ( Contract $c ) => (int) $c->get_id(),
-			Subscriptions::list_for_customer( 41 )
-		);
-
-		$this->assertContains( $mine_a, $ids );
-		$this->assertContains( $mine_b, $ids );
-		$this->assertNotContains( $theirs, $ids );
-		$this->assertCount( 2, $ids );
-	}
-
-	/**
-	 * @testdox get_for_customer returns the contract when the customer owns it.
-	 */
-	public function test_get_for_customer_returns_the_owned_contract(): void {
-		$id = $this->seed_for_customer( 43 );
-
-		$contract = Subscriptions::get_for_customer( $id, 43 );
-
-		$this->assertInstanceOf( Contract::class, $contract );
-		$this->assertSame( $id, $contract->get_id() );
-	}
-
-	/**
-	 * @testdox get_for_customer is null for both a foreign owner and an unknown id (asymmetric).
-	 */
-	public function test_get_for_customer_is_null_for_foreign_and_unknown(): void {
-		$id = $this->seed_for_customer( 44 );
-
-		$this->assertNull( Subscriptions::get_for_customer( $id, 45 ), 'foreign-owned reads as not found' );
-		$this->assertNull( Subscriptions::get_for_customer( 987654, 44 ), 'unknown id reads as not found' );
-	}
-
 	/**
 	 * @testdox the lifecycle verbs return false for an unknown contract.
 	 */
@@ -465,20 +283,20 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
 		$this->assertNotNull( $contract_id );

 		$this->assertTrue( Subscriptions::hold( $contract_id ) );
-		$held = Subscriptions::get( $contract_id );
-		$this->assertInstanceOf( Contract::class, $held );
+		$held = Contracts::get( $contract_id );
+		$this->assertInstanceOf( ContractView::class, $held );
 		$this->assertSame( ContractStatus::ON_HOLD, $held->get_status() );
 		$this->assertNull( $held->get_next_payment_gmt(), 'Hold disarms the next-due moment.' );

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

 		$this->assertTrue( Subscriptions::cancel_at_period_end( $contract_id ) );
-		$pending = Subscriptions::get( $contract_id );
-		$this->assertInstanceOf( Contract::class, $pending );
+		$pending = Contracts::get( $contract_id );
+		$this->assertInstanceOf( ContractView::class, $pending );
 		$this->assertSame( ContractStatus::PENDING_CANCELLATION, $pending->get_status() );
 		$this->assertNull( $pending->get_next_payment_gmt(), 'Cancel at period end disarms the next-due moment.' );
 		$this->assertNotNull( $pending->get_end_gmt(), 'The former next-due moment becomes the end date.' );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
index 27ddf59396c..ff963ed45d5 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
@@ -10,6 +10,10 @@

 declare( strict_types=1 );

+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;

 /**
@@ -84,4 +88,95 @@ abstract class EngineIntegrationTestCase extends WP_UnitTestCase {

 		$this->approved_gateways[] = $gateway;
 	}
+
+	/**
+	 * Sign up a contract for a paid order on `$plan` through the contracts facade, the way an
+	 * extension maps its checkout: create a draft from explicit order fields and snapshots,
+	 * record cycle 1 (billed, linked to the order), then activate. An order without a
+	 * customer gets a new one.
+	 *
+	 * @param WC_Order             $order     Saved, paid order.
+	 * @param Plan                 $plan      Saved selling plan.
+	 * @param array<string, mixed> $overrides `Contracts::create()` fields to replace; `status` is the final status.
+	 * @return int The contract id.
+	 */
+	protected function sign_up_from_order( WC_Order $order, Plan $plan, array $overrides = array() ): int {
+		if ( $order->get_customer_id() <= 0 ) {
+			$customer_id = self::factory()->user->create();
+			$this->assertIsInt( $customer_id );
+			$order->set_customer_id( $customer_id );
+			$order->save();
+		}
+
+		$paid  = $order->get_date_paid();
+		$start = null !== $paid
+			? new DateTimeImmutable( '@' . $paid->getTimestamp() )
+			: new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
+
+		$items = array();
+		foreach ( $order->get_items() as $item ) {
+			if ( $item instanceof WC_Order_Item_Product ) {
+				$items[] = array(
+					'item_name'    => $item->get_name(),
+					'item_type'    => 'line_item',
+					'product_id'   => $item->get_product_id(),
+					'variation_id' => $item->get_variation_id(),
+					'quantity'     => (string) $item->get_quantity(),
+					'subtotal'     => (string) $item->get_subtotal(),
+					'total'        => (string) $item->get_total(),
+					'taxes'        => $item->get_taxes(),
+				);
+			}
+		}
+
+		$tokens   = $order->get_payment_tokens();
+		$token_id = array() !== $tokens ? (int) end( $tokens ) : 0;
+
+		$args = array_merge(
+			array(
+				'extension_slug'       => (string) $plan->get_extension_slug(),
+				'customer_id'          => $order->get_customer_id(),
+				'currency'             => $order->get_currency(),
+				'selling_plan_id'      => $plan->get_id(),
+				'origin_order_id'      => $order->get_id(),
+				'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'     => $token_id > 0 ? $token_id : null,
+				'start_gmt'            => $start,
+				'next_payment_gmt'     => $plan->get_billing_policy()->compute_first_renewal_from( $start ),
+				'billing_total'        => (string) $order->get_total(),
+				'discount_total'       => (string) $order->get_total_discount(),
+				'shipping_total'       => (string) $order->get_shipping_total(),
+				'tax_total'            => (string) $order->get_total_tax(),
+				'items'                => $items,
+				'addresses'            => array(
+					'billing'  => $order->get_address( 'billing' ),
+					'shipping' => $order->get_address( 'shipping' ),
+				),
+			),
+			$overrides
+		);
+
+		$status         = $args['status'] ?? ContractStatus::ACTIVE;
+		$args['status'] = ContractStatus::DRAFT;
+
+		$view = Contracts::create( $args );
+		$id   = $view->get_id();
+
+		Contracts::add_cycle(
+			$id,
+			array(
+				'status'         => CycleStatus::BILLED,
+				'count'          => 1,
+				'order_id'       => $order->get_id(),
+				'starts_at_gmt'  => (string) $view->get_start_gmt(),
+				'ends_at_gmt'    => (string) $view->get_next_payment_gmt(),
+				'expected_total' => $view->get_billing_total(),
+				'currency'       => $view->get_currency(),
+			)
+		);
+		Contracts::update( $id, array( 'status' => $status ) );
+
+		return $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
deleted file mode 100644
index 995b2891c41..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php
+++ /dev/null
@@ -1,331 +0,0 @@
-<?php
-/**
- * Integration tests for ContractFactory.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Checkout;
-
-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\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\ContractFactory;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\ContractFactory
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage
- */
-class ContractFactoryTest extends EngineIntegrationTestCase {
-
-	/**
-	 * @param int|null                              $max_cycles Maximum number of billing cycles, or null for unlimited.
-	 * @param array{length: int, unit: string}|null $trial      Native trial duration, or null for none.
-	 */
-	private function make_plan( ?int $max_cycles = null, ?array $trial = null ): Plan {
-		$plan = Plan::create(
-			array(
-				'name'           => 'Monthly coffee',
-				'billing_policy' => new BillingPolicy( 'month', 1, null, $max_cycles, $trial ),
-				'category'       => Plan::DEFAULT_CATEGORY,
-				'extension_slug' => 'lite',
-			)
-		);
-		( new PlanRepository() )->insert( $plan );
-
-		return $plan;
-	}
-
-	private function make_order(): WC_Order {
-		$order = new WC_Order();
-		$order->set_currency( 'USD' );
-		$order->set_payment_method( 'woocommerce_payments' );
-		$order->set_payment_method_title( 'Credit card' );
-		$order->set_total( '19.99' );
-		$order->set_address(
-			array(
-				'first_name' => 'Ada',
-				'last_name'  => 'Lovelace',
-				'country'    => 'US',
-				'email'      => 'ada@example.test',
-			),
-			'billing'
-		);
-		$order->save();
-
-		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();
-
-		$contract = ( new ContractFactory() )->create_from_order( $order, $plan );
-
-		$this->assertNotNull( $contract->get_id() );
-		$this->assertSame( ContractStatus::ACTIVE, $contract->get_status() );
-		$this->assertSame( 'USD', $contract->get_currency() );
-		$this->assertSame( $plan->get_id(), $contract->get_selling_plan_id() );
-		$this->assertSame( $order->get_id(), $contract->get_origin_order_id() );
-		$this->assertSame( 'lite', $contract->get_extension_slug() );
-		$this->assertSame( 'woocommerce_payments', $contract->get_payment_instrument()->get_gateway() );
-
-		// Persisted and reloadable.
-		$reloaded = ( new ContractRepository() )->find( $contract->get_id() );
-		$this->assertInstanceOf( Contract::class, $reloaded );
-		$this->assertSame( $contract->get_id(), $reloaded->get_id() );
-
-		// Order is tagged with the parent relation.
-		$tagged_order = wc_get_order( $order->get_id() );
-		$this->assertInstanceOf( WC_Order::class, $tagged_order );
-		$this->assertSame( (string) $contract->get_id(), $tagged_order->get_meta( OrderLinkage::META_CONTRACT_ID ) );
-		$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_chain_head( $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( new CycleStatus( 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' );
-		$order->save();
-
-		$contract = ( new ContractFactory() )->create_from_order( $order, $this->make_plan() );
-
-		$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' );
-		$order->save();
-
-		$plan = $this->make_plan(
-			null,
-			array(
-				'length' => 14,
-				'unit'   => 'day',
-			)
-		);
-
-		$contract = ( new ContractFactory() )->create_from_order( $order, $plan );
-
-		// 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_chain_head( $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();
-
-		$contract = ( new ContractFactory() )->create_from_order(
-			$order,
-			$plan,
-			array(
-				'billing_total'    => '49.00',
-				'next_payment_gmt' => '2026-12-01 00:00:00',
-			)
-		);
-
-		// 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_chain_head( $contract_id );
-		$this->assertInstanceOf( Cycle::class, $cycle );
-		$this->assertSame( '49.00000000', $cycle->get_expected_total() );
-		$this->assertSame( '2026-12-01 00:00:00', $cycle->get_ends_at_gmt() );
-	}
-
-	/**
-	 * @testdox The origin cycle's plan snapshot freezes the pricing payload exactly as stored.
-	 */
-	public function test_plan_snapshot_round_trips_the_pricing_policy(): void {
-		$pricing_policy = array(
-			'policies'      => array(
-				array(
-					'type'            => 'bogo',
-					'duration_cycles' => 1,
-				),
-				array(
-					'type'  => 'tiered',
-					'value' => 10,
-				),
-			),
-			'one_time_fees' => array(),
-			'custom_key'    => 'kept',
-		);
-
-		$plan = Plan::create(
-			array(
-				'name'           => 'Discounted monthly',
-				'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
-				'pricing_policy' => $pricing_policy,
-				'category'       => Plan::DEFAULT_CATEGORY,
-				'extension_slug' => 'lite',
-			)
-		);
-		( new PlanRepository() )->insert( $plan );
-
-		$contract    = ( new ContractFactory() )->create_from_order( $this->make_order(), $plan );
-		$contract_id = $contract->get_id();
-		$this->assertNotNull( $contract_id );
-
-		$repo  = new ContractRepository();
-		$cycle = $repo->find_chain_head( $contract_id );
-		$this->assertInstanceOf( Cycle::class, $cycle );
-
-		$snapshot = $repo->find_plan_snapshot( $cycle->get_plan_snapshot_id() );
-		$this->assertInstanceOf( PlanSnapshot::class, $snapshot );
-
-		// The engine freezes the payload uninterpreted: an unknown type and an extra
-		// key survive the DB round-trip, and no value is added to the bogo entry.
-		$this->assertSame( $pricing_policy, $snapshot->to_array()['pricing_policy'] );
-		$this->assertSame( $pricing_policy, $snapshot->get_pricing_policy() );
-	}
-
-	/**
-	 * @testdox The plan snapshot records an explicit null when the plan has no pricing policy.
-	 */
-	public function test_plan_snapshot_is_null_safe_without_a_pricing_policy(): void {
-		$contract    = ( new ContractFactory() )->create_from_order( $this->make_order(), $this->make_plan() );
-		$contract_id = $contract->get_id();
-		$this->assertNotNull( $contract_id );
-
-		$repo  = new ContractRepository();
-		$cycle = $repo->find_chain_head( $contract_id );
-		$this->assertInstanceOf( Cycle::class, $cycle );
-
-		$snapshot = $repo->find_plan_snapshot( $cycle->get_plan_snapshot_id() );
-		$this->assertInstanceOf( PlanSnapshot::class, $snapshot );
-
-		// Explicit null (not a missing key): the frozen terms deliberately record
-		// "no pricing policy at signup", distinguishable from a pre-key snapshot.
-		$payload = $snapshot->to_array();
-		$this->assertArrayHasKey( 'pricing_policy', $payload );
-		$this->assertNull( $payload['pricing_policy'] );
-		$this->assertNull( $snapshot->get_pricing_policy() );
-	}
-
-	/**
-	 * @testdox An unsaved plan is rejected.
-	 */
-	public function test_unsaved_plan_is_rejected(): void {
-		$order = $this->make_order();
-		$plan  = Plan::create(
-			array(
-				'name'           => 'Monthly coffee',
-				'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
-				'category'       => Plan::DEFAULT_CATEGORY,
-			)
-		);
-
-		$this->expectException( \RuntimeException::class );
-		( 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.
-		$order = new WC_Order();
-		$order->set_currency( 'USD' );
-
-		$this->expectException( \RuntimeException::class );
-		( new ContractFactory() )->create_from_order( $order, $this->make_plan() );
-	}
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php
index 125be196c7a..78f598cfa2d 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php
@@ -14,6 +14,8 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integrati

 use DomainException;
 use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Subscriptions;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
@@ -60,6 +62,7 @@ class CancellationTest extends EngineIntegrationTestCase {
 	private function seed( string $status, ?string $end_gmt = null ): int {
 		$contract = Contract::create(
 			array(
+				'extension_slug'   => 'engine-tests',
 				'customer_id'      => 1,
 				'status'           => $status,
 				'currency'         => 'USD',
@@ -118,7 +121,7 @@ class CancellationTest extends EngineIntegrationTestCase {
 		$this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
 		$this->assertSame( '2099-01-01 00:00:00', $stored->get_end_gmt() );
 		$this->assertNull( $stored->get_next_payment_gmt() );
-		$this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+		$this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
 	}

 	public function test_cancel_at_period_end_on_a_pending_cancellation_contract_is_a_no_op(): void {
@@ -160,6 +163,7 @@ class CancellationTest extends EngineIntegrationTestCase {
 	public function test_rejects_a_terminal_contract(): void {
 		$contract = Contract::create(
 			array(
+				'extension_slug'  => 'engine-tests',
 				'customer_id'     => 1,
 				'status'          => ContractStatus::CANCELLED,
 				'currency'        => 'USD',
@@ -193,7 +197,93 @@ class CancellationTest extends EngineIntegrationTestCase {
 		$stored = $this->reload( $id );
 		$this->assertSame( ContractStatus::CANCELLED, $stored->get_status() );
 		$this->assertNull( $stored->get_next_payment_gmt() );
-		$this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+		$this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
+	}
+
+	/**
+	 * The anchor is cleared after the status write has committed, so a failed delete must
+	 * not abort the transition: the lifecycle action still fires.
+	 *
+	 * @dataProvider provide_cancel_modes
+	 *
+	 * @param string $method Cancellation method under test.
+	 * @param string $action Action the method fires.
+	 * @param string $status Status the method writes.
+	 */
+	public function test_a_failed_anchor_clear_does_not_abort_the_transition( string $method, string $action, string $status ): void {
+		$id = $this->seed_active();
+		( new Hold( $this->contracts ) )->hold( $this->reload( $id ) );
+
+		$fired = 0;
+		add_action(
+			$action,
+			static function () use ( &$fired ): void {
+				++$fired;
+			}
+		);
+		$break = $this->break_meta_deletes();
+
+		try {
+			$this->assertTrue( $this->sut->$method( $this->reload( $id ) ) );
+		} finally {
+			remove_filter( 'query', $break );
+		}
+
+		$this->assertSame( 1, $fired, 'The lifecycle action fires.' );
+		$this->assertSame( $status, $this->reload( $id )->get_status() );
+		$this->assertSame( '2099-01-01 00:00:00', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ), 'The anchor is left behind.' );
+	}
+
+	/**
+	 * A transition that loses its compare-and-set leaves the hold anchor in place, so the
+	 * contract that won can still resume from it.
+	 *
+	 * @dataProvider provide_cancel_modes
+	 *
+	 * @param string $method Cancellation method under test.
+	 */
+	public function test_a_lost_race_keeps_the_hold_anchor( string $method ): void {
+		$id = $this->seed_active();
+		( new Hold( $this->contracts ) )->hold( $this->reload( $id ) );
+		$stale = $this->reload( $id );
+
+		$concurrent = $this->reload( $id );
+		$concurrent->set_status( ContractStatus::EXPIRED );
+		$this->contracts->update( $concurrent );
+
+		try {
+			$this->sut->$method( $stale );
+			$this->fail( 'Expected a DomainException when the conditional write misses.' );
+		} catch ( DomainException $e ) {
+			$this->assertSame( '2099-01-01 00:00:00', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
+		}
+	}
+
+	/**
+	 * Both cancellation modes: method, action, resulting status.
+	 *
+	 * @return array<string, array{0: string, 1: string, 2: string}>
+	 */
+	public function provide_cancel_modes(): array {
+		return array(
+			'cancel'               => array( 'cancel', Cancellation::CONTRACT_CANCELLED_ACTION, ContractStatus::CANCELLED ),
+			'cancel at period end' => array( 'cancel_at_period_end', Cancellation::CONTRACT_PENDING_CANCELLATION_ACTION, ContractStatus::PENDING_CANCELLATION ),
+		);
+	}
+
+	/**
+	 * Make every contract meta DELETE fail until the returned filter is removed.
+	 *
+	 * @return callable The `query` filter to remove.
+	 */
+	private function break_meta_deletes(): callable {
+		$meta_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META );
+		$break      = static function ( string $query ) use ( $meta_table ): string {
+			return 0 === strpos( $query, "DELETE FROM `{$meta_table}`" ) ? 'SELECT broken syntax (' : $query;
+		};
+		add_filter( 'query', $break );
+
+		return $break;
 	}

 	public function test_cancel_accepts_a_pending_cancellation_contract(): void {
@@ -206,6 +296,33 @@ class CancellationTest extends EngineIntegrationTestCase {
 		$this->assertNull( $stored->get_next_payment_gmt() );
 	}

+	public function test_cancel_accepts_a_draft_contract(): void {
+		// A stuck draft (created, never activated) has no cycles and no due moment.
+		$id = Contracts::create(
+			array(
+				'extension_slug' => 'test-owner',
+				'customer_id'    => 1,
+				'currency'       => 'USD',
+			)
+		)->get_id();
+		$this->assertSame( ContractStatus::DRAFT, $this->reload( $id )->get_status() );
+
+		$fired = 0;
+		add_action(
+			Cancellation::CONTRACT_CANCELLED_ACTION,
+			static function () use ( &$fired ): void {
+				++$fired;
+			}
+		);
+
+		$this->assertTrue( Subscriptions::cancel( $id ) );
+
+		$stored = $this->reload( $id );
+		$this->assertSame( ContractStatus::CANCELLED, $stored->get_status() );
+		$this->assertNull( $stored->get_next_payment_gmt() );
+		$this->assertSame( 1, $fired );
+	}
+
 	public function test_cancel_on_a_cancelled_contract_is_a_no_op_that_refires_the_action(): void {
 		$id = $this->seed( ContractStatus::CANCELLED );

@@ -253,21 +370,22 @@ class CancellationTest extends EngineIntegrationTestCase {

 	public function test_cancel_at_period_end_ignores_a_malformed_hold_anchor(): void {
 		$id = $this->seed( ContractStatus::ON_HOLD );
-		$this->contracts->update( $this->with_hold_anchor( $this->reload( $id ), 'not-a-date' ) );
+		$this->contracts->update_meta( $id, Hold::ANCHOR_META_KEY, 'not-a-date' );

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

 		$stored = $this->reload( $id );
 		$this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
 		$this->assertSame( '2099-01-01 00:00:00', $stored->get_end_gmt(), 'The stored next payment, not the malformed anchor, is the period end.' );
-		$this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+		$this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
 	}

 	public function test_cancel_at_period_end_from_on_hold_without_a_date_leaves_no_end(): void {
 		$id   = $this->seed( ContractStatus::ON_HOLD );
 		$held = $this->reload( $id );
 		$held->set_next_payment_gmt( null );
-		$this->contracts->update( $this->with_hold_anchor( $held, 'not-a-date' ) );
+		$this->contracts->update( $held );
+		$this->contracts->update_meta( $id, Hold::ANCHOR_META_KEY, 'not-a-date' );

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

@@ -276,18 +394,6 @@ class CancellationTest extends EngineIntegrationTestCase {
 		$this->assertNull( $stored->get_end_gmt(), 'A malformed anchor is never written as the end date.' );
 	}

-	/**
-	 * Set the hold anchor meta on a contract.
-	 *
-	 * @param Contract $contract Contract to change.
-	 * @param string   $anchor   Anchor value to store.
-	 */
-	private function with_hold_anchor( Contract $contract, string $anchor ): Contract {
-		$contract->set_meta( Hold::ANCHOR_META_KEY, $anchor );
-
-		return $contract;
-	}
-
 	public function test_cancel_at_period_end_rejects_an_expired_contract(): void {
 		$id     = $this->seed( ContractStatus::EXPIRED );
 		$before = did_action( Cancellation::CONTRACT_PENDING_CANCELLATION_ACTION );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php
index 5880d933451..1973b59a226 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php
@@ -48,6 +48,7 @@ class HoldTest extends EngineIntegrationTestCase {
 	private function seed( string $status, ?string $next_payment = '2099-01-01 00:00:00' ): int {
 		$contract = Contract::create(
 			array(
+				'extension_slug'   => 'engine-tests',
 				'customer_id'      => 1,
 				'status'           => $status,
 				'currency'         => 'USD',
@@ -61,6 +62,16 @@ class HoldTest extends EngineIntegrationTestCase {
 		return $this->contracts->insert( $contract );
 	}

+	/**
+	 * The stored hold anchor for a contract ('' when absent).
+	 *
+	 * @param int $id Contract id.
+	 * @return mixed
+	 */
+	private function anchor( int $id ) {
+		return $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true );
+	}
+
 	public function test_hold_suspends_billing_by_moving_to_on_hold(): void {
 		$id = $this->seed( ContractStatus::ACTIVE );

@@ -78,7 +89,7 @@ class HoldTest extends EngineIntegrationTestCase {

 		$held = $this->reload( $id );
 		$this->assertNull( $held->get_next_payment_gmt() );
-		$this->assertSame( '2099-01-01 00:00:00', $held->get_meta()[ Hold::ANCHOR_META_KEY ] ?? null );
+		$this->assertSame( '2099-01-01 00:00:00', $this->anchor( $id ) );
 	}

 	public function test_hold_without_a_next_payment_stores_no_anchor(): void {
@@ -89,7 +100,7 @@ class HoldTest extends EngineIntegrationTestCase {
 		$held = $this->reload( $id );
 		$this->assertSame( ContractStatus::ON_HOLD, $held->get_status() );
 		$this->assertNull( $held->get_next_payment_gmt() );
-		$this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $held->get_meta() );
+		$this->assertSame( '', $this->anchor( $id ) );
 	}

 	public function test_hold_on_an_on_hold_contract_is_an_idempotent_no_op(): void {
@@ -111,7 +122,19 @@ class HoldTest extends EngineIntegrationTestCase {
 		$this->assertSame( 1, $fired );
 		$this->assertSame( ContractStatus::ON_HOLD, $held->get_status() );
 		$this->assertNull( $held->get_next_payment_gmt() );
-		$this->assertSame( '2099-01-01 00:00:00', $held->get_meta()[ Hold::ANCHOR_META_KEY ] ?? null );
+		$this->assertSame( '2099-01-01 00:00:00', $this->anchor( $id ) );
+	}
+
+	public function test_the_anchor_survives_a_whole_contract_update(): void {
+		$id = $this->seed( ContractStatus::ACTIVE );
+		$this->sut->hold( $this->reload( $id ) );
+
+		$held = $this->reload( $id );
+		$held->set_end_gmt( '2099-12-31 00:00:00' );
+		$this->contracts->update( $held );
+
+		$this->assertSame( '2099-01-01 00:00:00', $this->anchor( $id ) );
+		$this->assertSame( '2099-01-01 00:00:00', Hold::read_anchor( $this->contracts, $id ) );
 	}

 	public function test_hold_fires_the_held_action(): void {
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php
index 606d1c1d4d1..e3eabcbedfb 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php
@@ -96,6 +96,7 @@ class ReactivationTest extends EngineIntegrationTestCase {
 	private function seed_on_hold( ?string $next_payment_gmt, int $selling_plan_id, string $status = ContractStatus::ON_HOLD, array $meta = array() ): int {
 		$contract = Contract::create(
 			array(
+				'extension_slug'   => 'engine-tests',
 				'customer_id'      => 1,
 				'status'           => $status,
 				'currency'         => 'USD',
@@ -104,11 +105,15 @@ class ReactivationTest extends EngineIntegrationTestCase {
 				'start_gmt'        => '2026-01-01 00:00:00',
 				'next_payment_gmt' => $next_payment_gmt,
 				'billing_total'    => '19.99',
-				'meta'             => $meta,
 			)
 		);

-		return $this->contracts->insert( $contract );
+		$id = $this->contracts->insert( $contract );
+		foreach ( $meta as $key => $value ) {
+			$this->contracts->add_meta( $id, $key, $value );
+		}
+
+		return $id;
 	}

 	private function utc( string $datetime ): DateTimeImmutable {
@@ -156,7 +161,7 @@ class ReactivationTest extends EngineIntegrationTestCase {
 		$stored = $this->reload( $id );
 		$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
 		$this->assertSame( '2099-01-01 00:00:00', $stored->get_next_payment_gmt() );
-		$this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+		$this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
 	}

 	public function test_reactivate_rolls_a_past_due_anchor_forward(): void {
@@ -168,7 +173,7 @@ class ReactivationTest extends EngineIntegrationTestCase {

 		$stored = $this->reload( $id );
 		$this->assertSame( '2026-05-01 00:00:00', $stored->get_next_payment_gmt() );
-		$this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+		$this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
 	}

 	/**
@@ -181,7 +186,7 @@ class ReactivationTest extends EngineIntegrationTestCase {

 		$stored = $this->reload( $id );
 		$this->assertSame( '2099-06-01 00:00:00', $stored->get_next_payment_gmt() );
-		$this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+		$this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
 	}

 	public function test_reactivate_ignores_a_malformed_anchor(): void {
@@ -192,7 +197,7 @@ class ReactivationTest extends EngineIntegrationTestCase {
 		$stored = $this->reload( $id );
 		$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
 		$this->assertNull( $stored->get_next_payment_gmt(), 'A malformed anchor counts as absent.' );
-		$this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+		$this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
 	}

 	public function test_reactivate_rolls_by_the_frozen_snapshot_cadence_over_the_live_plan(): void {
@@ -264,6 +269,54 @@ class ReactivationTest extends EngineIntegrationTestCase {
 		$this->assertSame( 1, $fired );
 	}

+	/**
+	 * The anchor is cleared after the status write has committed, so a failed delete must
+	 * not abort the reactivation, which could not be retried on an active contract.
+	 */
+	public function test_a_failed_anchor_clear_does_not_abort_the_reactivation(): void {
+		$id = $this->seed_on_hold( null, $this->make_monthly_plan(), ContractStatus::ON_HOLD, array( Hold::ANCHOR_META_KEY => '2099-01-01 00:00:00' ) );
+
+		$fired = 0;
+		add_action(
+			Reactivation::CONTRACT_REACTIVATED_ACTION,
+			static function () use ( &$fired ): void {
+				++$fired;
+			}
+		);
+		$meta_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META );
+		$break      = static function ( string $query ) use ( $meta_table ): string {
+			return 0 === strpos( $query, "DELETE FROM `{$meta_table}`" ) ? 'SELECT broken syntax (' : $query;
+		};
+		add_filter( 'query', $break );
+
+		try {
+			$this->assertTrue( $this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) ) );
+		} finally {
+			remove_filter( 'query', $break );
+		}
+
+		$stored = $this->reload( $id );
+		$this->assertSame( 1, $fired, 'The reactivated action fires.' );
+		$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
+		$this->assertSame( '2099-01-01 00:00:00', $stored->get_next_payment_gmt() );
+	}
+
+	public function test_a_lost_race_keeps_the_hold_anchor(): void {
+		$id    = $this->seed_on_hold( null, $this->make_monthly_plan(), ContractStatus::ON_HOLD, array( Hold::ANCHOR_META_KEY => '2099-01-01 00:00:00' ) );
+		$stale = $this->reload( $id );
+
+		$concurrent = $this->reload( $id );
+		$concurrent->set_status( ContractStatus::CANCELLED );
+		$this->contracts->update( $concurrent );
+
+		try {
+			$this->sut->reactivate( $stale, $this->utc( '2026-06-01 00:00:00' ) );
+			$this->fail( 'Expected a DomainException when the conditional write misses.' );
+		} catch ( DomainException $e ) {
+			$this->assertSame( '2099-01-01 00:00:00', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
+		}
+	}
+
 	public function test_reactivate_rejects_an_already_active_contract(): void {
 		// An active contract past its due date must NOT reach the recompute: rolling its
 		// date forward would skip the charge the due scan owes it.
@@ -301,6 +354,7 @@ class ReactivationTest extends EngineIntegrationTestCase {
 	public function test_reactivate_rejects_a_terminal_contract(): void {
 		$contract = Contract::create(
 			array(
+				'extension_slug'  => 'engine-tests',
 				'customer_id'     => 1,
 				'status'          => ContractStatus::CANCELLED,
 				'currency'        => 'USD',
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php
index c5380f92158..6f66130bd5d 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php
@@ -27,7 +27,6 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\ContractFactory;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold;
@@ -148,7 +147,7 @@ class OwnerScopedDueScanTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * Sign up a monthly contract owned by `$owner` via the checkout factory (cycle 1 billed,
+	 * Sign up a monthly contract owned by `$owner` through the contracts facade (cycle 1 billed,
 	 * next payment due {@see self::FIRST_DUE}). Returns the contract id.
 	 *
 	 * @param string $owner The plan's (and so the contract's) extension slug.
@@ -171,8 +170,7 @@ class OwnerScopedDueScanTest extends EngineIntegrationTestCase {
 		$order->set_date_paid( '2026-01-15 00:00:00' );
 		$order->save();

-		$contract = ( new ContractFactory() )->create_from_order( $order, $plan );
-		$id       = (int) $contract->get_id();
+		$id = $this->sign_up_from_order( $order, $plan );
 		$this->assertSame( self::FIRST_DUE, $this->reload( $id )->get_next_payment_gmt() );

 		return $id;
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php
index 660dfba037e..c39089d3004 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php
@@ -13,6 +13,7 @@ use DateTimeImmutable;
 use DateTimeZone;
 use EngineIntegrationTestCase;
 use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
@@ -20,7 +21,6 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\ContractFactory;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
@@ -66,7 +66,7 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * Persist a monthly plan and return the entity (the ContractFactory needs the plan).
+	 * Persist a monthly plan and return the entity (the sign-up helper needs the plan).
 	 */
 	private function make_plan_object(): Plan {
 		$plan = Plan::create(
@@ -83,7 +83,7 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * Sign up a contract via the checkout factory so its billing chain holds cycle 1 (billed),
+	 * Sign up a contract through the contracts facade so its billing chain holds cycle 1 (billed),
 	 * with its next payment due at the given date.
 	 *
 	 * @param string $gateway          Gateway id stamped on the order/contract.
@@ -100,12 +100,14 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
 		$order->set_date_paid( '2026-01-15 00:00:00' );
 		$order->save();

-		$contract = ( new ContractFactory() )->create_from_order( $order, $plan );
+		$id = $this->sign_up_from_order( $order, $plan );

-		// The factory anchors the first renewal off the plan cadence; pin the schedule date
+		// Sign-up anchors the first renewal off the plan cadence; pin the schedule date
 		// the test reasons about so due/not-due is explicit.
-		$contract->set_next_payment_gmt( $next_payment_gmt );
-		( new ContractRepository() )->update( $contract );
+		Contracts::update( $id, array( 'next_payment_gmt' => $next_payment_gmt ) );
+
+		$contract = ( new ContractRepository() )->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );

 		return $contract;
 	}
@@ -129,7 +131,7 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {

 		// The contract was not advanced: still cycle 1, schedule unmoved.
 		$repo = new ContractRepository();
-		$this->assertSame( 1, $repo->max_count( $contract_id ) );
+		$this->assertSame( 1, $this->head_count( $contract_id ) );
 		$reloaded = $repo->find( $contract_id );
 		$this->assertInstanceOf( Contract::class, $reloaded );
 		$this->assertSame( '2026-02-15 00:00:00', $reloaded->get_next_payment_gmt() );
@@ -183,7 +185,7 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {

 		// Untouched: still cycle 1, schedule unmoved, no renewal order.
 		$repo = new ContractRepository();
-		$this->assertSame( 1, $repo->max_count( $contract_id ) );
+		$this->assertSame( 1, $this->head_count( $contract_id ) );
 		$reloaded = $repo->find( $contract_id );
 		$this->assertInstanceOf( Contract::class, $reloaded );
 		$this->assertSame( '2026-06-15 00:00:00', $reloaded->get_next_payment_gmt() );
@@ -209,14 +211,14 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
 		$this->assertSame( 2, $processed, 'A single tick processes at most the batch size.' );

 		// The two oldest-due contracts advanced; the third is still at cycle 1.
-		$this->assertSame( 2, $repo->max_count( (int) $first->get_id() ) );
-		$this->assertSame( 2, $repo->max_count( (int) $second->get_id() ) );
-		$this->assertSame( 1, $repo->max_count( (int) $third->get_id() ) );
+		$this->assertSame( 2, $this->head_count( (int) $first->get_id() ) );
+		$this->assertSame( 2, $this->head_count( (int) $second->get_id() ) );
+		$this->assertSame( 1, $this->head_count( (int) $third->get_id() ) );

 		// The next tick drains the remaining due contract.
 		$processed_next = $dispatcher->run_batch( $this->scan_now(), 2 );
 		$this->assertSame( 1, $processed_next );
-		$this->assertSame( 2, $repo->max_count( (int) $third->get_id() ) );
+		$this->assertSame( 2, $this->head_count( (int) $third->get_id() ) );
 	}

 	/**
@@ -306,7 +308,7 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
 		$this->assertSame( 0, $dispatcher->run_batch( $this->scan_now(), -5 ) );

 		// Nothing was renewed by the no-op ticks.
-		$this->assertSame( 1, ( new ContractRepository() )->max_count( (int) $contract->get_id() ) );
+		$this->assertSame( 1, $this->head_count( (int) $contract->get_id() ) );
 	}

 	/**
@@ -360,4 +362,15 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
 			)
 		);
 	}
+
+	/**
+	 * The billing chain head's count, or null for an empty chain.
+	 *
+	 * @param int $contract_id Contract id.
+	 */
+	private function head_count( int $contract_id ): ?int {
+		$head = ( new ContractRepository() )->find_chain_head( $contract_id );
+
+		return null === $head ? null : $head->get_count();
+	}
 }
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 42f7d0be5bc..2ac1e894e24 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
@@ -11,6 +11,7 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integrati

 use EngineIntegrationTestCase;
 use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
@@ -18,7 +19,6 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\ContractFactory;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
@@ -110,7 +110,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * Persist a monthly plan and return the entity (the ContractFactory needs the plan).
+	 * Persist a monthly plan and return the entity (the sign-up helper needs the plan).
 	 *
 	 * @param int|null $max_cycles Maximum billing cycles, or null for open-ended.
 	 */
@@ -129,7 +129,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * Sign up a contract via the checkout factory so its billing chain holds cycle 1
+	 * Sign up a contract through the contracts facade so its billing chain holds cycle 1
 	 * (billed), the starting point the renewal advances from.
 	 *
 	 * @param string   $gateway    Gateway id stamped on the order/contract.
@@ -146,7 +146,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$order->set_date_paid( '2026-01-15 00:00:00' );
 		$order->save();

-		return ( new ContractFactory() )->create_from_order( $order, $plan );
+		return $this->reload_contract( $this->sign_up_from_order( $order, $plan ) );
 	}

 	private function make_origin_order(): WC_Order {
@@ -166,6 +166,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		// so the renewal amount resolves off the current cycle.
 		$contract = Contract::create(
 			array(
+				'status'           => ContractStatus::ACTIVE,
 				'customer_id'      => 1,
 				'currency'         => 'USD',
 				'selling_plan_id'  => $plan_id,
@@ -446,9 +447,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$order->add_item( $line );
 		$order->save();

-		$contract    = ( new ContractFactory() )->create_from_order( $order, $this->make_plan_object() );
-		$contract_id = $contract->get_id();
-		$this->assertNotNull( $contract_id );
+		$contract_id = $this->sign_up_from_order( $order, $this->make_plan_object() );

 		$renewal_order = $this->run_scheduled_renewal( $contract_id );
 		$this->assertInstanceOf( WC_Order::class, $renewal_order );
@@ -752,32 +751,6 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
 	}

-	/**
-	 * @testdox the scheduled scan renews from the contract's own plan snapshot even when the live plan is deleted.
-	 *
-	 * The contract's frozen snapshot is the cadence source of truth, so a deleted live selling
-	 * plan no longer blocks the renewal - the chain advances on the snapshot's terms.
-	 */
-	public function test_scheduled_renewal_renews_from_contract_snapshot_when_live_plan_deleted(): void {
-		$this->approve_charges_for( self::GATEWAY_APPROVING );
-
-		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
-		$contract_id = $contract->get_id();
-		$this->assertNotNull( $contract_id );
-
-		// Delete the live selling plan; the contract keeps its frozen snapshot.
-		( new PlanRepository() )->delete( $contract->get_selling_plan_id() );
-
-		$renewal_order = $this->run_scheduled_renewal( $contract_id );
-		$this->assertInstanceOf( WC_Order::class, $renewal_order );
-
-		// Cycle 2 was billed from the snapshot's cadence.
-		$cycle = ( new ContractRepository() )->find_chain_head( $contract_id );
-		$this->assertInstanceOf( Cycle::class, $cycle );
-		$this->assertSame( 2, $cycle->get_count() );
-		$this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
-	}
-
 	/**
 	 * @testdox the scheduled scan expires the contract when it hits max cycles.
 	 */
@@ -900,6 +873,8 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$order       = $this->make_origin_order();
 		$contract    = Contract::create(
 			array(
+				'extension_slug'   => 'engine-tests',
+				'status'           => ContractStatus::ACTIVE,
 				'customer_id'      => 1,
 				'currency'         => 'USD',
 				'selling_plan_id'  => $plan_id,
@@ -1524,6 +1499,114 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$this->assertSame( '2026-02-15 00:00:00', $reloaded->get_next_payment_gmt() );
 	}

+	/**
+	 * Create an active, overdue contract through the facade, minus the given renewal input.
+	 *
+	 * @param string $missing One of currency, customer, payment_method, billing_policy.
+	 */
+	private function seed_contract_missing( string $missing ): int {
+		$customer = self::factory()->user->create();
+		$this->assertIsInt( $customer );
+
+		$args = array(
+			'extension_slug'   => 'engine-tests',
+			'customer_id'      => $customer,
+			'currency'         => 'USD',
+			'payment_method'   => self::GATEWAY_APPROVING,
+			'start_gmt'        => '2026-01-01 00:00:00',
+			'next_payment_gmt' => '2026-02-01 00:00:00',
+			'billing_total'    => '10',
+			'selling_plan_id'  => $this->make_plan(),
+		);
+
+		switch ( $missing ) {
+			case 'currency':
+				unset( $args['currency'], $args['billing_total'] );
+				break;
+			case 'customer':
+				unset( $args['customer_id'] );
+				break;
+			case 'billing_policy':
+				unset( $args['selling_plan_id'] );
+				break;
+			default:
+				unset( $args[ $missing ] );
+		}
+
+		$id = Contracts::create( $args )->get_id();
+		Contracts::add_cycle(
+			$id,
+			array(
+				'status'        => CycleStatus::BILLED,
+				'count'         => 1,
+				'currency'      => 'USD',
+				'starts_at_gmt' => '2026-01-01 00:00:00',
+				'ends_at_gmt'   => '2026-02-01 00:00:00',
+			)
+		);
+		Contracts::update( $id, array( 'status' => ContractStatus::ACTIVE ) );
+
+		return $id;
+	}
+
+	/**
+	 * @return array<string, array{0: string}>
+	 */
+	public function provide_missing_renewal_inputs(): array {
+		return array(
+			'currency'       => array( 'currency' ),
+			'payment method' => array( 'payment_method' ),
+			'billing policy' => array( 'billing_policy' ),
+		);
+	}
+
+	/**
+	 * @testdox the scheduled scan parks a contract missing a renewal input instead of erroring.
+	 * @dataProvider provide_missing_renewal_inputs
+	 *
+	 * @param string $missing The missing renewal input.
+	 */
+	public function test_scheduled_renewal_parks_a_contract_missing_a_renewal_input( string $missing ): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+		$contract_id = $this->seed_contract_missing( $missing );
+
+		$this->assertNull( $this->run_scheduled_renewal( $contract_id ) );
+
+		$this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+		$this->assertNull( $this->reload_contract( $contract_id )->get_next_payment_gmt(), 'Parked out of the due set.' );
+	}
+
+	/**
+	 * @testdox a contract with no customer renews as a guest order.
+	 */
+	public function test_a_contract_without_a_customer_renews_as_a_guest_order(): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+		$contract_id = $this->seed_contract_missing( 'customer' );
+
+		$order = $this->run_scheduled_renewal( $contract_id );
+
+		$this->assertInstanceOf( WC_Order::class, $order );
+		$this->assertSame( 0, $order->get_customer_id() );
+		$this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+		$this->assertSame( '2026-03-01 00:00:00', $this->reload_contract( $contract_id )->get_next_payment_gmt(), 'The next-due moment advanced.' );
+	}
+
+	/**
+	 * @testdox renew_now refuses a contract missing a renewal input without parking it.
+	 * @dataProvider provide_missing_renewal_inputs
+	 *
+	 * @param string $missing The missing renewal input.
+	 */
+	public function test_renew_now_refuses_a_contract_missing_a_renewal_input( string $missing ): void {
+		$this->approve_charges_for( self::GATEWAY_APPROVING );
+		$contract_id = $this->seed_contract_missing( $missing );
+
+		$this->assertNull( ( new RenewalEngine() )->renew_now( $contract_id ) );
+
+		$this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+		$this->assertSame( '2026-02-01 00:00:00', $this->reload_contract( $contract_id )->get_next_payment_gmt() );
+	}
+
 	/**
 	 * @testdox a manual paid-status change settles a processing cycle (cash-on-delivery shape).
 	 *
@@ -1749,7 +1832,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$order->add_item( $line );
 		$order->save();

-		return ( new ContractFactory() )->create_from_order( $order, $plan );
+		return $this->reload_contract( $this->sign_up_from_order( $order, $plan ) );
 	}

 	/**
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractCustomerReadTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractCustomerReadTest.php
index 2726126eb30..5e69e62d8f1 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractCustomerReadTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractCustomerReadTest.php
@@ -43,6 +43,7 @@ class ContractCustomerReadTest extends EngineIntegrationTestCase {
 	private function seed( int $customer_id, string $status ): int {
 		$contract = Contract::create(
 			array(
+				'extension_slug'   => 'engine-tests',
 				'customer_id'      => $customer_id,
 				'status'           => $status,
 				'currency'         => 'USD',
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractMetaTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractMetaTest.php
new file mode 100644
index 00000000000..fe4d6da088d
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractMetaTest.php
@@ -0,0 +1,168 @@
+<?php
+/**
+ * Integration tests for the multi-value contract meta reads and writes on
+ * ContractRepository (WordPress post-meta semantics).
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Storage;
+
+use EngineIntegrationTestCase;
+use InvalidArgumentException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository
+ */
+class ContractMetaTest extends EngineIntegrationTestCase {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var ContractRepository
+	 */
+	private $sut;
+
+	/**
+	 * A stored contract id.
+	 *
+	 * @var int
+	 */
+	private $id;
+
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new ContractRepository();
+		$this->id  = $this->sut->insert( Contract::create( array( 'extension_slug' => 'acme-subs' ) ) );
+	}
+
+	public function test_add_meta_keeps_every_value_under_one_key_in_order(): void {
+		$first  = $this->sut->add_meta( $this->id, 'note', 'one' );
+		$second = $this->sut->add_meta( $this->id, 'note', 'two' );
+
+		$this->assertIsInt( $first );
+		$this->assertIsInt( $second );
+		$this->assertGreaterThan( $first, $second );
+		$this->assertSame( array( 'one', 'two' ), $this->sut->get_meta( $this->id, 'note' ) );
+		$this->assertSame( 'one', $this->sut->get_meta( $this->id, 'note', true ) );
+	}
+
+	public function test_unique_add_on_an_existing_key_adds_nothing(): void {
+		$this->sut->add_meta( $this->id, 'note', 'one' );
+
+		$this->assertNull( $this->sut->add_meta( $this->id, 'note', 'two', true ) );
+		$this->assertSame( array( 'one' ), $this->sut->get_meta( $this->id, 'note' ) );
+	}
+
+	public function test_update_meta_adds_when_absent(): void {
+		$this->assertTrue( $this->sut->update_meta( $this->id, 'note', 'one' ) );
+		$this->assertSame( array( 'one' ), $this->sut->get_meta( $this->id, 'note' ) );
+	}
+
+	public function test_update_meta_rewrites_every_row_for_the_key(): void {
+		$this->sut->add_meta( $this->id, 'note', 'one' );
+		$this->sut->add_meta( $this->id, 'note', 'two' );
+
+		$this->assertTrue( $this->sut->update_meta( $this->id, 'note', 'three' ) );
+		$this->assertSame( array( 'three', 'three' ), $this->sut->get_meta( $this->id, 'note' ) );
+	}
+
+	public function test_update_meta_with_a_previous_value_rewrites_only_matching_rows(): void {
+		$this->sut->add_meta( $this->id, 'note', 'one' );
+		$this->sut->add_meta( $this->id, 'note', 'two' );
+
+		$this->assertTrue( $this->sut->update_meta( $this->id, 'note', 'three', 'two' ) );
+		$this->assertSame( array( 'one', 'three' ), $this->sut->get_meta( $this->id, 'note' ) );
+		$this->assertFalse( $this->sut->update_meta( $this->id, 'note', 'four', 'missing' ) );
+	}
+
+	public function test_update_meta_to_the_same_value_reports_no_change(): void {
+		$this->sut->add_meta( $this->id, 'note', 'one' );
+
+		$this->assertFalse( $this->sut->update_meta( $this->id, 'note', 'one' ) );
+	}
+
+	public function test_delete_meta_with_a_value_removes_only_that_row(): void {
+		$this->sut->add_meta( $this->id, 'note', 'one' );
+		$this->sut->add_meta( $this->id, 'note', 'two' );
+
+		$this->assertTrue( $this->sut->delete_meta( $this->id, 'note', 'one' ) );
+		$this->assertSame( array( 'two' ), $this->sut->get_meta( $this->id, 'note' ) );
+	}
+
+	public function test_delete_meta_without_a_value_removes_every_row_for_the_key(): void {
+		$this->sut->add_meta( $this->id, 'note', 'one' );
+		$this->sut->add_meta( $this->id, 'note', 'two' );
+		$this->sut->add_meta( $this->id, 'other', 'kept' );
+
+		$this->assertTrue( $this->sut->delete_meta( $this->id, 'note' ) );
+		$this->assertSame( array(), $this->sut->get_meta( $this->id, 'note' ) );
+		$this->assertSame( array( 'kept' ), $this->sut->get_meta( $this->id, 'other' ) );
+		$this->assertFalse( $this->sut->delete_meta( $this->id, 'note' ) );
+	}
+
+	public function test_get_meta_without_a_key_groups_every_key(): void {
+		$this->sut->add_meta( $this->id, 'note', 'one' );
+		$this->sut->add_meta( $this->id, 'flag', 'yes' );
+		$this->sut->add_meta( $this->id, 'note', 'two' );
+
+		$this->assertSame(
+			array(
+				'note' => array( 'one', 'two' ),
+				'flag' => array( 'yes' ),
+			),
+			$this->sut->get_meta( $this->id )
+		);
+	}
+
+	public function test_arrays_round_trip_through_serialization(): void {
+		$value = array(
+			'a' => 1,
+			'b' => array( 'c' ),
+		);
+
+		$this->sut->add_meta( $this->id, 'payload', $value );
+
+		$this->assertSame( $value, $this->sut->get_meta( $this->id, 'payload', true ) );
+		$this->assertTrue( $this->sut->delete_meta( $this->id, 'payload', $value ) );
+	}
+
+	public function test_missing_key_reads_as_empty(): void {
+		$this->assertSame( '', $this->sut->get_meta( $this->id, 'missing', true ) );
+		$this->assertSame( array(), $this->sut->get_meta( $this->id, 'missing' ) );
+		$this->assertSame( array(), $this->sut->get_meta( $this->id ) );
+	}
+
+	public function test_meta_is_scoped_to_its_contract(): void {
+		$other = $this->sut->insert( Contract::create( array( 'extension_slug' => 'acme-subs' ) ) );
+		$this->sut->add_meta( $other, 'note', 'theirs' );
+
+		$this->assertSame( array(), $this->sut->get_meta( $this->id, 'note' ) );
+	}
+
+	/**
+	 * @dataProvider provide_write_methods
+	 *
+	 * @param string $method Write method name.
+	 */
+	public function test_an_empty_key_is_rejected_on_writes( string $method ): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		$this->sut->{$method}( $this->id, '', 'value' );
+	}
+
+	/**
+	 * @return array<string, array{0: string}>
+	 */
+	public function provide_write_methods(): array {
+		return array(
+			'add'    => array( 'add_meta' ),
+			'update' => array( 'update_meta' ),
+			'delete' => array( 'delete_meta' ),
+		);
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
index 767b88862b9..5fc3bb0a965 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
@@ -21,6 +21,7 @@ use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepos
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\DuplicateCycleException;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\RenewalCandidate;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SnapshotStore;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository
@@ -52,6 +53,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	private function make_contract(): Contract {
 		return Contract::create(
 			array(
+				'status'               => ContractStatus::ACTIVE,
 				'customer_id'          => 42,
 				'currency'             => 'USD',
 				'selling_plan_id'      => 7,
@@ -93,9 +95,6 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 						'country'    => 'US',
 					),
 				),
-				'meta'                 => array(
-					'source_channel' => 'pdp',
-				),
 			)
 		);
 	}
@@ -190,8 +189,6 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$this->assertArrayHasKey( Contract::ADDRESS_BILLING, $addresses );
 		$this->assertArrayHasKey( Contract::ADDRESS_SHIPPING, $addresses );
 		$this->assertSame( 'Ada', $addresses[ Contract::ADDRESS_BILLING ]['first_name'] );
-
-		$this->assertSame( 'pdp', $fetched->get_meta()['source_channel'] );
 	}

 	/**
@@ -206,7 +203,75 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$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 find_by_origin_order returns the matching contracts oldest first, with plan terms hydrated.
+	 */
+	public function test_find_by_origin_order_returns_matches_oldest_first(): void {
+		$first  = $this->sut->insert( $this->make_contract() );
+		$second = $this->sut->insert( $this->make_contract() );
+		$other  = $this->make_contract();
+		$other->set_origin_order_id( 2002 );
+		$this->sut->insert( $other );
+
+		$first_contract = $this->sut->find( $first );
+		$this->assertInstanceOf( Contract::class, $first_contract );
+		$this->seed_plan_snapshot(
+			$first_contract,
+			array(
+				'selling_plan_id' => 7,
+				'name'            => 'Monthly',
+			)
+		);
+
+		$found = $this->sut->find_by_origin_order( 1001 );
+
+		$this->assertSame(
+			array( $first, $second ),
+			array_map(
+				static function ( Contract $c ): ?int {
+					return $c->get_id();
+				},
+				$found
+			)
+		);
+		$snapshot = $found[0]->get_plan_snapshot();
+		$this->assertInstanceOf( PlanSnapshot::class, $snapshot );
+		$this->assertSame( 'Monthly', $snapshot->to_array()['name'] );
+		$this->assertSame( array(), $this->sut->find_by_origin_order( 9999 ) );
+	}
+
+	/**
+	 * Store a plan snapshot row and point the contract at it.
+	 *
+	 * @param Contract             $contract Stored contract.
+	 * @param array<string, mixed> $payload  Plan snapshot payload.
+	 */
+	private function seed_plan_snapshot( Contract $contract, array $payload ): void {
+		$plan = PlanSnapshot::from_array( $payload );
+		$contract->set_plan_snapshot_id(
+			( new SnapshotStore() )->insert( (int) $contract->get_id(), SnapshotStore::TYPE_PLAN, $plan->get_selling_plan_id(), $plan->to_payload(), $plan->get_schema_version() )
+		);
+		$this->sut->update_fields( $contract, array( 'plan_snapshot_id' ) );
+	}
+
+	/**
+	 * @testdox Meta written through the meta methods survives whole-contract writes.
+	 */
+	public function test_meta_survives_a_whole_entity_update(): void {
+		$id = $this->sut->insert( $this->make_contract() );
+		$this->sut->add_meta( $id, 'source_channel', 'pdp' );
+
+		$contract = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+		$contract->set_status( ContractStatus::ON_HOLD );
+		$this->assertTrue( $this->sut->update( $contract ) );
+		$this->assertSame( 'pdp', $this->sut->get_meta( $id, 'source_channel', true ) );
+
+		$contract->set_status( ContractStatus::ACTIVE );
+		$this->assertTrue( $this->sut->update_if_status( $contract, ContractStatus::ON_HOLD ) );
+		$this->assertSame( array( 'pdp' ), $this->sut->get_meta( $id, 'source_channel' ) );
 	}

 	/**
@@ -258,6 +323,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		return $this->sut->insert(
 			Contract::create(
 				array(
+					'extension_slug'   => 'engine-tests',
 					'customer_id'      => $customer_id,
 					'status'           => $status,
 					'currency'         => 'USD',
@@ -525,7 +591,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$counts = $this->sut->count_by_status();

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

 	/**
@@ -614,6 +680,8 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$id = $this->sut->insert(
 			Contract::create(
 				array(
+					'extension_slug'  => 'engine-tests',
+					'status'          => ContractStatus::ACTIVE,
 					'customer_id'     => 1,
 					'currency'        => 'EUR',
 					'selling_plan_id' => 2,
@@ -628,41 +696,16 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox insert_with_origin_cycle records cycle 1's snapshot refs on the contract too.
+	 * @testdox A stored row with no extension_slug hydrates a null slug.
 	 */
-	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( new CycleStatus( 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_chain_head( $id );
-		$this->assertInstanceOf( Cycle::class, $current );
-		$this->assertSame( 1, $current->get_count() );
-		$this->assertTrue( $current->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
-	}
+	public function test_a_stored_null_extension_slug_hydrates_as_null(): void {
+		global $wpdb;

-	/**
-	 * @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(
+					'extension_slug'  => 'engine-tests',
+					'status'          => ContractStatus::ACTIVE,
 					'customer_id'     => 1,
 					'currency'        => 'EUR',
 					'selling_plan_id' => 2,
@@ -672,6 +715,9 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 			)
 		);

+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'extension_slug' => null ), array( 'id' => $id ) );
+
 		$fetched = $this->sut->find( $id );
 		$this->assertInstanceOf( Contract::class, $fetched );
 		$this->assertNull( $fetched->get_extension_slug() );
@@ -728,6 +774,8 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {

 		$mutated = Contract::create(
 			array(
+				'extension_slug'  => 'engine-tests',
+				'status'          => ContractStatus::ACTIVE,
 				'customer_id'     => 42,
 				'currency'        => 'USD',
 				'selling_plan_id' => 7,
@@ -743,7 +791,6 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 						'total'      => '24.00',
 					),
 				),
-				'meta'            => array( 'source_channel' => 'email' ),
 			)
 		);
 		$mutated->set_id( $id );
@@ -755,7 +802,6 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$items = $reloaded->get_items();
 		$this->assertCount( 1, $items );
 		$this->assertSame( 'Tea tin', $items[0]['item_name'] );
-		$this->assertSame( 'email', $reloaded->get_meta()['source_channel'] );
 	}

 	/**
@@ -792,6 +838,50 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$this->assertSame( '0', $remaining );
 	}

+	/**
+	 * @testdox update_fields on a deleted contract returns false before any child write.
+	 */
+	public function test_update_fields_returns_false_for_a_deleted_contract_and_writes_no_children(): void {
+		global $wpdb;
+
+		$id = $this->sut->insert( $this->make_contract() );
+		$this->assertTrue( $this->sut->delete( $id ) );
+
+		$stale = $this->make_contract();
+		$stale->set_id( $id );
+
+		$this->assertFalse( $this->sut->update_fields( $stale, array( 'status', 'items', 'addresses' ) ) );
+
+		foreach ( array( SchemaInstaller::TABLE_CONTRACT_ITEMS, SchemaInstaller::TABLE_CONTRACT_ADDRESSES ) as $table ) {
+			$table = SchemaInstaller::get_table_name( $table );
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+			$this->assertSame( '0', $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE contract_id = %d", $id ) ) );
+		}
+	}
+
+	/**
+	 * @testdox update_fields writing identical values (no changed rows) does not throw.
+	 */
+	public function test_update_fields_with_identical_values_does_not_throw(): void {
+		global $wpdb;
+
+		$id       = $this->sut->insert( $this->make_contract() );
+		$contract = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+
+		// A write in a new second changes date_updated_gmt; repeat until one changes no rows.
+		$zero_rows = false;
+		for ( $i = 0; $i < 3 && ! $zero_rows; $i++ ) {
+			$this->assertTrue( $this->sut->update_fields( $contract, array( 'status', 'next_payment_gmt' ) ) );
+			$zero_rows = 0 === $wpdb->rows_affected;
+		}
+
+		$this->assertTrue( $zero_rows, 'A write changed no rows.' );
+		$stored = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $stored );
+		$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
+	}
+
 	/**
 	 * @testdox append_cycle inserts a cycle reachable as the chain's current cycle.
 	 */
@@ -901,27 +991,6 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$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.
 	 */
@@ -1129,9 +1198,13 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	 * @testdox find_due skips a due contract with no owner.
 	 */
 	public function test_find_due_skips_a_contract_with_no_owner(): void {
+		global $wpdb;
+
 		$now      = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
 		$owned    = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
-		$no_owner = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::BILLED, null, null, null );
+		$no_owner = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'extension_slug' => null ), array( 'id' => $no_owner ) );

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

@@ -1444,7 +1517,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	 * @param string      $head_status      The head cycle status (a CycleStatus value).
 	 * @param string|null $claimed_until    The head cycle lease expiry, or null for none.
 	 * @param string|null $head_ends_at     The head period end; defaults to `$next_payment_gmt`.
-	 * @param string|null $owner            The owning extension slug; defaults to the registered test owner.
+	 * @param string      $owner            The owning extension slug; defaults to the registered test owner.
 	 */
 	private function insert_contract_due_at(
 		?string $next_payment_gmt,
@@ -1453,7 +1526,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		string $head_status = CycleStatus::BILLED,
 		?string $claimed_until = null,
 		?string $head_ends_at = null,
-		?string $owner = self::OWNER
+		string $owner = self::OWNER
 	): int {
 		$contract = Contract::create(
 			array(
@@ -1628,6 +1701,60 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$this->sut->append_cycle( $this->make_cycle( $id, 2, 1, '2026-08-15 00:00:00', '2026-09-15 00:00:00' ) );
 	}

+	/**
+	 * Build a billing cycle whose position the append assigns.
+	 *
+	 * @param int                  $contract_id Contract id.
+	 * @param array<string, mixed> $overrides   Extra or replacement keys.
+	 */
+	private function make_unpositioned_cycle( int $contract_id, array $overrides = array() ): Cycle {
+		return Cycle::create(
+			array_merge(
+				array(
+					'contract_id'   => $contract_id,
+					'starts_at_gmt' => '2026-07-15 00:00:00',
+					'ends_at_gmt'   => '2026-08-15 00:00:00',
+					'currency'      => 'USD',
+				),
+				$overrides
+			)
+		);
+	}
+
+	/**
+	 * @testdox append_cycle assigns sequence 1 to the first unpositioned cycle and leaves the count alone.
+	 */
+	public function test_append_cycle_assigns_the_first_sequence_no(): void {
+		$id    = $this->sut->insert( $this->make_contract() );
+		$cycle = $this->make_unpositioned_cycle( $id );
+
+		$this->sut->append_cycle( $cycle );
+
+		$this->assertSame( 1, $cycle->get_sequence_no() );
+		$this->assertNull( $cycle->get_count() );
+		$head = $this->sut->find_chain_head( $id );
+		$this->assertInstanceOf( Cycle::class, $head );
+		$this->assertSame( $cycle->get_id(), $head->get_id() );
+	}
+
+	/**
+	 * @testdox append_cycle assigns the head's sequence + 1 within the cycle's own chain.
+	 */
+	public function test_append_cycle_assigns_the_next_sequence_no_in_its_chain(): void {
+		$id = $this->sut->insert( $this->make_contract() );
+		$this->sut->append_cycle( $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' ) );
+		$this->sut->append_cycle( $this->make_cycle( $id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00' ) );
+
+		$next  = $this->make_unpositioned_cycle( $id, array( 'count' => 3 ) );
+		$other = $this->make_unpositioned_cycle( $id, array( 'kind' => 'shipping' ) );
+		$this->sut->append_cycle( $next );
+		$this->sut->append_cycle( $other );
+
+		$this->assertSame( 3, $next->get_sequence_no() );
+		$this->assertSame( 3, $next->get_count() );
+		$this->assertSame( 1, $other->get_sequence_no() );
+	}
+
 	/**
 	 * @testdox Multiple non-counting cycles (count = null) coexist in one chain.
 	 */
@@ -1643,9 +1770,6 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$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 ) );
 	}

 	/**
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 78bd8fa2528..bb9251991f9 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,7 +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\Core\Support\Coercion;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;

 /**
@@ -251,10 +251,44 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox The schema version is 2.4.0 (owner-scoped due scan).
+	 * @testdox The schema version is 2.5.0 (nullable contract identity columns, HPOS-style meta indexes).
 	 */
-	public function test_schema_version_is_2_4_0(): void {
-		$this->assertSame( '2.4.0', SchemaInstaller::get_version() );
+	public function test_schema_version_is_2_5_0(): void {
+		$this->assertSame( '2.5.0', SchemaInstaller::get_version() );
+	}
+
+	/**
+	 * @testdox Contract identity columns are nullable until the extension supplies them.
+	 */
+	public function test_contracts_identity_columns_are_nullable(): void {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+		foreach ( array( 'customer_id', 'currency', 'selling_plan_id', 'start_gmt' ) as $column ) {
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+			$row = $wpdb->get_row( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", $column ), ARRAY_A );
+
+			$this->assertIsArray( $row, "Expected a contracts.{$column} column." );
+			$this->assertSame( 'YES', $row['Null'] ?? null, "Expected contracts.{$column} to be NULLable." );
+		}
+	}
+
+	/**
+	 * @testdox Contract meta carries the HPOS-style key/value indexes and no contract_key index.
+	 */
+	public function test_contract_meta_has_hpos_style_indexes(): void {
+		$table   = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META );
+		$indexes = $this->index_names( $table );
+
+		$this->assertContains( 'meta_key_value', $indexes );
+		$this->assertContains( 'contract_meta_key_value', $indexes );
+		$this->assertNotContains( 'contract_key', $indexes );
+		$this->assertSame( array( 'meta_key', 'meta_value' ), $this->index_columns( $table, 'meta_key_value' ) );
+		$this->assertSame(
+			array( 'contract_id', 'meta_key', 'meta_value' ),
+			$this->index_columns( $table, 'contract_meta_key_value' )
+		);
 	}

 	public function test_cycles_table_has_expected_columns(): void {
@@ -501,7 +535,7 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
 		$names = array();
 		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
 			if ( is_array( $row ) ) {
-				$names[] = ScalarCoercion::coerce_string( $row['Key_name'] ?? null );
+				$names[] = Coercion::coerce_string( $row['Key_name'] ?? null );
 			}
 		}

@@ -525,8 +559,8 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
 		usort(
 			$rows,
 			static function ( $a, $b ): int {
-				$a_seq = is_array( $a ) ? ScalarCoercion::coerce_int( $a['Seq_in_index'] ?? null ) : 0;
-				$b_seq = is_array( $b ) ? ScalarCoercion::coerce_int( $b['Seq_in_index'] ?? null ) : 0;
+				$a_seq = is_array( $a ) ? Coercion::coerce_int( $a['Seq_in_index'] ?? null ) : 0;
+				$b_seq = is_array( $b ) ? Coercion::coerce_int( $b['Seq_in_index'] ?? null ) : 0;

 				return $a_seq <=> $b_seq;
 			}
@@ -535,7 +569,7 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
 		$columns = array();
 		foreach ( $rows as $row ) {
 			if ( is_array( $row ) ) {
-				$columns[] = ScalarCoercion::coerce_string( $row['Column_name'] ?? null );
+				$columns[] = Coercion::coerce_string( $row['Column_name'] ?? null );
 			}
 		}

@@ -561,7 +595,7 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {

 		foreach ( $rows as $row ) {
 			// Non_unique = 0 marks a UNIQUE index.
-			if ( is_array( $row ) && '0' !== ScalarCoercion::coerce_string( $row['Non_unique'] ?? null ) ) {
+			if ( is_array( $row ) && '0' !== Coercion::coerce_string( $row['Non_unique'] ?? null ) ) {
 				return false;
 			}
 		}
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
index 85c21eff967..e02ed9779a3 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SnapshotStoreTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SnapshotStoreTest.php
@@ -10,7 +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\Core\Support\Coercion;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SnapshotStore;

@@ -78,10 +78,10 @@ class SnapshotStoreTest extends EngineIntegrationTestCase {

 		$row = $this->snapshot_row( $id );
 		$this->assertNotNull( $row );
-		$this->assertSame( '100', ScalarCoercion::coerce_string( $row['contract_id'] ?? null ) );
+		$this->assertSame( '100', Coercion::coerce_string( $row['contract_id'] ?? null ) );
 		$this->assertSame( SnapshotStore::TYPE_PLAN, $row['snapshot_type'] );
-		$this->assertSame( '7', ScalarCoercion::coerce_string( $row['parent_id'] ?? null ) );
-		$this->assertSame( '2', ScalarCoercion::coerce_string( $row['schema_version'] ?? null ) );
+		$this->assertSame( '7', Coercion::coerce_string( $row['parent_id'] ?? null ) );
+		$this->assertSame( '2', Coercion::coerce_string( $row['schema_version'] ?? null ) );
 	}

 	/**
@@ -109,7 +109,7 @@ class SnapshotStoreTest extends EngineIntegrationTestCase {
 		$row = $this->snapshot_row( $id );

 		$this->assertNotNull( $row );
-		$this->assertSame( $payload, json_decode( ScalarCoercion::coerce_string( $row['payload'] ?? null ), true ) );
+		$this->assertSame( $payload, json_decode( Coercion::coerce_string( $row['payload'] ?? null ), true ) );
 	}

 	/**
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php
new file mode 100644
index 00000000000..a3e210e1cdf
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php
@@ -0,0 +1,147 @@
+<?php
+/**
+ * Integration tests for the shared facade argument validators.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Support;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use EngineIntegrationTestCase;
+use InvalidArgumentException;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator
+ */
+class ArgumentValidatorTest extends EngineIntegrationTestCase {
+
+	/**
+	 * @dataProvider provide_valid_values
+	 *
+	 * @param string            $method   Validator method.
+	 * @param array<int, mixed> $args     Validator arguments.
+	 * @param mixed             $expected Normalized value.
+	 */
+	public function test_a_valid_value_is_returned_normalized( string $method, array $args, $expected ): void {
+		$this->assertSame( $expected, ArgumentValidator::$method( ...$args ) );
+	}
+
+	/**
+	 * @return array<string, array{0: string, 1: array<int, mixed>, 2: mixed}>
+	 */
+	public function provide_valid_values(): array {
+		return array(
+			'currency'              => array( 'validate_currency', array( 'EUR' ), 'EUR' ),
+			'string'                => array( 'validate_string', array( 'status', 'active' ), 'active' ),
+			'nullable string'       => array( 'validate_nullable_string', array( 'title', null ), null ),
+			'nullable id as digits' => array( 'validate_nullable_id', array( 'order_id', '42' ), 42 ),
+			'nullable date object'  => array(
+				'validate_nullable_date',
+				array( 'start_gmt', new DateTimeImmutable( '2026-01-01 03:00:00', new DateTimeZone( 'Europe/Berlin' ) ) ),
+				'2026-01-01 02:00:00',
+			),
+			'money'                 => array( 'validate_money', array( 'tax_total', '1.5' ), '1.50000000' ),
+			'money null is zero'    => array( 'validate_money', array( 'tax_total', null ), '0.00000000' ),
+			'list of arrays'        => array( 'validate_list_of_arrays', array( 'items', array( array( 'name' => 'a' ) ) ), array( array( 'name' => 'a' ) ) ),
+		);
+	}
+
+	/**
+	 * @dataProvider provide_invalid_values
+	 *
+	 * @param string            $method  Validator method.
+	 * @param array<int, mixed> $args    Validator arguments.
+	 * @param string            $message Expected exception message.
+	 */
+	public function test_an_invalid_value_is_rejected_naming_the_field( string $method, array $args, string $message ): void {
+		$this->expectException( InvalidArgumentException::class );
+		$this->expectExceptionMessage( $message );
+
+		ArgumentValidator::$method( ...$args );
+	}
+
+	/**
+	 * @return array<string, array{0: string, 1: array<int, mixed>, 2: string}>
+	 */
+	public function provide_invalid_values(): array {
+		return array(
+			'currency'        => array( 'validate_currency', array( 'eur' ), '"currency" must be null or a three-letter uppercase ISO-4217 code.' ),
+			'string'          => array( 'validate_string', array( 'status', 5 ), '"status" must be a string.' ),
+			'nullable string' => array( 'validate_nullable_string', array( 'title', 5 ), '"title" must be null or a string.' ),
+			'nullable id'     => array( 'validate_nullable_id', array( 'order_id', 0 ), '"order_id" must be null or a positive integer.' ),
+			'nullable date'   => array( 'validate_nullable_date', array( 'start_gmt', '2026-02-30 00:00:00' ), '"start_gmt" must be null, a DateTimeInterface, or a GMT "Y-m-d H:i:s" string.' ),
+			'money'           => array( 'validate_money', array( 'tax_total', 'ten' ), '"tax_total" must be a number or a numeric string.' ),
+			'list of arrays'  => array( 'validate_list_of_arrays', array( 'items', array( 'a' => array() ) ), '"items" must be a list of arrays.' ),
+		);
+	}
+
+	public function test_filter_known_keys_drops_unknown_keys_with_a_notice(): void {
+		$this->setExpectedIncorrectUsage( 'Acme::write' );
+		$messages = array();
+		add_action(
+			'doing_it_wrong_run',
+			static function ( $function_name, $message ) use ( &$messages ): void {
+				$messages[] = $message;
+			},
+			10,
+			2
+		);
+
+		$filtered = ArgumentValidator::filter_known_keys(
+			'Acme::write',
+			array(
+				'known' => 1,
+				'other' => 2,
+			),
+			array( 'known' => true ),
+			'item key'
+		);
+
+		$this->assertSame( array( 'known' => 1 ), $filtered );
+		$this->assertSame( array( 'Unknown item key "other" ignored.' ), $messages );
+	}
+
+	public function test_contract_items_keep_item_fields_and_drop_unknown_keys_with_a_notice(): void {
+		$this->setExpectedIncorrectUsage( 'facade' );
+
+		$rows = ArgumentValidator::validate_contract_items(
+			array(
+				array(
+					'item_name' => 'Coffee',
+					'price'     => '1',
+				),
+			),
+			'facade'
+		);
+
+		$this->assertSame( array( array( 'item_name' => 'Coffee' ) ), $rows );
+	}
+
+	public function test_contract_addresses_reject_an_unknown_address_type(): void {
+		$this->expectException( InvalidArgumentException::class );
+		$this->expectExceptionMessage( '"addresses" must be an array keyed "billing" / "shipping" with array values.' );
+
+		ArgumentValidator::validate_contract_addresses( array( 'home' => array( 'first_name' => 'Ada' ) ) );
+	}
+
+	public function test_contract_addresses_keep_address_fields_and_drop_unknown_keys_with_a_notice(): void {
+		$this->setExpectedIncorrectUsage( 'facade' );
+
+		$addresses = ArgumentValidator::validate_contract_addresses(
+			array(
+				'billing' => array(
+					'first_name' => 'Ada',
+					'zip'        => '1',
+				),
+			),
+			'facade'
+		);
+
+		$this->assertSame( array( 'billing' => array( 'first_name' => 'Ada' ) ), $addresses );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/ContractViewTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/ContractViewTest.php
new file mode 100644
index 00000000000..341a9744bda
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/ContractViewTest.php
@@ -0,0 +1,147 @@
+<?php
+/**
+ * Unit tests for the ContractView DTO.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Api\View;
+
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView
+ */
+class ContractViewTest extends TestCase {
+
+	/**
+	 * A complete stored contract row.
+	 *
+	 * @return array<string, mixed>
+	 */
+	private function row(): array {
+		return array(
+			'id'                   => 10,
+			'status'               => 'active',
+			'customer_id'          => 1,
+			'currency'             => 'USD',
+			'selling_plan_id'      => 2,
+			'origin_order_id'      => 3,
+			'extension_slug'       => 'acme-subs',
+			'payment_method'       => 'dummy',
+			'payment_method_title' => 'Dummy',
+			'payment_token_id'     => 4,
+			'start_gmt'            => '2026-01-01 00:00:00',
+			'next_payment_gmt'     => '2026-02-01 00:00:00',
+			'last_payment_gmt'     => '2026-01-01 00:00:00',
+			'last_attempt_gmt'     => '2026-01-01 00:00:01',
+			'trial_end_gmt'        => '2026-01-08 00:00:00',
+			'end_gmt'              => '2027-01-01 00:00:00',
+			'billing_total'        => '20.00',
+			'discount_total'       => '1.00',
+			'shipping_total'       => '5.00',
+			'tax_total'            => '2.50',
+			'schedule_source'      => Contract::SCHEDULE_SOURCE_GATEWAY,
+		);
+	}
+
+	public function test_every_getter_reads_the_stored_row(): void {
+		$view = ContractView::from_contract( Contract::from_storage( $this->row() ), false );
+
+		$this->assertSame( 10, $view->get_id() );
+		$this->assertSame( 'active', $view->get_status() );
+		$this->assertSame( 'acme-subs', $view->get_extension_slug() );
+		$this->assertSame( 1, $view->get_customer_id() );
+		$this->assertSame( 'USD', $view->get_currency() );
+		$this->assertSame( 2, $view->get_selling_plan_id() );
+		$this->assertSame( 3, $view->get_origin_order_id() );
+		$this->assertSame( 'dummy', $view->get_payment_method() );
+		$this->assertSame( 'Dummy', $view->get_payment_method_title() );
+		$this->assertSame( 4, $view->get_payment_token_id() );
+		$this->assertSame( '2026-01-01 00:00:00', $view->get_start_gmt() );
+		$this->assertSame( '2026-02-01 00:00:00', $view->get_next_payment_gmt() );
+		$this->assertSame( '2026-01-01 00:00:00', $view->get_last_payment_gmt() );
+		$this->assertSame( '2026-01-01 00:00:01', $view->get_last_attempt_gmt() );
+		$this->assertSame( '2026-01-08 00:00:00', $view->get_trial_end_gmt() );
+		$this->assertSame( '2027-01-01 00:00:00', $view->get_end_gmt() );
+		$this->assertSame( '20.00000000', $view->get_billing_total() );
+		$this->assertSame( '1.00000000', $view->get_discount_total() );
+		$this->assertSame( '5.00000000', $view->get_shipping_total() );
+		$this->assertSame( '2.50000000', $view->get_tax_total() );
+		$this->assertSame( Contract::SCHEDULE_SOURCE_GATEWAY, $view->get_schedule_source() );
+	}
+
+	public function test_optional_fields_read_as_null(): void {
+		$view = ContractView::from_contract( Contract::from_storage( array( 'id' => 5 ) ), true );
+
+		$this->assertNull( $view->get_extension_slug() );
+		$this->assertNull( $view->get_customer_id() );
+		$this->assertNull( $view->get_currency() );
+		$this->assertNull( $view->get_selling_plan_id() );
+		$this->assertNull( $view->get_payment_method() );
+		$this->assertNull( $view->get_start_gmt() );
+	}
+
+	public function test_children_are_null_when_not_loaded(): void {
+		$view = ContractView::from_contract( Contract::from_storage( $this->row(), null, array( array( 'item_name' => 'Tea' ) ) ), false );
+
+		$this->assertNull( $view->get_items() );
+		$this->assertNull( $view->get_addresses() );
+	}
+
+	public function test_children_are_projected_onto_the_write_shape(): void {
+		$items     = array(
+			array(
+				'id'           => '7',
+				'contract_id'  => '42',
+				'item_name'    => 'Tea',
+				'item_type'    => 'line_item',
+				'product_id'   => '12',
+				'variation_id' => null,
+				'quantity'     => '2.0000',
+				'subtotal'     => '10.00000000',
+				'total'        => '9.00000000',
+				'taxes'        => '{"total":{"1":"0.90"}}',
+			),
+		);
+		$addresses = array(
+			'billing' => array(
+				'id'           => '3',
+				'contract_id'  => '42',
+				'address_type' => 'billing',
+				'city'         => 'Lisbon',
+			),
+		);
+
+		$loaded = ContractView::from_contract( Contract::from_storage( $this->row(), null, $items, $addresses ), true );
+		$empty  = ContractView::from_contract( Contract::from_storage( $this->row() ), true );
+
+		$this->assertSame(
+			array(
+				array(
+					'item_name'    => 'Tea',
+					'item_type'    => 'line_item',
+					'product_id'   => 12,
+					'variation_id' => null,
+					'quantity'     => '2.0000',
+					'subtotal'     => '10.00000000',
+					'total'        => '9.00000000',
+					'taxes'        => array( 'total' => array( 1 => '0.90' ) ),
+				),
+			),
+			$loaded->get_items()
+		);
+		$addresses = $loaded->get_addresses();
+		$this->assertIsArray( $addresses );
+		$this->assertSame( array( 'billing' ), array_keys( $addresses ) );
+		$this->assertSame( Contract::ADDRESS_FIELDS, array_keys( $addresses['billing'] ) );
+		$this->assertSame( 'Lisbon', $addresses['billing']['city'] );
+		$this->assertNull( $addresses['billing']['phone'] );
+		$this->assertSame( array(), $empty->get_items() );
+		$this->assertSame( array(), $empty->get_addresses() );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/CycleViewTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/CycleViewTest.php
new file mode 100644
index 00000000000..f4279620fd9
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/CycleViewTest.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * Unit tests for the CycleView DTO.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Api\View;
+
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\CycleView;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\View\CycleView
+ */
+class CycleViewTest extends TestCase {
+
+	public function test_every_getter_reads_the_stored_row(): void {
+		$cycle = Cycle::from_storage(
+			array(
+				'id'             => 9,
+				'contract_id'    => 10,
+				'kind'           => Cycle::KIND_BILLING,
+				'sequence_no'    => 2,
+				'count'          => 2,
+				'status'         => 'billed',
+				'starts_at_gmt'  => '2026-02-01 00:00:00',
+				'ends_at_gmt'    => '2026-03-01 00:00:00',
+				'expected_total' => '20.00',
+				'currency'       => 'USD',
+				'order_id'       => 77,
+			)
+		);
+
+		$view = CycleView::from_cycle( $cycle );
+
+		$this->assertSame( 9, $view->get_id() );
+		$this->assertSame( 10, $view->get_contract_id() );
+		$this->assertSame( Cycle::KIND_BILLING, $view->get_kind() );
+		$this->assertSame( 2, $view->get_sequence_no() );
+		$this->assertSame( 2, $view->get_count() );
+		$this->assertSame( 'billed', $view->get_status() );
+		$this->assertSame( '2026-02-01 00:00:00', $view->get_starts_at_gmt() );
+		$this->assertSame( '2026-03-01 00:00:00', $view->get_ends_at_gmt() );
+		$this->assertSame( '20.00000000', $view->get_expected_total() );
+		$this->assertSame( 'USD', $view->get_currency() );
+		$this->assertSame( 77, $view->get_order_id() );
+	}
+
+	public function test_a_non_counting_cycle_without_an_order_reads_null(): void {
+		$cycle = Cycle::from_storage(
+			array(
+				'id'            => 1,
+				'contract_id'   => 2,
+				'sequence_no'   => 1,
+				'count'         => null,
+				'status'        => 'pending',
+				'starts_at_gmt' => '2026-02-01 00:00:00',
+				'ends_at_gmt'   => '2026-03-01 00:00:00',
+				'currency'      => 'USD',
+			)
+		);
+
+		$view = CycleView::from_cycle( $cycle );
+
+		$this->assertNull( $view->get_count() );
+		$this->assertNull( $view->get_order_id() );
+		$this->assertSame( 'pending', $view->get_status() );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTest.php
index 7082643bf13..c953d4541ee 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTest.php
@@ -28,13 +28,17 @@ class ContractStatusTest extends TestCase {
 		$this->assertFalse( ContractStatus::is_registered( 'nonsense' ) );
 	}

-	public function test_defaults_lists_the_five_engine_slugs(): void {
+	public function test_defaults_lists_the_six_engine_slugs(): void {
 		$this->assertSame(
-			array( 'active', 'on-hold', 'pending-cancellation', 'cancelled', 'expired' ),
+			array( 'draft', 'active', 'on-hold', 'pending-cancellation', 'cancelled', 'expired' ),
 			ContractStatus::get_defaults()
 		);
 	}

+	public function test_draft_is_a_registered_default(): void {
+		$this->assertTrue( ContractStatus::is_registered( 'draft' ) );
+	}
+
 	public function test_all_equals_the_defaults_with_nothing_registered(): void {
 		$this->assertSame( ContractStatus::get_defaults(), ContractStatus::get_all() );
 	}
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 d04957c122f..98e56aab395 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
@@ -66,6 +66,7 @@ class ContractTest extends TestCase {
 	private function make_contract(): Contract {
 		return Contract::create(
 			array(
+				'status'           => ContractStatus::ACTIVE,
 				'customer_id'      => 1,
 				'currency'         => 'USD',
 				'selling_plan_id'  => 2,
@@ -78,9 +79,152 @@ class ContractTest extends TestCase {
 	}

 	/**
-	 * @testdox create() builds an active contract from its identity and live config.
+	 * @testdox create() with no attributes yields a draft with no customer, currency, plan or start.
 	 */
-	public function test_create_builds_an_active_contract(): void {
+	public function test_create_with_no_args_is_an_empty_draft(): void {
+		$contract = Contract::create( array( 'extension_slug' => 'acme-subs' ) );
+
+		$this->assertSame( ContractStatus::DRAFT, $contract->get_status() );
+		$this->assertNull( $contract->get_customer_id() );
+		$this->assertNull( $contract->get_currency() );
+		$this->assertNull( $contract->get_selling_plan_id() );
+		$this->assertNull( $contract->get_start_gmt() );
+
+		$row = $contract->to_storage();
+		$this->assertNull( $row['customer_id'] );
+		$this->assertNull( $row['currency'] );
+		$this->assertNull( $row['selling_plan_id'] );
+		$this->assertNull( $row['start_gmt'] );
+	}
+
+	/**
+	 * @testdox create() rejects a non-zero total without a currency.
+	 */
+	public function test_create_rejects_a_non_zero_total_without_a_currency(): void {
+		$this->expectException( DomainException::class );
+		$this->expectExceptionMessage( 'money totals require a currency' );
+
+		Contract::create(
+			array(
+				'extension_slug' => 'acme-subs',
+				'billing_total'  => '10',
+			)
+		);
+	}
+
+	/**
+	 * @testdox Zero totals need no currency.
+	 */
+	public function test_zero_totals_need_no_currency(): void {
+		$contract = Contract::create(
+			array(
+				'extension_slug' => 'acme-subs',
+				'billing_total'  => '0',
+			)
+		);
+
+		$contract->assert_money_has_currency();
+		$this->assertNull( $contract->get_currency() );
+	}
+
+	/**
+	 * @testdox Clearing the currency while a total is non-zero is refused.
+	 */
+	public function test_clearing_the_currency_with_a_non_zero_total_is_refused(): void {
+		$contract = Contract::create(
+			array(
+				'extension_slug' => 'acme-subs',
+				'currency'       => 'USD',
+				'billing_total'  => '10',
+			)
+		);
+		$contract->set_currency( null );
+
+		$this->expectException( DomainException::class );
+		$contract->assert_money_has_currency();
+	}
+
+	/**
+	 * @testdox create() requires an extension_slug.
+	 */
+	public function test_create_requires_an_extension_slug(): void {
+		$this->expectException( DomainException::class );
+		$this->expectExceptionMessage( 'Contract: extension_slug is required' );
+
+		Contract::create( array() );
+	}
+
+	/**
+	 * @testdox create() rejects an empty extension_slug.
+	 */
+	public function test_create_rejects_an_empty_extension_slug(): void {
+		$this->expectException( DomainException::class );
+
+		Contract::create( array( 'extension_slug' => '' ) );
+	}
+
+	/**
+	 * @testdox from_storage() hydrates a row with no extension_slug.
+	 */
+	public function test_from_storage_allows_a_null_extension_slug(): void {
+		$contract = Contract::from_storage( array( 'id' => 1 ) );
+
+		$this->assertNull( $contract->get_extension_slug() );
+	}
+
+	/**
+	 * @testdox The facade setters round-trip their values.
+	 */
+	public function test_setters_round_trip(): void {
+		$contract = Contract::create( array( 'extension_slug' => 'acme-subs' ) );
+
+		$contract->set_customer_id( 7 );
+		$contract->set_currency( 'EUR' );
+		$contract->set_selling_plan_id( 8 );
+		$contract->set_origin_order_id( 9 );
+		$contract->set_start_gmt( '2026-03-01 00:00:00' );
+		$contract->set_schedule_source( Contract::SCHEDULE_SOURCE_GATEWAY );
+		$contract->set_items( array( array( 'item_name' => 'Coffee' ), 'skipped' ) );
+		$contract->set_addresses( array( Contract::ADDRESS_BILLING => array( 'city' => 'Lisbon' ) ) );
+
+		$this->assertSame( 7, $contract->get_customer_id() );
+		$this->assertSame( 'EUR', $contract->get_currency() );
+		$this->assertSame( 8, $contract->get_selling_plan_id() );
+		$this->assertSame( 9, $contract->get_origin_order_id() );
+		$this->assertSame( '2026-03-01 00:00:00', $contract->get_start_gmt() );
+		$this->assertSame( Contract::SCHEDULE_SOURCE_GATEWAY, $contract->get_schedule_source() );
+		$this->assertSame( array( array( 'item_name' => 'Coffee' ) ), $contract->get_items() );
+		$this->assertSame( array( 'billing' => array( 'city' => 'Lisbon' ) ), $contract->get_addresses() );
+
+		$contract->set_customer_id( null );
+		$contract->set_currency( null );
+		$contract->set_selling_plan_id( null );
+		$contract->set_origin_order_id( null );
+		$contract->set_start_gmt( null );
+
+		$this->assertNull( $contract->get_customer_id() );
+		$this->assertNull( $contract->get_currency() );
+		$this->assertNull( $contract->get_selling_plan_id() );
+		$this->assertNull( $contract->get_origin_order_id() );
+		$this->assertNull( $contract->get_start_gmt() );
+	}
+
+	/**
+	 * @testdox set_schedule_source() refuses an unknown source.
+	 */
+	public function test_set_schedule_source_rejects_an_unknown_source(): void {
+		$contract = Contract::create( array( 'extension_slug' => 'acme-subs' ) );
+
+		$this->expectException( DomainException::class );
+		$this->expectExceptionMessage( 'Contract: invalid schedule source "bogus".' );
+
+		$contract->set_schedule_source( 'bogus' );
+	}
+
+	/**
+	 * @testdox create() builds a contract from its identity and live config.
+	 */
+	public function test_create_builds_a_contract_from_its_identity(): void {
 		$contract = $this->make_contract();

 		$this->assertNull( $contract->get_id() );
@@ -101,6 +245,7 @@ class ContractTest extends TestCase {
 	public function test_create_defaults_live_config(): void {
 		$contract = Contract::create(
 			array(
+				'extension_slug'  => 'acme-subs',
 				'customer_id'     => 1,
 				'currency'        => 'USD',
 				'selling_plan_id' => 2,
@@ -109,7 +254,6 @@ class ContractTest extends TestCase {
 		);

 		$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() );
@@ -129,6 +273,7 @@ class ContractTest extends TestCase {
 	public function test_create_normalizes_live_totals(): void {
 		$contract = Contract::create(
 			array(
+				'extension_slug'  => 'acme-subs',
 				'customer_id'     => 1,
 				'currency'        => 'USD',
 				'selling_plan_id' => 2,
@@ -153,6 +298,7 @@ class ContractTest extends TestCase {
 	public function test_create_allows_a_null_origin_order_id(): void {
 		$contract = Contract::create(
 			array(
+				'extension_slug'  => 'acme-subs',
 				'customer_id'     => 1,
 				'currency'        => 'USD',
 				'selling_plan_id' => 2,
@@ -171,6 +317,7 @@ class ContractTest extends TestCase {

 		Contract::create(
 			array(
+				'extension_slug'  => 'acme-subs',
 				'customer_id'     => 1,
 				'currency'        => 'USD',
 				'selling_plan_id' => 2,
@@ -189,6 +336,7 @@ class ContractTest extends TestCase {

 		Contract::create(
 			array(
+				'extension_slug'  => 'acme-subs',
 				'customer_id'     => 1,
 				'currency'        => 'USD',
 				'selling_plan_id' => 2,
@@ -308,20 +456,18 @@ class ContractTest extends TestCase {
 	}

 	/**
-	 * @testdox from_storage() hydrates the plan snapshot, items, addresses, and meta children.
+	 * @testdox from_storage() hydrates the plan snapshot, items, and addresses.
 	 */
 	public function test_from_storage_hydrates_children(): void {
 		$snapshot  = PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ) );
 		$items     = array( array( 'product_id' => 42 ) );
 		$addresses = array( 'billing' => array( 'first_name' => 'Ada' ) );
-		$meta      = array( 'flag' => 'on' );

-		$contract = Contract::from_storage( $this->valid_row(), $snapshot, $items, $addresses, $meta );
+		$contract = Contract::from_storage( $this->valid_row(), $snapshot, $items, $addresses );

 		$this->assertSame( $snapshot, $contract->get_plan_snapshot() );
 		$this->assertSame( $items, $contract->get_items() );
 		$this->assertSame( $addresses, $contract->get_addresses() );
-		$this->assertSame( $meta, $contract->get_meta() );
 	}

 	/**
@@ -466,6 +612,7 @@ class ContractTest extends TestCase {

 		$created = Contract::create(
 			array(
+				'extension_slug'  => 'acme-subs',
 				'customer_id'     => 1,
 				'currency'        => 'USD',
 				'selling_plan_id' => 2,
@@ -495,26 +642,4 @@ class ContractTest extends TestCase {

 		$this->assertSame( 'legacy-paused', $contract->to_storage()['status'] );
 	}
-
-	/**
-	 * @testdox set_meta() adds, overwrites, and (with null) removes a key.
-	 */
-	public function test_set_meta_adds_overwrites_and_removes_a_key(): void {
-		$contract = Contract::from_storage( $this->valid_row(), null, array(), array(), array( 'keep' => 'me' ) );
-
-		$contract->set_meta( 'k', 'v' );
-		$this->assertSame(
-			array(
-				'keep' => 'me',
-				'k'    => 'v',
-			),
-			$contract->get_meta()
-		);
-
-		$contract->set_meta( 'k', 'w' );
-		$this->assertSame( 'w', $contract->get_meta()['k'] );
-
-		$contract->set_meta( 'k', null );
-		$this->assertSame( array( 'keep' => 'me' ), $contract->get_meta() );
-	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
index 8f6480c7d73..517f2c83e06 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
@@ -10,6 +10,7 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;

 use DomainException;
+use LogicException;
 use PHPUnit\Framework\TestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
@@ -125,6 +126,20 @@ class CycleTest extends TestCase {
 		$this->make_pending( array( 'sequence_no' => 0 ) );
 	}

+	public function test_create_requires_a_start_and_an_end(): void {
+		$this->expectException( DomainException::class );
+		$this->expectExceptionMessage( 'starts_at_gmt and ends_at_gmt are required' );
+
+		$this->make_pending( array( 'ends_at_gmt' => null ) );
+	}
+
+	public function test_create_requires_a_currency(): void {
+		$this->expectException( DomainException::class );
+		$this->expectExceptionMessage( 'currency is required' );
+
+		$this->make_pending( array( 'currency' => null ) );
+	}
+
 	public function test_create_rejects_a_non_positive_count(): void {
 		$this->expectException( DomainException::class );

@@ -442,6 +457,29 @@ class CycleTest extends TestCase {
 		$cycle->set_items_snapshot_id( 99 );
 	}

+	public function test_from_storage_keeps_stored_values_that_create_would_refuse(): void {
+		$cycle = Cycle::from_storage(
+			array(
+				'id'             => 5,
+				'contract_id'    => 0,
+				'sequence_no'    => 0,
+				'count'          => 0,
+				'kind'           => '',
+				'status'         => 'legacy-x',
+				'starts_at_gmt'  => '2026-03-01 00:00:00',
+				'ends_at_gmt'    => '2026-04-01 00:00:00',
+				'expected_total' => '20.00',
+				'currency'       => 'USD',
+			)
+		);
+
+		$row = $cycle->to_storage();
+		$this->assertSame( 0, $row['contract_id'] );
+		$this->assertSame( 0, $row['sequence_no'] );
+		$this->assertSame( 0, $row['count'] );
+		$this->assertSame( '', $row['kind'] );
+	}
+
 	public function test_from_storage_hydrates_a_non_counting_cycle(): void {
 		$cycle = Cycle::from_storage(
 			array(
@@ -495,19 +533,58 @@ class CycleTest extends TestCase {
 		$this->assertSame( 'pending', $row['status'] );
 	}

-	public function test_sequence_no_can_be_reassigned_after_construction(): void {
-		$cycle = $this->make_pending( array( 'sequence_no' => 1 ) );
+	/**
+	 * Build a cycle without `sequence_no` or `count`.
+	 */
+	private function make_unpositioned(): Cycle {
+		return Cycle::create(
+			array(
+				'contract_id'   => 7,
+				'starts_at_gmt' => '2026-02-01 00:00:00',
+				'ends_at_gmt'   => '2026-03-01 00:00:00',
+				'currency'      => 'USD',
+			)
+		);
+	}
+
+	public function test_create_without_a_sequence_no_leaves_it_to_the_append(): void {
+		$this->assertTrue( $this->make_unpositioned()->awaits_sequence_no() );
+		$this->assertFalse( $this->make_pending()->awaits_sequence_no() );
+	}
+
+	public function test_a_null_sequence_no_is_unassigned_like_an_absent_one(): void {
+		$this->assertTrue( $this->make_pending( array( 'sequence_no' => null ) )->awaits_sequence_no() );
+	}

-		$cycle->set_sequence_no( 4 );
+	public function test_create_without_a_count_is_non_counting(): void {
+		$this->assertNull( $this->make_unpositioned()->get_count() );
+	}
+
+	public function test_an_unassigned_sequence_no_cannot_be_read(): void {
+		$this->expectException( LogicException::class );
+
+		$this->make_unpositioned()->get_sequence_no();
+	}
+
+	public function test_the_sequence_no_can_be_assigned_once(): void {
+		$cycle = $this->make_unpositioned();
+
+		$cycle->assign_sequence_no( 4 );

 		$this->assertSame( 4, $cycle->get_sequence_no() );
+		$this->assertFalse( $cycle->awaits_sequence_no() );
 	}

-	public function test_set_sequence_no_rejects_a_non_positive_value(): void {
-		$cycle = $this->make_pending();
+	public function test_assign_sequence_no_rejects_a_non_positive_value(): void {
+		$this->expectException( DomainException::class );

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

 	public function test_id_can_be_stamped_after_persistence(): void {
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/StatusRegistryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/StatusRegistryTest.php
index ac3a19aa865..b17d1c8e9e0 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/StatusRegistryTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/StatusRegistryTest.php
@@ -27,7 +27,7 @@ class StatusRegistryTest extends TestCase {

 	public function test_all_returns_exactly_the_engine_defaults_with_nothing_registered(): void {
 		$this->assertSame(
-			array( 'active', 'on-hold', 'pending-cancellation', 'cancelled', 'expired' ),
+			array( 'draft', 'active', 'on-hold', 'pending-cancellation', 'cancelled', 'expired' ),
 			StatusRegistry::get_all( StatusRegistry::KIND_CONTRACT )
 		);
 		$this->assertSame(
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/CoercionTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/CoercionTest.php
new file mode 100644
index 00000000000..aefca28998b
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/CoercionTest.php
@@ -0,0 +1,111 @@
+<?php
+/**
+ * Unit tests for Coercion's array coercions.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Support;
+
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion
+ */
+class CoercionTest extends TestCase {
+
+	public function test_coerce_string_keyed_casts_keys_to_strings_and_keeps_values(): void {
+		$result = Coercion::coerce_string_keyed(
+			array(
+				0   => 'zero',
+				'a' => array( 1, 2 ),
+				7   => null,
+			)
+		);
+
+		$this->assertSame(
+			array(
+				'0' => 'zero',
+				'a' => array( 1, 2 ),
+				'7' => null,
+			),
+			$result
+		);
+	}
+
+	public function test_coerce_string_keyed_keeps_empty_array(): void {
+		$this->assertSame( array(), Coercion::coerce_string_keyed( array() ) );
+	}
+
+	/**
+	 * @dataProvider provide_non_arrays
+	 *
+	 * @param mixed $value Non-array input.
+	 */
+	public function test_coerce_list_of_arrays_returns_empty_for_non_array( $value ): void {
+		$this->assertSame( array(), Coercion::coerce_list_of_arrays( $value ) );
+	}
+
+	public function test_coerce_list_of_arrays_skips_non_array_rows_and_reindexes(): void {
+		$result = Coercion::coerce_list_of_arrays(
+			array(
+				'x' => array( 'sku' => 'A' ),
+				5   => 'not a row',
+				9   => array( 0 => 'b' ),
+				10  => null,
+			)
+		);
+
+		$this->assertSame(
+			array(
+				array( 'sku' => 'A' ),
+				array( '0' => 'b' ),
+			),
+			$result
+		);
+	}
+
+	/**
+	 * @dataProvider provide_non_arrays
+	 *
+	 * @param mixed $value Non-array input.
+	 */
+	public function test_coerce_map_of_arrays_returns_empty_for_non_array( $value ): void {
+		$this->assertSame( array(), Coercion::coerce_map_of_arrays( $value ) );
+	}
+
+	public function test_coerce_map_of_arrays_keeps_keys_and_skips_non_array_entries(): void {
+		$result = Coercion::coerce_map_of_arrays(
+			array(
+				'billing'  => array( 'city' => 'Lisbon' ),
+				'shipping' => 'not an entry',
+				3          => array( 1 => 'x' ),
+			)
+		);
+
+		$this->assertSame(
+			array(
+				'billing' => array( 'city' => 'Lisbon' ),
+				'3'       => array( '1' => 'x' ),
+			),
+			$result
+		);
+	}
+
+	/**
+	 * Non-array inputs.
+	 *
+	 * @return array<string, array{0: mixed}>
+	 */
+	public function provide_non_arrays(): array {
+		return array(
+			'null'   => array( null ),
+			'string' => array( 'items' ),
+			'int'    => array( 3 ),
+			'object' => array( new \stdClass() ),
+		);
+	}
+}