Commit ac247fc21a0 for woocommerce

commit ac247fc21a076aa256f43fa58894ee97191f0f5b
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date:   Mon Jul 6 16:23:22 2026 +0200

    Add subscriptions-engine customer-portal reads and lifecycle actions (#66000)

    - Extend Api\Subscriptions with ownership-checked customer reads (unknown and foreign-owned both read as null, the anti-IDOR rule), hold/reactivate/cancel-at-period-end verbs, and a windowed related-orders read
    - Add focused lifecycle services: Hold, Reactivation (on-hold only, with a forward-only next-payment recompute off the frozen snapshot cadence), and a period-end mode on Cancellation
    - Expose actions-only wc/v3 endpoints (POST hold/reactivate/cancel) behind a logged-in floor with the 401/404/409 matrix, responding with a minimal { id, status } domain summary
    - Hydrate the frozen plan snapshot on every contract read (a from_storage argument, batch-loaded per list page) so the snapshot cadence wins over the live plan, including in the renewal money path
    - Make contract status writes compare-and-set so concurrent lifecycle calls and renewal settles conflict loudly instead of silently clobbering each other

diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-customer-portal b/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-customer-portal
new file mode 100644
index 00000000000..05bade9841b
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-customer-portal
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the customer-portal engine surface: extend the Api\Subscriptions facade with customer-scoped reads (list_for_customer, get_for_customer), hold/reactivate/cancel-at-period-end verbs, and get_related_orders(); add focused per-operation contract services (Hold, Reactivation, and a period-end mode on Cancellation); add authenticated wc/v3 lifecycle-action endpoints (hold, reactivate, cancel) under Api\Rest responding with a domain summary; and hydrate the contract's frozen plan snapshot on the facade reads (PlanSnapshot::get_billing_policy()).
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php
new file mode 100644
index 00000000000..8baad69696a
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php
@@ -0,0 +1,352 @@
+<?php
+/**
+ * ContractsController - the authenticated `wc/v3` REST surface for the generic
+ * contract lifecycle actions.
+ *
+ * Routes (namespace `wc/v3`, base `subscriptions-engine/contracts`):
+ *
+ *   POST /{id}/hold              Put an active contract on hold.
+ *   POST /{id}/reactivate        Resume a held contract (next date recomputed forward).
+ *   POST /{id}/cancel            Cancel. Body `{ at_period_end: bool }` - true winds the
+ *                                contract down at the current period end, false cancels now.
+ *
+ * Actions only, deliberately: the engine exposes ONE implementation of the lifecycle
+ * transitions (guards, ownership, conflict semantics) that every consumer calls rather
+ * than re-implements - while READS for UI stay server-side, where each consumer shapes
+ * its own view from the {@see Subscriptions} facade. There are no read routes here and
+ * no view-model in the responses: an action responds with a minimal domain summary
+ * (`id` + resulting `status` slug, e.g. cancel lands on `pending-cancellation` or
+ * `cancelled` depending on the mode), so no consumer-specific presentation leaks into
+ * the engine. The summary is ADDITIVE: fields may appear; consumers tolerate unknown
+ * fields and must not assume the set is closed. A generic resource read API is a
+ * planned follow-up alongside the read-model views, when a consumer needs it.
+ *
+ * Every route requires a logged-in user, enforced through the shared
+ * {@see RESTPermissions} floor (core's cookie auth has already verified the REST nonce
+ * `wp_rest` by then). Per-route, ownership is enforced with the asymmetric not-found
+ * rule: a contract owned by another user returns 404 - IDENTICAL to an unknown id - so
+ * a caller never confirms the existence of a contract the requester does not own
+ * (anti-IDOR).
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api\Rest
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api\Rest;
+
+use DomainException;
+use Throwable;
+use WP_Error;
+use WP_REST_Controller;
+use WP_REST_Request;
+use WP_REST_Response;
+use WP_REST_Server;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Subscriptions;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\RESTPermissions;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * REST controller for the generic contract lifecycle actions.
+ */
+final class ContractsController extends WP_REST_Controller {
+
+	private const REST_NAMESPACE = 'wc/v3';
+
+	private const REST_BASE = 'subscriptions-engine/contracts';
+
+	/**
+	 * REST permissions.
+	 *
+	 * @var RESTPermissions
+	 */
+	private $rest_permissions;
+
+	/**
+	 * Build the controller.
+	 *
+	 * @param RESTPermissions|null $rest_permissions REST permissions; default instance when omitted.
+	 */
+	public function __construct( ?RESTPermissions $rest_permissions = null ) {
+		$this->namespace        = self::REST_NAMESPACE;
+		$this->rest_base        = self::REST_BASE;
+		$this->rest_permissions = $rest_permissions ?? new RESTPermissions();
+	}
+
+	/**
+	 * Wire route registration.
+	 */
+	public static function register_hooks(): void {
+		add_action(
+			'rest_api_init',
+			static function (): void {
+				( new self() )->register_routes();
+			}
+		);
+	}
+
+	/**
+	 * Register the routes.
+	 */
+	public function register_routes(): void {
+		register_rest_route(
+			self::REST_NAMESPACE,
+			'/' . self::REST_BASE . '/(?P<id>[\d]+)/hold',
+			array(
+				'args'   => $this->id_arg(),
+				array(
+					'methods'             => WP_REST_Server::CREATABLE,
+					'callback'            => array( $this, 'hold_item' ),
+					'permission_callback' => array( $this, 'permissions_check' ),
+				),
+				'schema' => array( $this, 'get_public_item_schema' ),
+			)
+		);
+
+		register_rest_route(
+			self::REST_NAMESPACE,
+			'/' . self::REST_BASE . '/(?P<id>[\d]+)/reactivate',
+			array(
+				'args'   => $this->id_arg(),
+				array(
+					'methods'             => WP_REST_Server::CREATABLE,
+					'callback'            => array( $this, 'reactivate_item' ),
+					'permission_callback' => array( $this, 'permissions_check' ),
+				),
+				'schema' => array( $this, 'get_public_item_schema' ),
+			)
+		);
+
+		register_rest_route(
+			self::REST_NAMESPACE,
+			'/' . self::REST_BASE . '/(?P<id>[\d]+)/cancel',
+			array(
+				'args'   => $this->id_arg(),
+				array(
+					'methods'             => WP_REST_Server::CREATABLE,
+					'callback'            => array( $this, 'cancel_item' ),
+					'permission_callback' => array( $this, 'permissions_check' ),
+					'args'                => array(
+						'at_period_end' => array(
+							'description'       => __( 'Whether to cancel at the end of the current billing period (true) or immediately (false).', 'woocommerce-subscriptions-engine' ),
+							'type'              => 'boolean',
+							'required'          => false,
+							'default'           => true,
+							'sanitize_callback' => 'rest_sanitize_boolean',
+							'validate_callback' => 'rest_validate_request_arg',
+						),
+					),
+				),
+				'schema' => array( $this, 'get_public_item_schema' ),
+			)
+		);
+	}
+
+	/**
+	 * Permission callback for all routes: the shared logged-in floor.
+	 *
+	 * Any logged-in user passes; per-contract ownership is enforced by the route
+	 * handlers (the asymmetric 404).
+	 *
+	 * @param WP_REST_Request $request Request.
+	 * @return true|WP_Error True when logged in, else a 401 error.
+	 */
+	public function permissions_check( $request ) {
+		return $this->rest_permissions->require_logged_in_permission();
+	}
+
+	/**
+	 * POST /{id}/hold.
+	 *
+	 * @param WP_REST_Request $request The request.
+	 * @return WP_REST_Response|WP_Error The domain summary, or an error.
+	 */
+	public function hold_item( $request ) {
+		return $this->run_action(
+			$request,
+			static function ( int $id ): void {
+				Subscriptions::hold( $id );
+			}
+		);
+	}
+
+	/**
+	 * POST /{id}/reactivate.
+	 *
+	 * @param WP_REST_Request $request The request.
+	 * @return WP_REST_Response|WP_Error The domain summary, or an error.
+	 */
+	public function reactivate_item( $request ) {
+		return $this->run_action(
+			$request,
+			static function ( int $id ): void {
+				Subscriptions::reactivate( $id );
+			}
+		);
+	}
+
+	/**
+	 * POST /{id}/cancel - body `{ at_period_end: bool }` (default true).
+	 *
+	 * `at_period_end` true winds the contract down at the current period end (graceful);
+	 * false cancels immediately, reusing the shared {@see Subscriptions::cancel()}.
+	 *
+	 * @param WP_REST_Request $request The request.
+	 * @return WP_REST_Response|WP_Error The domain summary, or an error.
+	 */
+	public function cancel_item( $request ) {
+		// Boolean-typed, defaulted, and `rest_sanitize_boolean`-sanitized by the route
+		// 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' ) );
+
+		return $this->run_action(
+			$request,
+			static function ( int $id ) use ( $at_period_end ): void {
+				if ( $at_period_end ) {
+					Subscriptions::cancel_at_period_end( $id );
+				} else {
+					Subscriptions::cancel( $id );
+				}
+			}
+		);
+	}
+
+	/**
+	 * Serialize a contract as the action-response domain summary.
+	 *
+	 * 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 WP_REST_Request $request Request.
+	 * @return WP_REST_Response
+	 */
+	public function prepare_item_for_response( $item, $request ) {
+		$data = array(
+			'id'     => (int) $item->get_id(),
+			'status' => $item->get_status(),
+		);
+
+		$data = $this->add_additional_fields_to_object( $data, $request );
+
+		return rest_ensure_response( $data );
+	}
+
+	/**
+	 * Get item schema: the action-response domain summary.
+	 *
+	 * @return array<string, mixed>
+	 */
+	public function get_item_schema(): array {
+		if ( $this->schema ) {
+			return $this->add_additional_fields_schema( $this->schema );
+		}
+
+		$this->schema = array(
+			'$schema'    => 'http://json-schema.org/draft-04/schema#',
+			'title'      => 'subscription_engine_contract_action',
+			'type'       => 'object',
+			'properties' => array(
+				'id'     => array(
+					'description' => __( 'Unique identifier for the subscription contract.', 'woocommerce-subscriptions-engine' ),
+					'type'        => 'integer',
+					'context'     => array( 'view' ),
+					'readonly'    => true,
+				),
+				'status' => array(
+					'description' => __( 'Contract status after the action.', 'woocommerce-subscriptions-engine' ),
+					'type'        => 'string',
+					'context'     => array( 'view' ),
+					'readonly'    => true,
+				),
+			),
+		);
+
+		return $this->add_additional_fields_schema( $this->schema );
+	}
+
+	/**
+	 * Run a lifecycle action behind the ownership guard, then return the domain
+	 * summary with the resulting status.
+	 *
+	 * A `DomainException` (an illegal transition for the contract's current state) maps to
+	 * a 409 Conflict; any other failure maps to a 500. The ownership guard keeps the
+	 * asymmetric 404 for not-owned / unknown.
+	 *
+	 * @param WP_REST_Request $request The request (carries the id).
+	 * @param callable        $action  Runs the lifecycle action; receives the contract id.
+	 * @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' ) );
+		$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 ) ) {
+			return $this->not_found_error();
+		}
+
+		try {
+			$action( $contract_id );
+		} catch ( DomainException $e ) {
+			return new WP_Error(
+				'woocommerce_subscriptions_engine_illegal_action',
+				__( 'That action is not available for this subscription right now.', 'woocommerce-subscriptions-engine' ),
+				array( 'status' => 409 )
+			);
+		} catch ( Throwable $e ) {
+			return new WP_Error(
+				'woocommerce_subscriptions_engine_action_failed',
+				__( 'The subscription could not be updated. Please try again.', 'woocommerce-subscriptions-engine' ),
+				array( 'status' => 500 )
+			);
+		}
+
+		// 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 );
+		if ( null === $refreshed ) {
+			return new WP_Error(
+				'woocommerce_subscriptions_engine_refresh_failed',
+				__( 'The subscription was updated, but its refreshed state could not be loaded.', 'woocommerce-subscriptions-engine' ),
+				array( 'status' => 500 )
+			);
+		}
+
+		return $this->prepare_item_for_response( $refreshed, $request );
+	}
+
+	/**
+	 * The shared 404, identical for unknown and not-owned contracts.
+	 */
+	private function not_found_error(): WP_Error {
+		return new WP_Error(
+			'woocommerce_subscriptions_engine_contract_not_found',
+			__( 'Subscription not found.', 'woocommerce-subscriptions-engine' ),
+			array( 'status' => 404 )
+		);
+	}
+
+	/**
+	 * Route-level arg schema for the `{id}` path parameter.
+	 *
+	 * @return array<string, mixed>
+	 */
+	private function id_arg(): array {
+		return array(
+			'id' => array(
+				'description'       => __( 'Unique identifier for the subscription contract.', 'woocommerce-subscriptions-engine' ),
+				'type'              => 'integer',
+				'sanitize_callback' => 'absint',
+				'validate_callback' => 'rest_validate_request_arg',
+			),
+		);
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
index 5992360a64f..a7d34a8b2d0 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
@@ -23,7 +23,10 @@ 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;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Reactivation;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;

@@ -37,7 +40,11 @@ defined( 'ABSPATH' ) || exit;
 final class Subscriptions {

 	/**
-	 * Fetch a subscription contract by id.
+	 * 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.
@@ -62,6 +69,50 @@ final class Subscriptions {
 		);
 	}

+	/**
+	 * 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.
 	 *
@@ -73,6 +124,24 @@ final class Subscriptions {
 		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
+	 * facade-only so a consumer never reaches into the order-linkage internals.
+	 *
+	 * Returns live `WC_Order` objects; presentation shaping is the caller's job.
+	 * A long-running contract accumulates one renewal 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> Related orders, newest first.
+	 */
+	public static function get_related_orders( int $contract_id, int $limit = -1, int $offset = 0 ): array {
+		return ( new RelatedOrders() )->for_contract( $contract_id, $limit, $offset );
+	}
+
 	/**
 	 * Cancel a subscription contract.
 	 *
@@ -88,6 +157,54 @@ final class Subscriptions {
 		return ( new Cancellation() )->cancel( $contract );
 	}

+	/**
+	 * Put a subscription contract on hold (suspend billing).
+	 *
+	 * @param int $contract_id Contract id.
+	 * @return bool True when the contract was found and held; false when not found.
+	 * @throws \DomainException If the contract cannot be held from its current state.
+	 */
+	public static function hold( int $contract_id ): bool {
+		$contract = ( new ContractRepository() )->find( $contract_id );
+		if ( null === $contract ) {
+			return false;
+		}
+
+		return ( new Hold() )->hold( $contract );
+	}
+
+	/**
+	 * Reactivate a held subscription contract (resume billing, recompute the next date).
+	 *
+	 * @param int $contract_id Contract id.
+	 * @return bool True when the contract was found and reactivated; false when not found.
+	 * @throws \DomainException If the contract cannot be reactivated from its current state.
+	 */
+	public static function reactivate( int $contract_id ): bool {
+		$contract = ( new ContractRepository() )->find( $contract_id );
+		if ( null === $contract ) {
+			return false;
+		}
+
+		return ( new Reactivation() )->reactivate( $contract );
+	}
+
+	/**
+	 * Cancel a subscription contract at the end of the current billing period.
+	 *
+	 * @param int $contract_id Contract id.
+	 * @return bool True when the contract was found and wound down; false when not found.
+	 * @throws \DomainException If the contract cannot be wound down from its current state.
+	 */
+	public static function cancel_at_period_end( int $contract_id ): bool {
+		$contract = ( new ContractRepository() )->find( $contract_id );
+		if ( null === $contract ) {
+			return false;
+		}
+
+		return ( new Cancellation() )->cancel_at_period_end( $contract );
+	}
+
 	/**
 	 * Renew the contract now on an admin's request, regardless of the schedule. A settled cycle
 	 * is billed ahead of its due date (the period continues from the previous end, so the schedule
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 8e47e0ae920..87598d82570 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
@@ -28,6 +28,7 @@ use DomainException;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\MoneyScale;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\InstrumentRef;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;

 defined( 'ABSPATH' ) || exit;

@@ -145,6 +146,16 @@ final class Contract {
 	 */
 	private $items_snapshot_id;

+	/**
+	 * Optionally-hydrated frozen plan terms for `plan_snapshot_id` - the per-contract
+	 * billing cadence read off the snapshot, not the live plan. Populated by the
+	 * repository on the read paths that need it (the customer-portal reads); null on the
+	 * lean reads that do not. Not a stored column, so it is absent from `to_storage()`.
+	 *
+	 * @var PlanSnapshot|null
+	 */
+	private $plan_snapshot;
+
 	/**
 	 * Live billing total (the recurring amount), a decimal-safe string.
 	 *
@@ -262,6 +273,7 @@ final class Contract {
 		$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->plan_snapshot        = ( $data['plan_snapshot'] ?? null ) instanceof PlanSnapshot ? $data['plan_snapshot'] : null;
 	}

 	/**
@@ -290,13 +302,18 @@ final class Contract {
 	/**
 	 * Hydrate from stored rows.
 	 *
-	 * @param array<string, mixed>                $row       Contract row.
-	 * @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.
+	 * 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.
+	 *
+	 * @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, array $items = array(), array $addresses = array(), array $meta = array() ): self {
-		return new self(
+	public static function from_storage( array $row, ?PlanSnapshot $plan_snapshot = null, array $items = array(), array $addresses = array(), array $meta = array() ): self {
+		$contract = new self(
 			array_merge(
 				$row,
 				array(
@@ -306,6 +323,12 @@ final class Contract {
 				)
 			)
 		);
+
+		if ( null !== $plan_snapshot ) {
+			$contract->set_plan_snapshot( $plan_snapshot );
+		}
+
+		return $contract;
 	}

 	/**
@@ -448,6 +471,24 @@ final class Contract {
 		$this->items_snapshot_id = $items_snapshot_id;
 	}

+	/**
+	 * The frozen plan terms for `plan_snapshot_id`, when hydrated - the per-contract
+	 * billing cadence read off the snapshot rather than the live plan. Null when the
+	 * read path did not hydrate it, or the contract carries no plan snapshot.
+	 */
+	public function get_plan_snapshot(): ?PlanSnapshot {
+		return $this->plan_snapshot;
+	}
+
+	/**
+	 * Attach the frozen plan terms for `plan_snapshot_id` (repository hydration).
+	 *
+	 * @param PlanSnapshot $plan_snapshot The decoded plan snapshot.
+	 */
+	public function set_plan_snapshot( PlanSnapshot $plan_snapshot ): void {
+		$this->plan_snapshot = $plan_snapshot;
+	}
+
 	/**
 	 * Live billing total (decimal-safe string).
 	 */
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 6126e1d7cac..c1490d59c05 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
@@ -100,6 +100,32 @@ final class PlanSnapshot {
 		return isset( $this->data['selling_plan_id'] ) ? ScalarCoercion::coerce_int( $this->data['selling_plan_id'] ) : null;
 	}

+	/**
+	 * The frozen billing cadence, reconstructed from the snapshot payload.
+	 *
+	 * Sourced from the `billing_policy` entry captured at signup, NOT the live plan -
+	 * so a consumer reads the cadence a contract is billed under straight off the
+	 * snapshot, even after the plan it came from is edited or deleted, with no live
+	 * {@see \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository}
+	 * join. Returns null when the payload carries no (or an unreadable) billing policy,
+	 * so a caller degrades to "no cadence" rather than fataling.
+	 */
+	public function get_billing_policy(): ?BillingPolicy {
+		$policy = $this->data['billing_policy'] ?? null;
+		if ( ! is_array( $policy ) ) {
+			return null;
+		}
+
+		try {
+			return BillingPolicy::from_array( self::string_keyed( $policy ) );
+		} catch ( DomainException $e ) {
+			// A structurally-invalid stored policy degrades to "no cadence" rather than
+			// fataling the read; snapshots this engine writes always carry a valid policy.
+			unset( $e );
+			return null;
+		}
+	}
+
 	/**
 	 * The plan terms payload, in its original key order.
 	 *
@@ -118,4 +144,21 @@ final class PlanSnapshot {
 	public function to_payload(): array {
 		return $this->data;
 	}
+
+	/**
+	 * Re-key a nested payload array as string-keyed for the typed value-object factory.
+	 * A no-op at runtime (decoded JSON object keys are already strings); it recovers the
+	 * string-keyed type that erases to `array<int|string, mixed>`.
+	 *
+	 * @param array<int|string, mixed> $value Nested payload array.
+	 * @return array<string, mixed>
+	 */
+	private static function string_keyed( array $value ): array {
+		$out = array();
+		foreach ( $value as $key => $item ) {
+			$out[ (string) $key ] = $item;
+		}
+
+		return $out;
+	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
index ee13600bbb7..503d1bd20e7 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
@@ -14,6 +14,7 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Integration;

+use Automattic\WooCommerce\SubscriptionsEngine\Api\Rest\ContractsController;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway\CapabilityRegistry;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine;
@@ -52,6 +53,7 @@ final class Bootstrap {
 		// boot (not just activation) so the hooks can fire.
 		( new RenewalEngine() )->register_hooks();
 		PlansController::register_hooks();
+		ContractsController::register_hooks();
 		( new RenewalDispatcher() )->register_hooks();

 		// Deferred boot work, each on the most specific moment it needs: the schema install
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/RelatedOrders.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/RelatedOrders.php
new file mode 100644
index 00000000000..1f5c4c2bf0e
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/RelatedOrders.php
@@ -0,0 +1,78 @@
+<?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.
+ *
+ * Integration zone: WordPress-native. The flat `meta_key`/`meta_value` lookup round-trips
+ * through both the HPOS and legacy order stores.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout;
+
+use WC_Order;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Order-side read of a contract's related orders.
+ */
+final class RelatedOrders {
+
+	/**
+	 * The orders linked 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 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.
+	 *
+	 * @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.
+	 */
+	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;
+
+		$orders = wc_get_orders(
+			array(
+				'limit'      => $limit,
+				'offset'     => max( 0, $offset ),
+				'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 ) {
+			if ( $order instanceof WC_Order ) {
+				$result[] = $order;
+			}
+		}
+
+		return $result;
+	}
+}
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 a9b57d8608f..0f227678842 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
@@ -1,11 +1,14 @@
 <?php
 /**
- * Cancellation - cancel a subscription contract.
+ * Cancellation - cancel a subscription contract, immediately or at period end.
  *
- * A focused contract-management operation (deliberately not a catch-all manager):
- * transition the contract to cancelled, close any charge caught mid-flight, clear its
- * pending renewal, and announce it. Lives under `Integration\Contracts` so contract
- * lifecycle stays separate from the renewal money-path.
+ * A focused contract-management operation (deliberately not a catch-all manager)
+ * covering the one cancel intent in its two modes: {@see self::cancel()} tears the
+ * contract down NOW (transition to cancelled, close any charge caught mid-flight,
+ * announce it), while {@see self::cancel_at_period_end()} winds it down gracefully
+ * (transition to pending-cancellation, stamp the end date, keep serving until the
+ * period lapses). Lives under `Integration\Contracts` so contract lifecycle stays
+ * separate from the renewal money-path.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts
  */
@@ -32,6 +35,11 @@ final class Cancellation {
 	 */
 	public const CONTRACT_CANCELLED_ACTION = 'woocommerce_subscriptions_engine_contract_cancelled';

+	/**
+	 * Action fired after a contract is set to wind down at period end, with `( $contract )`.
+	 */
+	public const CONTRACT_PENDING_CANCELLATION_ACTION = 'woocommerce_subscriptions_engine_contract_pending_cancellation';
+
 	/**
 	 * Contract repository.
 	 *
@@ -61,6 +69,7 @@ final class Cancellation {
 	 * @param Contract $contract Contract to cancel. Must have an id.
 	 * @return bool True when the contract was cancelled and persisted.
 	 * @throws RuntimeException If the contract has no id.
+	 * @throws \DomainException If the contract cannot be cancelled from its current state, or its state changed concurrently.
 	 */
 	public function cancel( Contract $contract ): bool {
 		$id = $contract->get_id();
@@ -68,8 +77,15 @@ final class Cancellation {
 			throw new RuntimeException( 'Cancellation::cancel(): cannot cancel a contract that has no id.' );
 		}

+		$previous = $contract->get_status();
 		$contract->set_status( ContractStatus::CANCELLED );
-		$this->contracts->update( $contract );
+
+		// Compare-and-set on the status read above: a concurrent transition (another
+		// request, the renewal engine's settle) makes this write miss loudly rather
+		// than be clobbered.
+		if ( ! $this->contracts->update_if_status( $contract, $previous ) ) {
+			throw new \DomainException( 'Cancellation::cancel(): the contract state changed concurrently; nothing was written.' );
+		}

 		// 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.
@@ -88,4 +104,63 @@ final class Cancellation {

 		return true;
 	}
+
+	/**
+	 * Wind `$contract` down at the end of the current period: transition to
+	 * pending-cancellation and stamp the end date.
+	 *
+	 * Status moves through the Core state machine ({@see Contract::set_status()}), which
+	 * raises a `DomainException` on an illegal transition. The contract keeps serving
+	 * until the current period ends, so the next-payment moment is recorded as the
+	 * contract `end_gmt` (when not already set) for a first-class "cancels on" date, and
+	 * the next-payment date is deliberately LEFT in place so the contract lapses at the
+	 * date rather than being torn down now.
+	 *
+	 * The due scan already refuses to charge a non-active contract ({@see RenewalEngine::process()}
+	 * skips it with no order), so no renewal fires while it winds down.
+	 *
+	 * TODO: terminating a PENDING_CANCELLATION contract (ACTIVE has lapsed) when its date
+	 * arrives - moving it to CANCELLED/EXPIRED at period end - is a follow-up slice. The
+	 * current dispatcher only skips a non-active contract; it does not yet transition it
+	 * terminal at the date, so a wound-down contract stays PENDING_CANCELLATION until a
+	 * later terminate-at-date pass lands. No charge occurs in the meantime.
+	 *
+	 * @param Contract $contract Contract to wind down. Must have an id, and be ACTIVE.
+	 * @return bool True when the contract was wound down and persisted.
+	 * @throws RuntimeException If the contract has no id.
+	 * @throws \DomainException If the contract cannot be wound down from its current state, or its state changed concurrently.
+	 */
+	public function cancel_at_period_end( Contract $contract ): bool {
+		$id = $contract->get_id();
+		if ( null === $id ) {
+			throw new RuntimeException( 'Cancellation::cancel_at_period_end(): cannot cancel a contract that has no id.' );
+		}
+
+		$previous = $contract->get_status();
+		$contract->set_status( ContractStatus::PENDING_CANCELLATION );
+
+		// The end of the current period is the next-payment moment: the contract is
+		// honoured up to (not through) it. Record it as the contract end when not already
+		// set, so reads have a first-class "cancels on" date.
+		if ( null === $contract->get_end_gmt() && null !== $contract->get_next_payment_gmt() ) {
+			$contract->set_end_gmt( $contract->get_next_payment_gmt() );
+		}
+
+		// Compare-and-set on the status read above: a concurrent transition makes this
+		// write miss loudly rather than be clobbered.
+		if ( ! $this->contracts->update_if_status( $contract, $previous ) ) {
+			throw new \DomainException( 'Cancellation::cancel_at_period_end(): the contract state changed concurrently; nothing was written.' );
+		}
+
+		// Intentionally leave the next-payment date in place: the contract lapses at the date (see the TODO above).
+
+		/**
+		 * Fires after a contract is set to wind down at the end of the current period.
+		 *
+		 * @param Contract $contract The pending-cancellation contract.
+		 */
+		do_action( self::CONTRACT_PENDING_CANCELLATION_ACTION, $contract );
+
+		return true;
+	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php
new file mode 100644
index 00000000000..f9f83dd87ba
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php
@@ -0,0 +1,94 @@
+<?php
+/**
+ * Hold - put an active subscription contract on hold (suspend billing).
+ *
+ * A focused contract-management operation (deliberately not a catch-all manager),
+ * mirroring {@see Cancellation}: transition the contract ACTIVE -> ON_HOLD through the
+ * Core state machine and announce it. No charge fires while held because the batch due
+ * scan only bills active contracts, so there is no per-contract schedule to clear. The
+ * contract keeps its `next_payment_gmt` so the held duration is recoverable on
+ * {@see Reactivation}. Lives under `Integration\Contracts` so contract lifecycle stays
+ * separate from the renewal money-path.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts;
+
+use RuntimeException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Put a contract on hold.
+ */
+final class Hold {
+
+	/**
+	 * Action fired after a contract is put on hold, with `( $contract )`.
+	 */
+	public const CONTRACT_HELD_ACTION = 'woocommerce_subscriptions_engine_contract_held';
+
+	/**
+	 * Contract repository.
+	 *
+	 * @var ContractRepository
+	 */
+	private $contracts;
+
+	/**
+	 * Construct.
+	 *
+	 * @param ContractRepository|null $contracts Contract repository; default instance when omitted.
+	 */
+	public function __construct( ?ContractRepository $contracts = null ) {
+		$this->contracts = $contracts ?? new ContractRepository();
+	}
+
+	/**
+	 * Hold `$contract`: transition it to on-hold.
+	 *
+	 * Status moves through the Core state machine ({@see Contract::set_status()}), which
+	 * raises a `DomainException` on an illegal transition (e.g. holding a terminal
+	 * contract). The current cycle is immutable and is NOT touched; only the live
+	 * contract status moves. No charge fires while held because the batch due scan only
+	 * bills active contracts - there is no per-contract schedule to clear. The
+	 * `next_payment_gmt` is preserved so {@see Reactivation} can recompute the schedule
+	 * forward.
+	 *
+	 * @param Contract $contract Contract to hold. Must have an id, and be ACTIVE.
+	 * @return bool True when the contract was held and persisted.
+	 * @throws RuntimeException If the contract has no id.
+	 * @throws \DomainException If the contract cannot be held from its current state, or its state changed concurrently.
+	 */
+	public function hold( Contract $contract ): bool {
+		$id = $contract->get_id();
+		if ( null === $id ) {
+			throw new RuntimeException( 'Hold::hold(): cannot hold a contract that has no id.' );
+		}
+
+		$previous = $contract->get_status();
+		$contract->set_status( ContractStatus::ON_HOLD );
+
+		// Compare-and-set on the status read above: a concurrent transition (another
+		// request, the renewal engine) makes this write miss loudly rather than be
+		// clobbered.
+		if ( ! $this->contracts->update_if_status( $contract, $previous ) ) {
+			throw new \DomainException( 'Hold::hold(): the contract state changed concurrently; nothing was written.' );
+		}
+
+		/**
+		 * Fires after a contract is put on hold.
+		 *
+		 * @param Contract $contract The held contract.
+		 */
+		do_action( self::CONTRACT_HELD_ACTION, $contract );
+
+		return true;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
new file mode 100644
index 00000000000..8ff46ddcd41
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
@@ -0,0 +1,233 @@
+<?php
+/**
+ * Reactivation - resume a held subscription contract (resume billing).
+ *
+ * A focused contract-management operation (deliberately not a catch-all manager),
+ * mirroring {@see Cancellation}: transition the contract ON_HOLD -> ACTIVE through the
+ * Core state machine, recompute the next-payment date forward, and announce it. Setting
+ * the contract active with a forward next-payment date is the re-arm: the batch due scan
+ * picks it up at the date. Lives under `Integration\Contracts` so contract lifecycle
+ * stays separate from the renewal money-path.
+ *
+ * `$now` is read at this integration boundary (or injected for tests) and the cadence
+ * math is delegated to the clock-free {@see RenewalCalculator}, so the engine keeps a
+ * single cadence path and Core takes no clock.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use DomainException;
+use RuntimeException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal\RenewalCalculator;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Reactivate a held contract.
+ */
+final class Reactivation {
+
+	/**
+	 * Action fired after a contract is reactivated, with `( $contract )`.
+	 */
+	public const CONTRACT_REACTIVATED_ACTION = 'woocommerce_subscriptions_engine_contract_reactivated';
+
+	/**
+	 * Bound on the forward roll so a pathological policy (or a very long-held contract)
+	 * cannot loop unboundedly while moving a past-due date into the future.
+	 */
+	private const MAX_FORWARD_ROLLS = 1000;
+
+	/**
+	 * Log source, matching the package's shared logging channel.
+	 */
+	private const LOG_SOURCE = 'woocommerce-subscriptions-engine';
+
+	/**
+	 * Contract repository.
+	 *
+	 * @var ContractRepository
+	 */
+	private $contracts;
+
+	/**
+	 * Plan repository, for the billing policy the forward recompute rolls on.
+	 *
+	 * @var PlanRepository
+	 */
+	private $plans;
+
+	/**
+	 * Construct.
+	 *
+	 * @param ContractRepository|null $contracts Contract repository; default instance when omitted.
+	 * @param PlanRepository|null     $plans     Plan repository; default instance when omitted.
+	 */
+	public function __construct( ?ContractRepository $contracts = null, ?PlanRepository $plans = null ) {
+		$this->contracts = $contracts ?? new ContractRepository();
+		$this->plans     = $plans ?? new PlanRepository();
+	}
+
+	/**
+	 * Reactivate `$contract`: transition to active, recompute the next-payment date
+	 * forward, and persist.
+	 *
+	 * Status moves through the Core state machine ({@see Contract::set_status()}), which
+	 * raises a `DomainException` on an illegal transition (e.g. reactivating a terminal
+	 * contract). The next date is recomputed through the single seam
+	 * ({@see self::recompute_next_payment()}) so a contract that sat on hold past its due
+	 * date does not fire an immediate, back-dated renewal the moment it resumes. Setting
+	 * the contract active with that forward date is the re-arm - the batch due scan picks
+	 * it up when the date arrives; a null next-payment simply leaves it unscheduled.
+	 *
+	 * @param Contract               $contract Contract to reactivate. Must have an id, and be ON_HOLD.
+	 * @param DateTimeImmutable|null $now      The current moment; read from the wall clock (UTC) when omitted.
+	 * @return bool True when the contract was reactivated and persisted.
+	 * @throws RuntimeException If the contract has no id.
+	 * @throws DomainException If the contract is not on hold, or its state changed concurrently.
+	 */
+	public function reactivate( Contract $contract, ?DateTimeImmutable $now = null ): bool {
+		$id = $contract->get_id();
+		if ( null === $id ) {
+			throw new RuntimeException( 'Reactivation::reactivate(): cannot reactivate a contract that has no id.' );
+		}
+
+		// Only a held contract reactivates. The state machine rejects terminal states on
+		// its own, but an already-ACTIVE contract would silently no-op through it and
+		// still reach the recompute below - and rolling a past-due active contract's
+		// next-payment date forward would skip the charge the due scan owes it. Reject
+		// it explicitly before any date math.
+		if ( ContractStatus::ON_HOLD !== $contract->get_status() ) {
+			throw new DomainException( 'Reactivation::reactivate(): only an on-hold contract can be reactivated.' );
+		}
+
+		// Read the clock at the integration boundary so the Core cadence math stays clock-free.
+		$now = ( $now ?? new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->setTimezone( new DateTimeZone( 'UTC' ) );
+
+		$contract->set_status( ContractStatus::ACTIVE );
+		$contract->set_next_payment_gmt( $this->recompute_next_payment( $contract, $now, $this->billing_policy( $contract ) ) );
+
+		// Compare-and-set on the ON_HOLD status read above: a concurrent transition
+		// (another request, the renewal engine) makes this write miss loudly rather
+		// than be clobbered.
+		if ( ! $this->contracts->update_if_status( $contract, ContractStatus::ON_HOLD ) ) {
+			throw new DomainException( 'Reactivation::reactivate(): the contract state changed concurrently; nothing was written.' );
+		}
+
+		/**
+		 * Fires after a held contract is reactivated and its renewal re-armed.
+		 *
+		 * @param Contract $contract The reactivated contract.
+		 */
+		do_action( self::CONTRACT_REACTIVATED_ACTION, $contract );
+
+		return true;
+	}
+
+	/**
+	 * Recompute the next-payment date when a held contract reactivates.
+	 *
+	 * THE SINGLE SWAPPABLE RECOMPUTE SEAM. The exact behaviour here is a pending PRODUCT
+	 * DECISION; every change to it is isolated to this one method so the rest of the
+	 * lifecycle wiring stays stable when the policy is finalized.
+	 *
+	 * Default = "Model 1" (suspend without mutating the immutable current cycle;
+	 * reactivate recomputes the next date FORWARD, with no catch-up / back-charge):
+	 *
+	 *  - A future stored date is kept as-is - resuming before the date arrives changes
+	 *    nothing.
+	 *  - A past-due date (the contract sat on hold past it) is rolled forward by whole
+	 *    billing cadences (via {@see RenewalCalculator::next_bill_date()}) until it is in
+	 *    the future, so resuming does not immediately fire a back-dated renewal. With no
+	 *    policy available to compute a cadence, the date is floored at `$now` (the due
+	 *    scan then bills the resumed contract on its next pass rather than for the held
+	 *    window).
+	 *  - A contract with no scheduled next payment stays unscheduled.
+	 *
+	 * Models 2 (resume immediately and charge for the held period) and 3 (extend the end
+	 * date by the held duration) are deliberately NOT implemented - do not add them here
+	 * until the product decision lands.
+	 *
+	 * @param Contract           $contract The contract being reactivated.
+	 * @param DateTimeImmutable  $now      The current moment (UTC; injected at the boundary).
+	 * @param BillingPolicy|null $policy   The plan billing policy for the forward roll, or null.
+	 * @return string|null The recomputed next-payment GMT string, or null when unscheduled.
+	 */
+	private function recompute_next_payment( Contract $contract, DateTimeImmutable $now, ?BillingPolicy $policy ): ?string {
+		$next_payment_gmt = $contract->get_next_payment_gmt();
+		if ( null === $next_payment_gmt ) {
+			return null;
+		}
+
+		$next = new DateTimeImmutable( $next_payment_gmt, new DateTimeZone( 'UTC' ) );
+
+		// Still in the future: resuming before the date arrives keeps the schedule.
+		if ( $next > $now ) {
+			return $next->format( 'Y-m-d H:i:s' );
+		}
+
+		// Past due while held. With no cadence to roll by, floor at `$now` so the due scan
+		// bills the resumed contract on its next pass, not for the held gap.
+		if ( null === $policy ) {
+			return $now->format( 'Y-m-d H:i:s' );
+		}
+
+		// Roll forward by whole cadences until the date is in the future.
+		$rolls = self::MAX_FORWARD_ROLLS;
+		while ( $next <= $now && $rolls-- > 0 ) {
+			$next = RenewalCalculator::next_bill_date( $policy, $next );
+		}
+
+		if ( $next <= $now ) {
+			// The cap ran out before the date cleared `$now` (a very long hold on a
+			// fine-grained cadence): floor at `$now` like the no-policy branch above, so
+			// the resume never lands a back-dated renewal. Logged because a capped roll
+			// means the schedule anchor left the plan's cadence grid.
+			wc_get_logger()->warning(
+				sprintf( 'Reactivation: contract %d exhausted the forward-roll cap; next payment floored at now.', (int) $contract->get_id() ),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => (int) $contract->get_id(),
+				)
+			);
+
+			return $now->format( 'Y-m-d H:i:s' );
+		}
+
+		return $next->format( 'Y-m-d H:i:s' );
+	}
+
+	/**
+	 * The billing policy the forward roll steps by: the contract's own frozen plan
+	 * terms first (the snapshot is what the contract actually bills under - the same
+	 * source the renewal money-path resolves), falling back to the live selling plan
+	 * for a contract with no snapshot, and null when neither resolves.
+	 *
+	 * @param Contract $contract The contract.
+	 */
+	private function billing_policy( Contract $contract ): ?BillingPolicy {
+		$snapshot = $contract->get_plan_snapshot();
+		if ( null !== $snapshot ) {
+			$policy = $snapshot->get_billing_policy();
+			if ( $policy instanceof BillingPolicy ) {
+				return $policy;
+			}
+		}
+
+		$plan = $this->plans->find( $contract->get_selling_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 0f44d08e888..198587eaf74 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
@@ -375,7 +375,9 @@ final class RenewalEngine {
 	 * @return BillingPolicy|null The billing policy, or null when unresolvable.
 	 */
 	private function resolve_billing_policy( Contract $contract ): ?BillingPolicy {
-		$snapshot = $this->contracts->find_plan_snapshot( $contract->get_plan_snapshot_id() );
+		// The full contract reads already carry the snapshot; the store fetch is the
+		// fallback for a contract built on a lean path (row-only reads).
+		$snapshot = $contract->get_plan_snapshot() ?? $this->contracts->find_plan_snapshot( $contract->get_plan_snapshot_id() );
 		if ( $snapshot instanceof PlanSnapshot ) {
 			$payload = $snapshot->to_array();
 			if ( isset( $payload['billing_policy'] ) && is_array( $payload['billing_policy'] ) ) {
@@ -718,7 +720,23 @@ final class RenewalEngine {
 			$contract->set_next_payment_gmt( $cycle->get_ends_at_gmt() );
 			$contract->set_last_payment_gmt( null !== $paid_at ? gmdate( 'Y-m-d H:i:s', $paid_at->getTimestamp() ) : $now );
 			$contract->set_last_attempt_gmt( $now );
-			$this->contracts->update( $contract );
+			// Conditioned on the ACTIVE status this settle read: a lifecycle transition
+			// that landed mid-settle (a customer cancel) must not be clobbered by this
+			// schedule advance - the cycle outcome above already landed either way.
+			if ( ! $this->contracts->update_if_status( $contract, ContractStatus::ACTIVE ) ) {
+				wc_get_logger()->info(
+					sprintf( 'RenewalEngine: contract %d transitioned during settle; schedule advance skipped.', (int) $contract->get_id() ),
+					array(
+						'source'      => self::LOG_SOURCE,
+						'contract_id' => (int) $contract->get_id(),
+					)
+				);
+
+				// The action below announces "billed AND schedule advanced"; the advance
+				// did not persist, so do not hand listeners the unpersisted in-memory
+				// entity. The order + billed cycle rows carry the charge for audit.
+				return;
+			}

 			/**
 			 * Fires after a renewal cycle is billed and the contract schedule advanced.
@@ -742,7 +760,9 @@ final class RenewalEngine {
 			$cycle->set_claimed_until_gmt( null );

 			$contract->set_last_attempt_gmt( $now );
-			$this->contracts->update( $contract );
+			// Conditioned like the billed branch: bookkeeping only, never worth
+			// clobbering a concurrent lifecycle transition for.
+			$this->contracts->update_if_status( $contract, ContractStatus::ACTIVE );

 			return;
 		}
@@ -756,7 +776,9 @@ final class RenewalEngine {
 		}

 		$contract->set_last_attempt_gmt( $now );
-		$this->contracts->update( $contract );
+		// Conditioned like the billed branch: bookkeeping only, never worth clobbering
+		// a concurrent lifecycle transition for.
+		$this->contracts->update_if_status( $contract, ContractStatus::ACTIVE );
 	}

 	/**
@@ -1062,7 +1084,9 @@ final class RenewalEngine {
 			}

 			$contract->set_next_payment_gmt( null );
-			$this->contracts->update( $contract );
+			// Conditioned on the status just read: a lifecycle transition racing the
+			// park must not be clobbered - the contract is out of the due set either way.
+			$this->contracts->update_if_status( $contract, $contract->get_status() );
 		} catch ( Throwable $e ) {
 			wc_get_logger()->error(
 				sprintf( 'RenewalEngine::park(): failed to park contract %d - %s', $contract_id, $e->getMessage() ),
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
index 4aa70978438..101cdb55846 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
@@ -180,11 +180,76 @@ final class ContractRepository {
 		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,
+	 * mirroring {@see self::transition_cycle_status()} on the cycle side.
+	 *
+	 * The lifecycle transitions (hold / reactivate / cancel) and the renewal engine's
+	 * schedule advances all read-validate-write the contract row; unconditioned, the
+	 * slower writer silently clobbers the faster one - a customer cancel lost to a
+	 * concurrent settle would resurrect the contract into future billing. Keying the
+	 * write on the status the caller read makes the race lose LOUDLY: no row matches,
+	 * false comes back, and the caller reports a conflict or re-reads instead of
+	 * overwriting.
+	 *
+	 * A zero-row result is disambiguated with a follow-up status read (an update whose
+	 * values happen to be byte-identical also reports zero rows), so false always means
+	 * "the stored status is no longer `$expected_status`, or the row is gone" - never a
+	 * false conflict.
+	 *
+	 * @param Contract $contract        Contract to write. Must have an id.
+	 * @param string   $expected_status The status the caller read; the write lands only while the row still carries it.
+	 * @return bool True when the row was written (children synced); false when the condition missed.
+	 * @throws \RuntimeException If the contract has no id, or the write itself errors.
+	 */
+	public function update_if_status( Contract $contract, string $expected_status ): 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.' );
+		}
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$updated = $wpdb->update(
+			SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ),
+			array_merge(
+				$contract->to_storage(),
+				array( 'date_updated_gmt' => gmdate( 'Y-m-d H:i:s' ) )
+			),
+			array(
+				'id'     => (int) $id,
+				'status' => $expected_status,
+			)
+		);
+
+		if ( false === $updated ) {
+			throw new \RuntimeException( 'Failed to update contract.' );
+		}
+
+		if ( 0 === $updated ) {
+			$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+			$current = $wpdb->get_var( $wpdb->prepare( "SELECT status FROM {$table} WHERE id = %d", $id ) );
+			if ( $expected_status !== $current ) {
+				return false;
+			}
+		}
+
+		$this->sync_children( $contract );
+
+		return true;
+	}
+
 	/**
 	 * Fetch a contract by id, hydrating the live entity with its items / addresses /
-	 * meta. Cycles are NOT hydrated - they are reached on demand through the targeted
-	 * cycle reads. For list / guard paths that do not need the children, use
-	 * {@see self::find_summary()}.
+	 * meta, 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
+	 * that do not need the children, use {@see self::find_summary()}.
 	 *
 	 * @param int $id Contract id.
 	 * @return Contract|null Hydrated contract, or null if not found.
@@ -195,8 +260,48 @@ final class ContractRepository {
 			return null;
 		}

+		return $this->hydrate_row( $row );
+	}
+
+	/**
+	 * Fetch a contract by id when `$customer_id` owns it - the ownership-checked full
+	 * read behind the customer portal.
+	 *
+	 * The ownership filter lives in the row query itself, so an unknown id and a
+	 * contract owned by someone else are the SAME null - one read, nothing for a
+	 * caller to distinguish (the anti-IDOR rule at the storage layer). The returned
+	 * contract is hydrated exactly like {@see self::find()}.
+	 *
+	 * @param int $contract_id Contract id.
+	 * @param int $customer_id Customer that must own the contract.
+	 * @return Contract|null Hydrated contract when owned by `$customer_id`, else null.
+	 */
+	public function find_for_customer( int $contract_id, int $customer_id ): ?Contract {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d AND customer_id = %d", $contract_id, $customer_id ), ARRAY_A );
+		if ( ! is_array( $row ) ) {
+			return null;
+		}
+
+		return $this->hydrate_row( self::as_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.
+	 *
+	 * @param array<string, mixed> $row Contract row.
+	 */
+	private function hydrate_row( array $row ): Contract {
+		$id = ScalarCoercion::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_items( $id ),
 			$this->find_addresses( $id ),
 			$this->find_meta( $id )
@@ -246,12 +351,39 @@ 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} ORDER BY id DESC LIMIT %d OFFSET %d", $limit, $offset ), ARRAY_A );

-		$contracts = array();
-		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
+		return $this->contracts_from_rows( is_array( $rows ) ? $rows : array() );
+	}
+
+	/**
+	 * Construct a page of list contracts from fetched rows: row-only children, with the
+	 * frozen plan terms batch-loaded in ONE `IN()` read for the whole page - the one
+	 * list construction path, so every list read carries the snapshot the same way.
+	 *
+	 * @param array<int, mixed> $rows Fetched rows (non-arrays are skipped).
+	 * @return array<int, Contract> Contracts in row order.
+	 */
+	private function contracts_from_rows( array $rows ): array {
+		$clean_rows = array();
+		foreach ( $rows as $row ) {
 			if ( is_array( $row ) ) {
-				$contracts[] = Contract::from_storage( self::as_string_keyed( $row ) );
+				$clean_rows[] = self::as_string_keyed( $row );
+			}
+		}
+
+		$snapshot_ids = array();
+		foreach ( $clean_rows as $row ) {
+			$snapshot_id = ScalarCoercion::coerce_nullable_int( $row['plan_snapshot_id'] ?? null );
+			if ( null !== $snapshot_id ) {
+				$snapshot_ids[ $snapshot_id ] = $snapshot_id;
 			}
 		}
+		$snapshots = $this->find_plan_snapshots( array_values( $snapshot_ids ) );
+
+		$contracts = array();
+		foreach ( $clean_rows as $row ) {
+			$snapshot_id = ScalarCoercion::coerce_nullable_int( $row['plan_snapshot_id'] ?? null );
+			$contracts[] = Contract::from_storage( $row, null !== $snapshot_id ? ( $snapshots[ $snapshot_id ] ?? null ) : null );
+		}

 		return $contracts;
 	}
@@ -345,6 +477,74 @@ final class ContractRepository {
 		return $result;
 	}

+	/**
+	 * Customer-scoped contract list, newest first - the customer portal's list read.
+	 *
+	 * Hits the `(customer_id, status)` index: scopes to the owner, applies the optional
+	 * status filter, and pages. Returns lightweight (row only, no children) {@see Contract}
+	 * entities like {@see self::query()} / {@see self::find_summary()} - enough for a list
+	 * row without a per-contract child read. An empty result means "this customer has no
+	 * matching contracts"; it is never null.
+	 *
+	 * Takes a WooCommerce-style args array (cf. {@see self::query()}) rather than positional
+	 * 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
+	 * 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.
+	 *
+	 * @param int                       $customer_id Owning customer id.
+	 * @param array<string, mixed>|null $args        {
+	 *     Optional. Query args.
+	 *
+	 *     @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}).
+	 * }
+	 * @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;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+		// One query for both shapes: the optional status filter joins the WHERE, and
+		// every value is a placeholder (table names cannot be bound, so the table is
+		// interpolated).
+		$where  = 'customer_id = %d';
+		$params = array( $customer_id );
+		if ( null !== $status ) {
+			$where   .= ' AND status = %s';
+			$params[] = $status;
+		}
+		$params[] = $limit;
+		$params[] = $offset;
+
+		// The WHERE placeholders join dynamically, so the sniff cannot count them.
+		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+		$rows = $wpdb->get_results(
+			$wpdb->prepare(
+				"SELECT *
+				FROM {$table}
+				WHERE {$where}
+				ORDER BY id DESC
+				LIMIT %d OFFSET %d",
+				$params
+			),
+			ARRAY_A
+		);
+		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+
+		return $this->contracts_from_rows( is_array( $rows ) ? $rows : array() );
+	}
+
 	/**
 	 * Whether a contract row exists for `$id`.
 	 *
@@ -777,6 +977,54 @@ final class ContractRepository {
 		return PlanSnapshot::from_payload( self::as_string_keyed( $decoded['payload'] ), $decoded['schema_version'] );
 	}

+	/**
+	 * Decode a batch of stored plan snapshot rows in one read - the list pages'
+	 * hydration path, so a page of contracts costs one snapshot query, not one
+	 * per row.
+	 *
+	 * @param array<int, int> $snapshot_ids Snapshot row ids (deduplicated by the caller or not - the IN() dedupes).
+	 * @return array<int, PlanSnapshot> Decoded value objects keyed by snapshot row id.
+	 */
+	private function find_plan_snapshots( array $snapshot_ids ): array {
+		global $wpdb;
+
+		$ids = array_values( array_unique( array_map( 'intval', $snapshot_ids ) ) );
+		if ( array() === $ids ) {
+			return array();
+		}
+
+		$table        = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+		$placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
+
+		// The IN() placeholders are built per id, so the sniff sees none in the literal.
+		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+		$rows = $wpdb->get_results(
+			$wpdb->prepare(
+				"SELECT id, payload, schema_version
+				FROM {$table}
+				WHERE id IN ( {$placeholders} )",
+				$ids
+			),
+			ARRAY_A
+		);
+		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+
+		$snapshots = array();
+		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
+			if ( ! is_array( $row ) ) {
+				continue;
+			}
+			$payload = json_decode( ScalarCoercion::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 )
+			);
+		}
+
+		return $snapshots;
+	}
+
 	/**
 	 * Decode a stored items snapshot row into a typed value object.
 	 *
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/RESTPermissions.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/RESTPermissions.php
index 0c93c1100c0..1e81dd6cadd 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/RESTPermissions.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/RESTPermissions.php
@@ -1,6 +1,6 @@
 <?php
 /**
- * Shared admin REST permission checks.
+ * Shared REST permission checks.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Support
  */
@@ -12,16 +12,21 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Support;
 defined( 'ABSPATH' ) || exit;

 /**
- * Shared admin REST permission checks.
+ * Shared REST permission checks.
  */
 class RESTPermissions {

 	/**
-	 * Require a logged in user with the manage_woocommerce capability.
+	 * Require a logged in user.
 	 *
-	 * @return true|\WP_Error
+	 * The shared authentication floor for customer-facing routes: any logged-in user
+	 * passes; resource-level authorization (e.g. per-contract ownership) stays with the
+	 * route handlers. Core's cookie auth has already verified the REST nonce (`wp_rest`)
+	 * for a cookie-authenticated request by the time a permission callback runs.
+	 *
+	 * @return true|\WP_Error True when logged in, else a 401 error.
 	 */
-	public function require_admin_permission() {
+	public function require_logged_in_permission() {
 		if ( ! is_user_logged_in() ) {
 			return new \WP_Error(
 				'woocommerce_subscriptions_engine_not_authenticated',
@@ -30,6 +35,20 @@ class RESTPermissions {
 			);
 		}

+		return true;
+	}
+
+	/**
+	 * Require a logged in user with the manage_woocommerce capability.
+	 *
+	 * @return true|\WP_Error
+	 */
+	public function require_admin_permission() {
+		$logged_in = $this->require_logged_in_permission();
+		if ( true !== $logged_in ) {
+			return $logged_in;
+		}
+
 		// phpcs:ignore WordPress.WP.Capabilities.Unknown -- WooCommerce registers manage_woocommerce.
 		if ( ! current_user_can( 'manage_woocommerce' ) ) {
 			return new \WP_Error(
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
new file mode 100644
index 00000000000..70c3ee172ef
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php
@@ -0,0 +1,248 @@
+<?php
+/**
+ * Integration tests for the lifecycle-actions REST controller: the auth + ownership
+ * matrix (anonymous 401, valid owner 200, foreign owner 404, unknown id 404), the
+ * action round-trips with their domain-summary responses, and the
+ * illegal-transition 409.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api\Rest;
+
+use EngineIntegrationTestCase;
+use WP_REST_Request;
+use WP_REST_Response;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Rest\ContractsController;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\Rest\ContractsController
+ */
+class ContractsControllerTest extends EngineIntegrationTestCase {
+
+	private const BASE = '/wc/v3/subscriptions-engine/contracts';
+
+	/**
+	 * @var ContractRepository
+	 */
+	private $contracts;
+
+	/**
+	 * @var int
+	 */
+	private $owner_id;
+
+	/**
+	 * @var int
+	 */
+	private $other_id;
+
+	public function set_up(): void {
+		parent::set_up();
+
+		$this->contracts = new ContractRepository();
+
+		// Register the controller on `rest_api_init` (where core requires routes to be
+		// registered) and re-fire the action so the routes exist on the live server for
+		// this test. Mirrors how Bootstrap wires it in production.
+		add_action(
+			'rest_api_init',
+			static function (): void {
+				( new ContractsController() )->register_routes();
+			}
+		);
+		do_action( 'rest_api_init' );
+
+		$this->owner_id = $this->create_customer();
+		$this->other_id = $this->create_customer();
+	}
+
+	public function tear_down(): void {
+		wp_set_current_user( 0 );
+		parent::tear_down();
+	}
+
+	/**
+	 * Create a customer user and return its id.
+	 */
+	private function create_customer(): int {
+		$user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+		$this->assertIsInt( $user_id );
+
+		return $user_id;
+	}
+
+	/**
+	 * Seed a contract for a customer.
+	 *
+	 * @param int    $customer_id Owning customer.
+	 * @param string $status      Status.
+	 */
+	private function seed( int $customer_id, string $status = ContractStatus::ACTIVE ): int {
+		$contract = Contract::create(
+			array(
+				'customer_id'          => $customer_id,
+				'status'               => $status,
+				'currency'             => 'USD',
+				'selling_plan_id'      => 1,
+				'payment_method_title' => 'Visa ending in 4242',
+				'start_gmt'            => '2026-01-01 00:00:00',
+				'next_payment_gmt'     => '2099-02-01 00:00:00',
+				'billing_total'        => '19.99',
+			)
+		);
+
+		return $this->contracts->insert( $contract );
+	}
+
+	public function test_anonymous_request_is_unauthorized(): void {
+		wp_set_current_user( 0 );
+		$id = $this->seed( $this->owner_id );
+
+		$response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/hold' ) );
+
+		$this->assertSame( 401, $response->get_status() );
+		// The contract is untouched.
+		$this->assertSame( ContractStatus::ACTIVE, $this->reload( $id )->get_status() );
+	}
+
+	public function test_unknown_contract_is_not_found_indistinguishably_from_foreign(): void {
+		wp_set_current_user( $this->other_id );
+
+		$response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/4242424/hold' ) );
+
+		$this->assertSame( 404, $response->get_status() );
+	}
+
+	public function test_options_exposes_the_action_schema(): void {
+		wp_set_current_user( $this->owner_id );
+		$id = $this->seed( $this->owner_id );
+
+		$response = rest_get_server()->dispatch( new WP_REST_Request( 'OPTIONS', self::BASE . '/' . $id . '/hold' ) );
+
+		$this->assertSame( 200, $response->get_status() );
+		$data = $this->data_array( $response );
+		$this->assertIsArray( $data['schema'] );
+		$this->assertSame( 'subscription_engine_contract_action', $data['schema']['title'] );
+	}
+
+	public function test_hold_action_on_a_foreign_contract_is_not_found(): void {
+		wp_set_current_user( $this->other_id );
+		$id = $this->seed( $this->owner_id );
+
+		$response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/hold' ) );
+
+		$this->assertSame( 404, $response->get_status() );
+		// The contract is untouched.
+		$this->assertSame( ContractStatus::ACTIVE, $this->reload( $id )->get_status() );
+	}
+
+	public function test_owner_hold_transitions_and_returns_the_domain_summary(): void {
+		wp_set_current_user( $this->owner_id );
+		$id = $this->seed( $this->owner_id );
+
+		$response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/hold' ) );
+
+		$this->assertSame( 200, $response->get_status() );
+		$data = $this->data_array( $response );
+		// The action response is a domain summary: id + resulting status slug,
+		// no view-model fields (labels, formatted values, visibility flags).
+		$this->assertSame( $id, $data['id'] );
+		$this->assertSame( ContractStatus::ON_HOLD, $data['status'] );
+		$this->assertArrayNotHasKey( 'status_label', $data );
+		$this->assertArrayNotHasKey( 'related_orders', $data );
+		$this->assertSame( ContractStatus::ON_HOLD, $this->reload( $id )->get_status() );
+	}
+
+	public function test_owner_reactivate_transitions_and_returns_the_domain_summary(): void {
+		wp_set_current_user( $this->owner_id );
+		$id = $this->seed( $this->owner_id, ContractStatus::ON_HOLD );
+
+		$response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/reactivate' ) );
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( ContractStatus::ACTIVE, $this->data_array( $response )['status'] );
+		$this->assertSame( ContractStatus::ACTIVE, $this->reload( $id )->get_status() );
+	}
+
+	public function test_reactivate_on_an_already_active_contract_is_a_conflict(): void {
+		// An active contract must never reach the date recompute (a past-due date
+		// rolled forward would skip a charge); the guard maps to a 409.
+		wp_set_current_user( $this->owner_id );
+		$id     = $this->seed( $this->owner_id, ContractStatus::ACTIVE );
+		$before = $this->reload( $id )->get_next_payment_gmt();
+
+		$response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/reactivate' ) );
+
+		$this->assertSame( 409, $response->get_status() );
+		$this->assertSame( $before, $this->reload( $id )->get_next_payment_gmt(), 'The schedule is untouched.' );
+	}
+
+	public function test_cancel_at_period_end_winds_down_the_contract(): void {
+		wp_set_current_user( $this->owner_id );
+		$id = $this->seed( $this->owner_id );
+
+		$request = new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/cancel' );
+		$request->set_body_params( array( 'at_period_end' => true ) );
+		$response = rest_get_server()->dispatch( $request );
+
+		$this->assertSame( 200, $response->get_status() );
+		// The summary status tells the caller which cancel mode landed.
+		$this->assertSame( ContractStatus::PENDING_CANCELLATION, $this->data_array( $response )['status'] );
+		$this->assertSame( ContractStatus::PENDING_CANCELLATION, $this->reload( $id )->get_status() );
+	}
+
+	public function test_cancel_now_terminates_the_contract(): void {
+		wp_set_current_user( $this->owner_id );
+		$id = $this->seed( $this->owner_id, ContractStatus::ON_HOLD );
+
+		$request = new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/cancel' );
+		$request->set_body_params( array( 'at_period_end' => false ) );
+		$response = rest_get_server()->dispatch( $request );
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( ContractStatus::CANCELLED, $this->data_array( $response )['status'] );
+		$this->assertSame( ContractStatus::CANCELLED, $this->reload( $id )->get_status() );
+	}
+
+	public function test_illegal_transition_is_a_conflict(): void {
+		// Reactivating an active contract is a no-op (idempotent) - so to force the
+		// illegal path, try to hold a cancelled contract.
+		wp_set_current_user( $this->owner_id );
+		$id = $this->seed( $this->owner_id, ContractStatus::CANCELLED );
+
+		$response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/hold' ) );
+
+		$this->assertSame( 409, $response->get_status() );
+	}
+
+	/**
+	 * The response body as an array (asserts it is one, narrowing offset access).
+	 *
+	 * @param WP_REST_Response $response The dispatched response.
+	 * @return array<int|string, mixed>
+	 */
+	private function data_array( WP_REST_Response $response ): array {
+		$data = $response->get_data();
+		$this->assertIsArray( $data );
+
+		return $data;
+	}
+
+	/**
+	 * Reload a contract, asserting it still exists (narrows the nullable read).
+	 *
+	 * @param int $id Contract id.
+	 */
+	private function reload( int $id ): Contract {
+		$contract = $this->contracts->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+
+		return $contract;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
index 1c612da3db7..0284a6d6fc4 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
@@ -19,8 +19,11 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanGroup;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
+use Automattic\WooCommerce\SubscriptionsEngine\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\PlanGroupRepository;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;

@@ -47,11 +50,13 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * Sign up a contract via the checkout factory (cycle 1 billed).
+	 * Sign up a contract via the checkout factory (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.
 	 * @return Contract The persisted contract with cycle 1 billed.
 	 */
-	private function sign_up_contract(): Contract {
+	private function sign_up_contract( int $customer_id = 0 ): Contract {
 		$group_id = ( new PlanGroupRepository() )->insert( PlanGroup::create( array( 'name' => 'Club' ) ) );
 		$plan     = Plan::create(
 			$group_id,
@@ -69,11 +74,30 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
 		$order->set_payment_method( self::GATEWAY );
 		$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();

 		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 );
+
+		$plan->set_billing_policy( $policy );
+		$plans->update( $plan );
+	}
+
 	/**
 	 * @testdox get returns the contract, and null for an unknown id.
 	 */
@@ -89,6 +113,126 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
 		$this->assertNull( Subscriptions::get( 999999 ) );
 	}

+	/**
+	 * @testdox get hydrates the contract's frozen plan terms, and the snapshot wins over a changed live plan.
+	 */
+	public function test_get_hydrates_the_plan_snapshot_and_snapshot_wins(): 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 );
+
+		$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() );
+	}
+
+	/**
+	 * @testdox get_related_orders is empty for a contract with no linked orders.
+	 */
+	public function test_get_related_orders_is_empty_when_none_are_linked(): void {
+		$this->assertSame( array(), Subscriptions::get_related_orders( 987654 ) );
+	}
+
+	/**
+	 * @testdox get_related_orders windows with limit/offset, newest first.
+	 */
+	public function test_get_related_orders_windows_with_limit_and_offset(): void {
+		$contract    = $this->sign_up_contract();
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		// Link three renewal orders backdated 1-3 days before the origin (created
+		// "now"), so the full set is 4 orders newest-first: origin, renewal1,
+		// renewal2, renewal3.
+		$renewal_ids = array();
+		foreach ( array( 3, 2, 1 ) as $days_ago ) {
+			$order = wc_create_order();
+			$this->assertInstanceOf( WC_Order::class, $order );
+			$order->set_date_created( gmdate( 'Y-m-d H:i:s', time() - ( $days_ago * 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();
+			$renewal_ids[ $days_ago ] = $order->get_id();
+		}
+
+		$all = Subscriptions::get_related_orders( $contract_id );
+		$this->assertCount( 4, $all, 'The default window stays "all".' );
+
+		$first_page = Subscriptions::get_related_orders( $contract_id, 2, 0 );
+		$this->assertCount( 2, $first_page );
+		$this->assertSame( $contract->get_origin_order_id(), $first_page[0]->get_id(), 'Newest linked order first.' );
+		$this->assertSame( $renewal_ids[1], $first_page[1]->get_id() );
+
+		$second_page = Subscriptions::get_related_orders( $contract_id, 2, 2 );
+		$this->assertCount( 2, $second_page );
+		$this->assertSame( $renewal_ids[2], $second_page[0]->get_id() );
+		$this->assertSame( $renewal_ids[3], $second_page[1]->get_id() );
+
+		$past_the_end = Subscriptions::get_related_orders( $contract_id, 2, 4 );
+		$this->assertSame( array(), $past_the_end );
+
+		// A zero limit means none - never WP_Query's posts-per-page default.
+		$this->assertSame( array(), Subscriptions::get_related_orders( $contract_id, 0 ) );
+	}
+
+	/**
+	 * @testdox list_for_customer hydrates each row's frozen plan terms.
+	 */
+	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( 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.
 	 */
@@ -169,4 +313,100 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
 		$this->assertInstanceOf( Contract::class, $after_cancel );
 		$this->assertSame( ContractStatus::CANCELLED, $after_cancel->get_status() );
 	}
+
+	/**
+	 * 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.
+	 */
+	public function test_lifecycle_actions_return_false_for_an_unknown_contract(): void {
+		$this->assertFalse( Subscriptions::hold( 987654 ) );
+		$this->assertFalse( Subscriptions::reactivate( 987654 ) );
+		$this->assertFalse( Subscriptions::cancel_at_period_end( 987654 ) );
+	}
+
+	/**
+	 * @testdox the portal lifecycle runs through the facade: hold, reactivate, cancel at period end.
+	 */
+	public function test_portal_lifecycle_hold_reactivate_cancel_at_period_end(): void {
+		$contract    = $this->sign_up_contract();
+		$contract_id = $contract->get_id();
+		$this->assertNotNull( $contract_id );
+
+		$this->assertTrue( Subscriptions::hold( $contract_id ) );
+		$held = Subscriptions::get( $contract_id );
+		$this->assertInstanceOf( Contract::class, $held );
+		$this->assertSame( ContractStatus::ON_HOLD, $held->get_status() );
+
+		$this->assertTrue( Subscriptions::reactivate( $contract_id ) );
+		$active = Subscriptions::get( $contract_id );
+		$this->assertInstanceOf( Contract::class, $active );
+		$this->assertSame( ContractStatus::ACTIVE, $active->get_status() );
+
+		$this->assertTrue( Subscriptions::cancel_at_period_end( $contract_id ) );
+		$pending = Subscriptions::get( $contract_id );
+		$this->assertInstanceOf( Contract::class, $pending );
+		$this->assertSame( ContractStatus::PENDING_CANCELLATION, $pending->get_status() );
+	}
 }
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
new file mode 100644
index 00000000000..756c7a8794d
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php
@@ -0,0 +1,138 @@
+<?php
+/**
+ * Integration tests for the Cancellation contract operation's period-end mode: ACTIVE ->
+ * PENDING_CANCELLATION, the end date stamped, and the next-payment date left in place so
+ * the contract lapses at period end. (The immediate-cancel mode is covered through the
+ * facade suite.)
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Contracts;
+
+use DomainException;
+use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation
+ */
+class CancellationTest extends EngineIntegrationTestCase {
+
+	/**
+	 * @var ContractRepository
+	 */
+	private $contracts;
+
+	/**
+	 * @var Cancellation
+	 */
+	private $sut;
+
+	public function set_up(): void {
+		parent::set_up();
+		$this->contracts = new ContractRepository();
+		$this->sut       = new Cancellation( $this->contracts );
+	}
+
+	/**
+	 * Seed an active contract with a future next-payment date.
+	 *
+	 * @param string|null $end_gmt Optional pre-set end date.
+	 */
+	private function seed_active( ?string $end_gmt = null ): int {
+		$contract = Contract::create(
+			array(
+				'customer_id'      => 1,
+				'status'           => ContractStatus::ACTIVE,
+				'currency'         => 'USD',
+				'selling_plan_id'  => 1,
+				'start_gmt'        => '2026-01-01 00:00:00',
+				'next_payment_gmt' => '2099-01-01 00:00:00',
+				'end_gmt'          => $end_gmt,
+				'billing_total'    => '19.99',
+			)
+		);
+
+		return $this->contracts->insert( $contract );
+	}
+
+	public function test_winds_down_to_pending_cancellation_and_stamps_the_end_date(): void {
+		$id = $this->seed_active();
+
+		$result = $this->sut->cancel_at_period_end( $this->reload( $id ) );
+
+		$this->assertTrue( $result );
+		$stored = $this->reload( $id );
+		$this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
+		// The next-payment moment becomes the contract end (the "cancels on" date).
+		$this->assertSame( '2099-01-01 00:00:00', $stored->get_end_gmt() );
+	}
+
+	public function test_leaves_the_next_payment_in_place_so_the_contract_lapses_at_the_date(): void {
+		// The next-payment date is deliberately left in place; the due scan refuses to
+		// charge a non-active contract, so no renewal fires while it winds down.
+		$id = $this->seed_active();
+
+		$this->sut->cancel_at_period_end( $this->reload( $id ) );
+
+		$this->assertSame( '2099-01-01 00:00:00', $this->reload( $id )->get_next_payment_gmt(), 'The contract lapses at the date; the next-payment date stays in place.' );
+	}
+
+	public function test_preserves_an_existing_end_date(): void {
+		$id = $this->seed_active( '2026-09-09 00:00:00' );
+
+		$this->sut->cancel_at_period_end( $this->reload( $id ) );
+
+		$this->assertSame( '2026-09-09 00:00:00', $this->reload( $id )->get_end_gmt() );
+	}
+
+	public function test_fires_the_pending_cancellation_action(): void {
+		$id    = $this->seed_active();
+		$fired = 0;
+		add_action(
+			Cancellation::CONTRACT_PENDING_CANCELLATION_ACTION,
+			static function () use ( &$fired ): void {
+				++$fired;
+			}
+		);
+
+		$this->sut->cancel_at_period_end( $this->reload( $id ) );
+
+		$this->assertSame( 1, $fired );
+	}
+
+	public function test_rejects_a_terminal_contract(): void {
+		$contract = Contract::create(
+			array(
+				'customer_id'     => 1,
+				'status'          => ContractStatus::CANCELLED,
+				'currency'        => 'USD',
+				'selling_plan_id' => 1,
+				'start_gmt'       => '2026-01-01 00:00:00',
+				'billing_total'   => '19.99',
+			)
+		);
+		$id       = $this->contracts->insert( $contract );
+
+		$this->expectException( DomainException::class );
+		$this->sut->cancel_at_period_end( $this->reload( $id ) );
+	}
+
+	/**
+	 * Reload a contract, asserting it still exists (narrows the nullable read).
+	 *
+	 * @param int $id Contract id.
+	 */
+	private function reload( int $id ): Contract {
+		$contract = $this->contracts->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+
+		return $contract;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php
new file mode 100644
index 00000000000..434fc58c2ae
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php
@@ -0,0 +1,131 @@
+<?php
+/**
+ * Integration tests for the Hold contract operation: ACTIVE -> ON_HOLD, the
+ * next-payment date preserved, and the held action fired.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Contracts;
+
+use DomainException;
+use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold
+ */
+class HoldTest extends EngineIntegrationTestCase {
+
+	/**
+	 * @var ContractRepository
+	 */
+	private $contracts;
+
+	/**
+	 * @var Hold
+	 */
+	private $sut;
+
+	public function set_up(): void {
+		parent::set_up();
+		$this->contracts = new ContractRepository();
+		$this->sut       = new Hold( $this->contracts );
+	}
+
+	/**
+	 * Seed a contract at a status with a future next-payment date.
+	 *
+	 * @param string $status Contract status.
+	 */
+	private function seed( string $status ): int {
+		$contract = Contract::create(
+			array(
+				'customer_id'      => 1,
+				'status'           => $status,
+				'currency'         => 'USD',
+				'selling_plan_id'  => 1,
+				'start_gmt'        => '2026-01-01 00:00:00',
+				'next_payment_gmt' => '2099-01-01 00:00:00',
+				'billing_total'    => '19.99',
+			)
+		);
+
+		return $this->contracts->insert( $contract );
+	}
+
+	public function test_hold_suspends_billing_by_moving_to_on_hold(): void {
+		$id = $this->seed( ContractStatus::ACTIVE );
+
+		$result = $this->sut->hold( $this->reload( $id ) );
+
+		$this->assertTrue( $result );
+		// No charge while held: the contract is on hold, and the batch due scan only bills active contracts.
+		$this->assertSame( ContractStatus::ON_HOLD, $this->reload( $id )->get_status() );
+	}
+
+	public function test_hold_keeps_the_next_payment_for_a_later_reactivate(): void {
+		$id = $this->seed( ContractStatus::ACTIVE );
+
+		$this->sut->hold( $this->reload( $id ) );
+
+		$this->assertSame( '2099-01-01 00:00:00', $this->reload( $id )->get_next_payment_gmt() );
+	}
+
+	public function test_hold_fires_the_held_action(): void {
+		$id    = $this->seed( ContractStatus::ACTIVE );
+		$fired = 0;
+		add_action(
+			Hold::CONTRACT_HELD_ACTION,
+			static function () use ( &$fired ): void {
+				++$fired;
+			}
+		);
+
+		$this->sut->hold( $this->reload( $id ) );
+
+		$this->assertSame( 1, $fired );
+	}
+
+	public function test_hold_loses_the_race_to_a_concurrent_transition(): void {
+		$id       = $this->seed( ContractStatus::ACTIVE );
+		$contract = $this->reload( $id );
+
+		// A concurrent request cancels the contract after our read: the compare-and-set
+		// write must miss loudly instead of resurrecting the contract to on-hold.
+		$concurrent = $this->reload( $id );
+		$concurrent->set_status( ContractStatus::CANCELLED );
+		$this->contracts->update( $concurrent );
+
+		try {
+			$this->sut->hold( $contract );
+			$this->fail( 'Expected a DomainException when the conditional write misses.' );
+		} catch ( DomainException $e ) {
+			$this->assertSame( ContractStatus::CANCELLED, $this->reload( $id )->get_status(), 'The concurrent cancel is not clobbered.' );
+		}
+	}
+
+	public function test_hold_rejects_a_cancelled_contract(): void {
+		$id = $this->seed( ContractStatus::CANCELLED );
+
+		$this->expectException( DomainException::class );
+		$this->sut->hold( $this->reload( $id ) );
+	}
+
+	/**
+	 * Reload a contract, asserting it still exists (narrows the nullable read).
+	 *
+	 * @param int $id Contract id.
+	 */
+	private function reload( int $id ): Contract {
+		$contract = $this->contracts->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+
+		return $contract;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php
new file mode 100644
index 00000000000..9bc5d4bc861
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php
@@ -0,0 +1,276 @@
+<?php
+/**
+ * Integration tests for the Reactivation contract operation: ON_HOLD -> ACTIVE and the
+ * next-payment date recomputed forward (the Model-1 seam), which re-arms the batch due
+ * scan (an active contract carrying a next-payment date).
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Contracts;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use DomainException;
+use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanGroup;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Reactivation;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanGroupRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Reactivation
+ */
+class ReactivationTest extends EngineIntegrationTestCase {
+
+	private const GATEWAY = 'engine_test_gateway';
+
+	/**
+	 * Selling-plan id that resolves to no plan row, to exercise the no-policy floor.
+	 */
+	private const MISSING_PLAN_ID = 999999;
+
+	/**
+	 * @var ContractRepository
+	 */
+	private $contracts;
+
+	/**
+	 * @var Reactivation
+	 */
+	private $sut;
+
+	public function set_up(): void {
+		parent::set_up();
+
+		$this->contracts = new ContractRepository();
+		$this->sut       = new Reactivation( $this->contracts );
+	}
+
+	/**
+	 * Create a monthly plan and return its id.
+	 */
+	private function make_monthly_plan(): int {
+		return $this->make_plan( 'month' );
+	}
+
+	/**
+	 * Create a plan on the given cadence period and return its id.
+	 *
+	 * @param string $period Billing period slug: day/week/month/year.
+	 */
+	private function make_plan( string $period ): int {
+		$group_id = ( new PlanGroupRepository() )->insert( PlanGroup::create( array( 'name' => 'Club' ) ) );
+		$plan     = Plan::create(
+			$group_id,
+			array(
+				'name'           => ucfirst( $period ) . 'ly',
+				'billing_policy' => new BillingPolicy( $period, 1, null, null, null ),
+				'category'       => Plan::DEFAULT_CATEGORY,
+				'extension_slug' => 'engine-tests',
+			)
+		);
+		( new PlanRepository() )->insert( $plan );
+
+		return (int) $plan->get_id();
+	}
+
+	/**
+	 * Seed a contract with a next-payment date and the given selling plan.
+	 *
+	 * @param string|null $next_payment_gmt Next-payment GMT string, or null.
+	 * @param int         $selling_plan_id  Selling plan id.
+	 * @param string      $status           Contract status. Default ON_HOLD.
+	 */
+	private function seed_on_hold( ?string $next_payment_gmt, int $selling_plan_id, string $status = ContractStatus::ON_HOLD ): int {
+		$contract = Contract::create(
+			array(
+				'customer_id'      => 1,
+				'status'           => $status,
+				'currency'         => 'USD',
+				'selling_plan_id'  => $selling_plan_id,
+				'payment_method'   => self::GATEWAY,
+				'start_gmt'        => '2026-01-01 00:00:00',
+				'next_payment_gmt' => $next_payment_gmt,
+				'billing_total'    => '19.99',
+			)
+		);
+
+		return $this->contracts->insert( $contract );
+	}
+
+	private function utc( string $datetime ): DateTimeImmutable {
+		return new DateTimeImmutable( $datetime, new DateTimeZone( 'UTC' ) );
+	}
+
+	public function test_reactivate_resumes_and_rearms_the_renewal(): void {
+		$id = $this->seed_on_hold( '2099-01-01 00:00:00', $this->make_monthly_plan() );
+
+		$result = $this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
+
+		$this->assertTrue( $result );
+		$stored = $this->reload( $id );
+		// Re-armed: an active contract carrying a next-payment date is what the batch due scan picks up.
+		$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
+		$this->assertNotNull( $stored->get_next_payment_gmt(), 'Reactivate re-arms: the contract is active with a next-payment date.' );
+	}
+
+	public function test_reactivate_keeps_a_future_next_payment_unchanged(): void {
+		// Held, then resumed before the date arrives: nothing to recompute.
+		$id = $this->seed_on_hold( '2026-07-01 00:00:00', $this->make_monthly_plan() );
+
+		$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-15 00:00:00' ) );
+
+		$this->assertSame( '2026-07-01 00:00:00', $this->reload( $id )->get_next_payment_gmt() );
+	}
+
+	public function test_reactivate_rolls_a_past_due_date_forward_by_whole_cadences(): void {
+		// Due 2026-02-01, held until 2026-04-15: roll +1 month until future ->
+		// 2026-02-01 -> 2026-03-01 -> 2026-04-01 -> 2026-05-01 (first > now).
+		$id = $this->seed_on_hold( '2026-02-01 00:00:00', $this->make_monthly_plan() );
+
+		$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-04-15 00:00:00' ) );
+
+		$this->assertSame( '2026-05-01 00:00:00', $this->reload( $id )->get_next_payment_gmt() );
+	}
+
+	public function test_reactivate_rolls_by_the_frozen_snapshot_cadence_over_the_live_plan(): void {
+		// The live selling plan is monthly, but the contract's frozen terms are
+		// yearly: the snapshot is what the contract bills under, so the forward
+		// roll steps by years - 2026-01-15 -> 2027-01-15 (first > now).
+		$id = $this->seed_on_hold( '2026-01-15 00:00:00', $this->make_monthly_plan() );
+
+		$contract = $this->reload( $id );
+		$contract->set_plan_snapshot(
+			PlanSnapshot::from_array(
+				array(
+					'selling_plan_id' => $contract->get_selling_plan_id(),
+					'billing_policy'  => array(
+						'period'   => 'year',
+						'interval' => 1,
+					),
+				)
+			)
+		);
+
+		$this->sut->reactivate( $contract, $this->utc( '2026-03-01 00:00:00' ) );
+
+		$this->assertSame( '2027-01-15 00:00:00', $this->reload( $id )->get_next_payment_gmt() );
+	}
+
+	public function test_reactivate_floors_past_due_at_now_when_the_roll_cap_exhausts(): void {
+		// Daily cadence, held ~6.5 years past due: more rolls than the cap allows, so
+		// the date is floored at `$now` - never returned still in the past.
+		$id = $this->seed_on_hold( '2020-01-01 00:00:00', $this->make_plan( 'day' ) );
+
+		$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-07-06 00:00:00' ) );
+
+		$this->assertSame( '2026-07-06 00:00:00', $this->reload( $id )->get_next_payment_gmt() );
+	}
+
+	public function test_reactivate_floors_past_due_at_now_without_a_policy(): void {
+		// Selling plan resolves to no row, so there is no cadence to roll by.
+		$id = $this->seed_on_hold( '2026-02-01 00:00:00', self::MISSING_PLAN_ID );
+
+		$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-04-15 09:30:00' ) );
+
+		$this->assertSame( '2026-04-15 09:30:00', $this->reload( $id )->get_next_payment_gmt() );
+	}
+
+	public function test_reactivate_leaves_a_null_next_payment_null(): void {
+		$id = $this->seed_on_hold( null, $this->make_monthly_plan() );
+
+		$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-04-15 00:00:00' ) );
+
+		$stored = $this->reload( $id );
+		$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
+		// No date to arm: the due scan never selects a contract without a next-payment date.
+		$this->assertNull( $stored->get_next_payment_gmt() );
+	}
+
+	public function test_reactivate_fires_the_reactivated_action(): void {
+		$id    = $this->seed_on_hold( '2099-01-01 00:00:00', $this->make_monthly_plan() );
+		$fired = 0;
+		add_action(
+			Reactivation::CONTRACT_REACTIVATED_ACTION,
+			static function () use ( &$fired ): void {
+				++$fired;
+			}
+		);
+
+		$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
+
+		$this->assertSame( 1, $fired );
+	}
+
+	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.
+		$id       = $this->seed_on_hold( '2026-02-01 00:00:00', $this->make_monthly_plan(), ContractStatus::ACTIVE );
+		$contract = $this->reload( $id );
+
+		try {
+			$this->sut->reactivate( $contract, $this->utc( '2026-04-15 00:00:00' ) );
+			$this->fail( 'Expected a DomainException for an already-active contract.' );
+		} catch ( DomainException $e ) {
+			$row = $this->reload( $id );
+			$this->assertSame( ContractStatus::ACTIVE, $row->get_status() );
+			$this->assertSame( '2026-02-01 00:00:00', $row->get_next_payment_gmt(), 'The past-due date is untouched, so the due scan still bills it.' );
+		}
+	}
+
+	public function test_reactivate_loses_the_race_to_a_concurrent_transition(): void {
+		$id       = $this->seed_on_hold( '2099-01-01 00:00:00', self::MISSING_PLAN_ID );
+		$contract = $this->reload( $id );
+
+		// A concurrent request cancels the contract after our read: the compare-and-set
+		// write must miss loudly instead of resurrecting the contract to active.
+		$concurrent = $this->reload( $id );
+		$concurrent->set_status( ContractStatus::CANCELLED );
+		$this->contracts->update( $concurrent );
+
+		try {
+			$this->sut->reactivate( $contract, $this->utc( '2026-01-01 00:00:00' ) );
+			$this->fail( 'Expected a DomainException when the conditional write misses.' );
+		} catch ( DomainException $e ) {
+			$this->assertSame( ContractStatus::CANCELLED, $this->reload( $id )->get_status(), 'The concurrent cancel is not clobbered.' );
+		}
+	}
+
+	public function test_reactivate_rejects_a_terminal_contract(): void {
+		$contract = Contract::create(
+			array(
+				'customer_id'     => 1,
+				'status'          => ContractStatus::CANCELLED,
+				'currency'        => 'USD',
+				'selling_plan_id' => $this->make_monthly_plan(),
+				'start_gmt'       => '2026-01-01 00:00:00',
+				'billing_total'   => '19.99',
+			)
+		);
+		$id       = $this->contracts->insert( $contract );
+
+		$this->expectException( DomainException::class );
+		$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
+	}
+
+	/**
+	 * Reload a contract, asserting it still exists (narrows the nullable read).
+	 *
+	 * @param int $id Contract id.
+	 */
+	private function reload( int $id ): Contract {
+		$contract = $this->contracts->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+
+		return $contract;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/StatusAwareDueScanTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/StatusAwareDueScanTest.php
new file mode 100644
index 00000000000..5510c34462c
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/StatusAwareDueScanTest.php
@@ -0,0 +1,168 @@
+<?php
+/**
+ * Integration tests for the status-aware due scan: a non-active contract is never
+ * charged when its renewal fires. An on-hold contract is skipped (no charge while
+ * held) and a pending-cancellation contract creates no renewal order at its date.
+ *
+ * Note: actually terminating a pending-cancellation contract at the date (moving it
+ * terminal) is a follow-up slice; the current dispatcher only refuses to charge it.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Renewal;
+
+use EngineIntegrationTestCase;
+use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalIntent;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine
+ */
+class StatusAwareDueScanTest extends EngineIntegrationTestCase {
+
+	private const GATEWAY = 'engine_test_gateway';
+
+	/**
+	 * @var ContractRepository
+	 */
+	private $contracts;
+
+	/**
+	 * @var RenewalEngine
+	 */
+	private $engine;
+
+	public function set_up(): void {
+		parent::set_up();
+		GatewayCapabilities::reset();
+		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
+
+		$this->contracts = new ContractRepository();
+		$this->engine    = new RenewalEngine( $this->contracts );
+	}
+
+	public function tear_down(): void {
+		GatewayCapabilities::reset();
+		parent::tear_down();
+	}
+
+	private function make_origin_order(): WC_Order {
+		$order = new WC_Order();
+		$order->set_currency( 'USD' );
+		$order->set_payment_method( self::GATEWAY );
+		$order->set_total( '19.99' );
+		$order->save();
+
+		return $order;
+	}
+
+	/**
+	 * Seed a contract at a status with a due (past) next-payment date.
+	 *
+	 * @param string $status Contract status.
+	 */
+	private function seed_due( string $status ): int {
+		$order    = $this->make_origin_order();
+		$contract = Contract::create(
+			array(
+				'customer_id'      => 1,
+				'status'           => $status,
+				'currency'         => 'USD',
+				'selling_plan_id'  => 1,
+				'origin_order_id'  => $order->get_id(),
+				'payment_method'   => self::GATEWAY,
+				'start_gmt'        => '2026-01-01 00:00:00',
+				'next_payment_gmt' => '2026-02-01 00:00:00',
+				'end_gmt'          => ContractStatus::PENDING_CANCELLATION === $status ? '2026-02-01 00:00:00' : null,
+				'billing_total'    => '19.99',
+			)
+		);
+
+		return $this->contracts->insert( $contract );
+	}
+
+	/**
+	 * Process a renewal for the contract, mirroring how the batch dispatcher hands the
+	 * engine a resolved intent for the due contract. The non-active status guard in
+	 * {@see RenewalEngine::process()} short-circuits ahead of any cycle selection, so the
+	 * exact cycle count handed in is immaterial here.
+	 *
+	 * @param int $contract_id Contract id.
+	 */
+	private function process_renewal( int $contract_id ): ?WC_Order {
+		return $this->engine->process(
+			new RenewalIntent( $contract_id, 1 ),
+			new \DateTimeImmutable( '2026-02-01 00:00:00', new \DateTimeZone( 'UTC' ) )
+		);
+	}
+
+	/**
+	 * Count renewal orders tagged for a contract.
+	 *
+	 * @param int $contract_id Contract id.
+	 */
+	private function renewal_order_count( int $contract_id ): int {
+		$orders = wc_get_orders(
+			array(
+				'limit'      => -1,
+				'status'     => 'any',
+				'type'       => 'shop_order',
+				'meta_key'   => OrderLinkage::META_CONTRACT_ID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+				'meta_value' => (string) $contract_id,          // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+			)
+		);
+
+		$count = 0;
+		foreach ( is_array( $orders ) ? $orders : array() as $order ) {
+			if ( $order instanceof WC_Order && OrderLinkage::RELATION_RENEWAL === $order->get_meta( OrderLinkage::META_RELATION_TYPE ) ) {
+				++$count;
+			}
+		}
+
+		return $count;
+	}
+
+	public function test_on_hold_contract_at_its_date_creates_no_renewal_order(): void {
+		$id = $this->seed_due( ContractStatus::ON_HOLD );
+
+		$result = $this->process_renewal( $id );
+
+		$this->assertNull( $result );
+		$this->assertSame( 0, $this->renewal_order_count( $id ) );
+		// Still on hold; the next-payment date is left for reactivate to re-arm.
+		$this->assertSame( ContractStatus::ON_HOLD, $this->reload( $id )->get_status() );
+	}
+
+	public function test_pending_cancellation_at_its_date_creates_no_renewal_order(): void {
+		$id = $this->seed_due( ContractStatus::PENDING_CANCELLATION );
+
+		$result = $this->process_renewal( $id );
+
+		$this->assertNull( $result, 'No renewal order is created for a non-active contract.' );
+		$this->assertSame( 0, $this->renewal_order_count( $id ) );
+		// The contract is not charged. Terminating it at the date is a follow-up slice,
+		// so it remains pending-cancellation for now.
+		$this->assertSame( ContractStatus::PENDING_CANCELLATION, $this->reload( $id )->get_status() );
+	}
+
+	/**
+	 * Reload a contract, asserting it still exists (narrows the nullable read).
+	 *
+	 * @param int $id Contract id.
+	 */
+	private function reload( int $id ): Contract {
+		$contract = $this->contracts->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+
+		return $contract;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractCustomerReadTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractCustomerReadTest.php
new file mode 100644
index 00000000000..2726126eb30
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractCustomerReadTest.php
@@ -0,0 +1,158 @@
+<?php
+/**
+ * Integration tests for the customer-scoped contract reads on ContractRepository:
+ * find_by_customer_id (owner scoping, ordering, status filter, paging) and
+ * find_for_customer (the ownership-filtered full read behind the asymmetric
+ * not-found rule).
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Storage;
+
+use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository
+ */
+class ContractCustomerReadTest extends EngineIntegrationTestCase {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var ContractRepository
+	 */
+	private $sut;
+
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new ContractRepository();
+	}
+
+	/**
+	 * Insert a contract for a customer at a given status.
+	 *
+	 * @param int    $customer_id Owning customer.
+	 * @param string $status      Contract status.
+	 */
+	private function seed( int $customer_id, string $status ): 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' => '2026-02-01 00:00:00',
+				'billing_total'    => '10.00',
+			)
+		);
+
+		return $this->sut->insert( $contract );
+	}
+
+	/**
+	 * Map a result set to its contract ids.
+	 *
+	 * @param array<int, Contract> $contracts Contracts.
+	 * @return array<int, int>
+	 */
+	private function ids( array $contracts ): array {
+		return array_map(
+			static function ( Contract $contract ): int {
+				return (int) $contract->get_id();
+			},
+			$contracts
+		);
+	}
+
+	public function test_returns_only_the_requested_customers_contracts(): void {
+		$mine_a = $this->seed( 10, ContractStatus::ACTIVE );
+		$mine_b = $this->seed( 10, ContractStatus::ON_HOLD );
+		$theirs = $this->seed( 20, ContractStatus::ACTIVE );
+
+		$ids = $this->ids( $this->sut->find_by_customer_id( 10 ) );
+
+		$this->assertContains( $mine_a, $ids );
+		$this->assertContains( $mine_b, $ids );
+		$this->assertNotContains( $theirs, $ids );
+		$this->assertCount( 2, $ids );
+	}
+
+	public function test_orders_newest_first(): void {
+		$older = $this->seed( 11, ContractStatus::ACTIVE );
+		$newer = $this->seed( 11, ContractStatus::ACTIVE );
+
+		$results = $this->sut->find_by_customer_id( 11 );
+
+		$this->assertSame( $newer, (int) $results[0]->get_id() );
+		$this->assertSame( $older, (int) $results[1]->get_id() );
+	}
+
+	public function test_honours_a_status_filter(): void {
+		$this->seed( 12, ContractStatus::ACTIVE );
+		$held = $this->seed( 12, ContractStatus::ON_HOLD );
+
+		$results = $this->sut->find_by_customer_id( 12, array( 'status' => ContractStatus::ON_HOLD ) );
+
+		$this->assertCount( 1, $results );
+		$this->assertSame( $held, (int) $results[0]->get_id() );
+		$this->assertSame( ContractStatus::ON_HOLD, $results[0]->get_status() );
+	}
+
+	public function test_paging_with_limit_and_offset(): void {
+		$ids = array();
+		for ( $i = 0; $i < 5; $i++ ) {
+			$ids[] = $this->seed( 13, ContractStatus::ACTIVE );
+		}
+		rsort( $ids ); // Newest-first order the query returns.
+
+		$page_one = $this->sut->find_by_customer_id(
+			13,
+			array(
+				'limit'  => 2,
+				'offset' => 0,
+			)
+		);
+		$page_two = $this->sut->find_by_customer_id(
+			13,
+			array(
+				'limit'  => 2,
+				'offset' => 2,
+			)
+		);
+
+		$this->assertSame( array( $ids[0], $ids[1] ), $this->ids( $page_one ) );
+		$this->assertSame( array( $ids[2], $ids[3] ), $this->ids( $page_two ) );
+	}
+
+	public function test_empty_for_a_customer_with_no_contracts(): void {
+		$this->assertSame( array(), $this->sut->find_by_customer_id( 999 ) );
+	}
+
+	public function test_find_for_customer_returns_the_owned_contract_hydrated(): void {
+		$id = $this->seed( 14, ContractStatus::ACTIVE );
+
+		$contract = $this->sut->find_for_customer( $id, 14 );
+
+		$this->assertInstanceOf( Contract::class, $contract );
+		$this->assertSame( $id, $contract->get_id() );
+		$this->assertSame( 14, $contract->get_customer_id() );
+	}
+
+	public function test_find_for_customer_is_null_for_a_foreign_owned_contract(): void {
+		$id = $this->seed( 14, ContractStatus::ACTIVE );
+
+		$this->assertNull( $this->sut->find_for_customer( $id, 15 ) );
+	}
+
+	public function test_find_for_customer_is_null_for_an_unknown_contract(): void {
+		// Indistinguishable from "owned by someone else" - the asymmetric not-found rule.
+		$this->assertNull( $this->sut->find_for_customer( 123456, 14 ) );
+	}
+}
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 862482b0f46..93c24a1c0ef 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
@@ -20,6 +20,7 @@ use PHPUnit\Framework\TestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\InstrumentRef;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract
@@ -301,20 +302,29 @@ class ContractTest extends TestCase {
 	}

 	/**
-	 * @testdox from_storage() hydrates items, addresses, and meta children.
+	 * @testdox from_storage() hydrates the plan snapshot, items, addresses, and meta children.
 	 */
 	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(), $items, $addresses, $meta );
+		$contract = Contract::from_storage( $this->valid_row(), $snapshot, $items, $addresses, $meta );

+		$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() );
 	}

+	/**
+	 * @testdox from_storage() leaves the plan snapshot null when none is passed.
+	 */
+	public function test_from_storage_defaults_to_no_plan_snapshot(): void {
+		$this->assertNull( Contract::from_storage( $this->valid_row() )->get_plan_snapshot() );
+	}
+
 	/**
 	 * @testdox to_storage() carries the full live column set.
 	 */
@@ -360,4 +370,35 @@ class ContractTest extends TestCase {

 		$this->assertArrayNotHasKey( 'cycle_count', $row, 'to_storage() must not carry a generic cycle_count; counters are per-chain and derived.' );
 	}
+
+	/**
+	 * @testdox the hydrated plan snapshot is null until set, then returns what was set.
+	 */
+	public function test_plan_snapshot_hydration_round_trips(): void {
+		$contract = $this->make_contract();
+		$this->assertNull( $contract->get_plan_snapshot() );
+
+		$snapshot = PlanSnapshot::from_array(
+			array(
+				'selling_plan_id' => 2,
+				'billing_policy'  => array(
+					'period'   => 'month',
+					'interval' => 1,
+				),
+			)
+		);
+		$contract->set_plan_snapshot( $snapshot );
+
+		$this->assertSame( $snapshot, $contract->get_plan_snapshot() );
+	}
+
+	/**
+	 * @testdox the hydrated plan snapshot is not a stored column.
+	 */
+	public function test_plan_snapshot_is_not_in_to_storage(): void {
+		$contract = $this->make_contract();
+		$contract->set_plan_snapshot( PlanSnapshot::from_array( array( 'selling_plan_id' => 2 ) ) );
+
+		$this->assertArrayNotHasKey( 'plan_snapshot', $contract->to_storage(), 'plan_snapshot is a hydrated read-only field, not a stored column.' );
+	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
index d41799d4ee2..e6892e8048d 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
@@ -11,6 +11,7 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\ValueObject

 use DomainException;
 use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;

 /**
@@ -94,4 +95,54 @@ class PlanSnapshotTest extends TestCase {

 		PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ), 0 );
 	}
+
+	/**
+	 * @testdox get_billing_policy reconstructs the frozen cadence from the payload.
+	 */
+	public function test_get_billing_policy_reconstructs_the_frozen_cadence(): void {
+		$snapshot = PlanSnapshot::from_array(
+			array(
+				'selling_plan_id' => 7,
+				'name'            => 'Monthly box',
+				'billing_policy'  => array(
+					'period'         => 'month',
+					'interval'       => 3,
+					'min_cycles'     => null,
+					'max_cycles'     => 12,
+					'trial_duration' => null,
+				),
+			)
+		);
+
+		$policy = $snapshot->get_billing_policy();
+
+		$this->assertInstanceOf( BillingPolicy::class, $policy );
+		$this->assertSame( 'month', $policy->get_period() );
+		$this->assertSame( 3, $policy->get_interval() );
+		$this->assertSame( 12, $policy->get_max_cycles() );
+	}
+
+	/**
+	 * @testdox get_billing_policy is null when the payload carries no billing policy.
+	 */
+	public function test_get_billing_policy_is_null_when_absent(): void {
+		$snapshot = PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ) );
+
+		$this->assertNull( $snapshot->get_billing_policy() );
+	}
+
+	/**
+	 * @testdox get_billing_policy degrades to null for a structurally-invalid stored policy.
+	 */
+	public function test_get_billing_policy_is_null_for_an_unreadable_policy(): void {
+		// `interval` missing: BillingPolicy::from_array() would throw; the accessor swallows
+		// it and degrades to "no cadence" rather than fataling the read.
+		$snapshot = PlanSnapshot::from_array(
+			array(
+				'billing_policy' => array( 'period' => 'month' ),
+			)
+		);
+
+		$this->assertNull( $snapshot->get_billing_policy() );
+	}
 }