Commit f755844ec96 for woocommerce

commit f755844ec96c83ab4ec291c074c61bd296193e81
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date:   Fri Oct 9 17:46:17 2026 +0200

    Subscriptions engine: move lifecycle flows out and add a contract action endpoint (#69584)

    Subscriptions engine: move lifecycle flows out and add a contract action endpoint (#69584)

    Hold, reactivate and cancel are lifecycle policy, so the engine stops running
    them; the consuming extension implements them on Api\Contracts. Instead of each
    extension shipping its own contract endpoints, the engine offers one action
    endpoint that dispatches extension-registered actions.

    Added:
    - Api\ContractActions::register( $extension_slug, $action, $args ): callback,
      permission (a capability checked with the ContractView, or a callable),
      description, args (property schemas or a per-contract callable) and
      is_available.
    - read_subscription_contract and manage_subscription_contract meta
      capabilities, mapped at map_meta_cap priority 0: the contract's customer
      needs read, anyone else manage_woocommerce.
    - GET wc/v3/subscriptions-engine/contracts/{id}: the stored contract.
    - GET .../contracts/{id}/action: the owner's available actions.
    - POST .../contracts/{id}/action { action, extension_slug, action_args }:
      runs the owner's action. 401 logged out; one 404 for unknown, other-owner,
      unregistered or not permitted; 409 not available; 400 invalid action_args.

    Removed:
    - Integration\Contracts\Hold, Reactivation, Cancellation and their actions.
    - The contracts/{id}/(hold|reactivate|cancel) routes.
    - Api\Subscriptions::cancel(), hold(), reactivate(), cancel_at_period_end().

    Kept: the renewal path and its status guards.

    Part of WOOSUBS-2062

diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-lifecycle-flows-to-lite b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-lifecycle-flows-to-lite
new file mode 100644
index 00000000000..baa4ecc1308
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-lifecycle-flows-to-lite
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Move lifecycle flows to extensions and add a contract action endpoint in the unreleased subscriptions engine; no changelog entry needed.
+
diff --git a/packages/php/woocommerce-subscriptions-engine/phpcs.xml b/packages/php/woocommerce-subscriptions-engine/phpcs.xml
index 22a3255832d..37162912c90 100644
--- a/packages/php/woocommerce-subscriptions-engine/phpcs.xml
+++ b/packages/php/woocommerce-subscriptions-engine/phpcs.xml
@@ -3,6 +3,16 @@
 	<!-- Set the base standard to WordPress -->
 	<rule ref="WordPress"/>

+	<!-- The engine maps these meta capabilities (Integration\Ownership\ContractCapabilities). -->
+	<rule ref="WordPress.WP.Capabilities">
+		<properties>
+			<property name="custom_capabilities" type="array">
+				<element value="read_subscription_contract"/>
+				<element value="manage_subscription_contract"/>
+			</property>
+		</properties>
+	</rule>
+
 	<!-- Define files and folders to scan -->
 	<file>.</file>

diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/ContractActions.php b/packages/php/woocommerce-subscriptions-engine/src/Api/ContractActions.php
new file mode 100644
index 00000000000..024523dec4e
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/ContractActions.php
@@ -0,0 +1,124 @@
+<?php
+/**
+ * ContractActions - register the contract actions the engine's action endpoint dispatches.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Rest\ContractActionRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Public registration facade for contract actions.
+ *
+ * `wc/v3/subscriptions-engine/contracts/{id}/action` dispatches only to actions registered by
+ * the contract's owning extension. The engine registers no actions. Register on or before `rest_api_init`. Final and static-only.
+ */
+final class ContractActions {
+
+	/**
+	 * Keys accepted by {@see self::register()}, as a key map.
+	 *
+	 * @var array<string, true>
+	 */
+	private const REGISTRATION_KEYS = array(
+		'callback'     => true,
+		'permission'   => true,
+		'description'  => true,
+		'args'         => true,
+		'is_available' => true,
+	);
+
+	/**
+	 * Register an action for the extension's contracts.
+	 *
+	 * An invalid or duplicate registration raises a `_doing_it_wrong()` notice and is not
+	 * registered (the first registration is kept). Unknown keys raise a notice and are ignored.
+	 *
+	 * @param string               $extension_slug Owning extension slug; matches the contracts' `extension_slug`.
+	 * @param string               $action         Action slug: lowercase letters, numbers, hyphens and underscores.
+	 * @param array<string, mixed> $args           `callback` (required): `callable( ContractView $contract, array $action_args ): ContractView|WP_Error`.
+	 *                                             `permission` (required): a capability, checked as `current_user_can( $permission, $contract )`
+	 *                                             with the `ContractView`, or a non-string `callable( ContractView $contract, WP_REST_Request $request ): bool`.
+	 *                                             `manage_subscription_contract` allows the contract's customer and store managers.
+	 *                                             `description` (string, default '').
+	 *                                             `args`: REST property schemas for `action_args` (`array<string, array>`),
+	 *                                             or `callable( ContractView $contract ): array` resolved per request.
+	 *                                             `is_available`: `callable( ContractView $contract ): bool`, default always.
+	 */
+	public static function register( string $extension_slug, string $action, array $args ): void {
+		$filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::REGISTRATION_KEYS, 'argument' );
+		$callback      = $filtered_args['callback'] ?? null;
+		$permission    = $filtered_args['permission'] ?? null;
+		$description   = $filtered_args['description'] ?? '';
+		$action_args   = $filtered_args['args'] ?? array();
+		$is_available  = $filtered_args['is_available'] ?? null;
+
+		if ( '' === trim( $extension_slug ) ) {
+			self::reject( 'The extension slug must not be empty.' );
+			return;
+		}
+
+		if ( '' === $action || sanitize_key( $action ) !== $action ) {
+			self::reject( sprintf( 'Contract action "%s" may only contain lowercase letters, numbers, hyphens and underscores.', $action ) );
+			return;
+		}
+
+		if ( ! is_callable( $callback ) ) {
+			self::reject( sprintf( 'Contract action "%s" needs a callable "callback".', $action ) );
+			return;
+		}
+
+		if ( is_string( $permission ) ? '' === trim( $permission ) : ! is_callable( $permission ) ) {
+			self::reject( sprintf( 'Contract action "%s" needs a "permission" capability or callable.', $action ) );
+			return;
+		}
+
+		if ( ! is_string( $description ) ) {
+			self::reject( sprintf( 'The "description" of contract action "%s" must be a string.', $action ) );
+			return;
+		}
+
+		if ( ! is_callable( $action_args ) && ! ContractActionRegistry::is_args_schema( $action_args ) ) {
+			self::reject( sprintf( 'The "args" of contract action "%s" must be an array of property schemas or a callable.', $action ) );
+			return;
+		}
+
+		if ( null !== $is_available && ! is_callable( $is_available ) ) {
+			self::reject( sprintf( 'The "is_available" of contract action "%s" must be a callable.', $action ) );
+			return;
+		}
+
+		if ( ContractActionRegistry::has( $extension_slug, $action ) ) {
+			self::reject( sprintf( 'Contract action "%s" is already registered for "%s"; the first registration is kept.', $action, $extension_slug ) );
+			return;
+		}
+
+		ContractActionRegistry::add(
+			array(
+				'extension_slug' => $extension_slug,
+				'action'         => $action,
+				'callback'       => $callback,
+				'permission'     => $permission,
+				'description'    => $description,
+				'args'           => $action_args,
+				'is_available'   => $is_available,
+			)
+		);
+	}
+
+	/**
+	 * Raise the `_doing_it_wrong()` notice for a registration that is not registered.
+	 *
+	 * @param string $message What is wrong.
+	 */
+	private static function reject( string $message ): void {
+		_doing_it_wrong( __CLASS__ . '::register', esc_html( $message ), '0.0.1' );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
index 667d2801fbc..fd91206ecbd 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
@@ -11,13 +11,12 @@
  * read-only {@see PlanView}s. The engine opens no transaction and keeps no cache.
  *
  * Billing payload contract: the engine reads one plan payload itself. Until every
- * contract carries a plan snapshot, renewal and reactivation fall back to the live
- * plan's `billing_policy` and read it with {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy::from_array()}
+ * contract carries a plan snapshot, renewal falls back to the live plan's
+ * `billing_policy` and reads it with {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy::from_array()}
  * (a string `period` of day, week, month or year, a positive int `interval`, and
  * optional cycle bounds and trial). A payload of another shape is still stored, but
- * renewal parks such a contract and reactivation rolls it without a cadence. The
- * snapshot's `billing_policy` is read the same way, and one that fails the rule falls
- * back to the live plan.
+ * renewal parks such a contract. The snapshot's `billing_policy` is read the same way,
+ * and one that fails the rule falls back to the live plan.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Api
  */
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php
index 1b3f84a85ef..684df422e8c 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php
@@ -1,35 +1,6 @@
 <?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.
- *
- * Interim: moves out of the engine with the lifecycle flows (hold / reactivate /
- * cancel and their routes).
- *
- * Every route requires a logged-in user, enforced through the shared
- * {@see RESTPermissions} floor (core's cookie auth has already verified the REST nonce
- * `wp_rest` by then). Per-route, ownership is enforced with the asymmetric not-found
- * 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).
+ * REST controller for subscription engine contracts.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Api\Rest
  */
@@ -38,23 +9,29 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Api\Rest;

-use DomainException;
+use SplObjectStorage;
 use Throwable;
+use UnexpectedValueException;
 use WP_Error;
 use WP_REST_Controller;
 use WP_REST_Request;
 use WP_REST_Response;
 use WP_REST_Server;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
-use Automattic\WooCommerce\SubscriptionsEngine\Api\Subscriptions;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ContractCapabilities;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Rest\ContractActionRegistry;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\RESTPermissions;

 defined( 'ABSPATH' ) || exit;

 /**
- * REST controller for the generic contract lifecycle actions.
+ * Contracts REST controller under `wc/v3/subscriptions-engine/contracts`: `GET /{id}` returns
+ * the stored contract to store managers; `GET|POST /{id}/action` lists and runs the actions
+ * the contract's owning extension registered through {@see \Automattic\WooCommerce\SubscriptionsEngine\Api\ContractActions}.
+ *
+ * @phpstan-import-type ContractActionDefinition from ContractActionRegistry
  */
 final class ContractsController extends WP_REST_Controller {

@@ -62,6 +39,8 @@ final class ContractsController extends WP_REST_Controller {

 	private const REST_BASE = 'subscriptions-engine/contracts';

+	private const LOG_SOURCE = 'woocommerce-subscriptions-engine';
+
 	/**
 	 * REST permissions.
 	 *
@@ -70,14 +49,28 @@ final class ContractsController extends WP_REST_Controller {
 	private $rest_permissions;

 	/**
-	 * Build the controller.
+	 * Read resolutions per request, so the callback reuses what the permission check read.
+	 *
+	 * @var SplObjectStorage<WP_REST_Request, ContractView|WP_Error>
+	 */
+	private $resolved_reads;
+
+	/**
+	 * Run resolutions per request, so the callback reuses what the permission check read.
 	 *
-	 * @param RESTPermissions|null $rest_permissions REST permissions; default instance when omitted.
+	 * @var SplObjectStorage<WP_REST_Request, array{contract: ContractView, definition: ContractActionDefinition}|WP_Error>
+	 */
+	private $resolved_runs;
+
+	/**
+	 * Build the controller.
 	 */
-	public function __construct( ?RESTPermissions $rest_permissions = null ) {
+	public function __construct() {
 		$this->namespace        = self::REST_NAMESPACE;
 		$this->rest_base        = self::REST_BASE;
-		$this->rest_permissions = $rest_permissions ?? new RESTPermissions();
+		$this->rest_permissions = new RESTPermissions();
+		$this->resolved_reads   = new SplObjectStorage();
+		$this->resolved_runs    = new SplObjectStorage();
 	}

 	/**
@@ -98,13 +91,21 @@ final class ContractsController extends WP_REST_Controller {
 	public function register_routes(): void {
 		register_rest_route(
 			self::REST_NAMESPACE,
-			'/' . self::REST_BASE . '/(?P<id>[\d]+)/hold',
+			'/' . self::REST_BASE . '/(?P<id>[\d]+)',
 			array(
-				'args'   => $this->id_arg(),
+				'args'   => array(
+					'id' => array(
+						'description' => __( 'Unique identifier for the contract.', 'woocommerce-subscriptions-engine' ),
+						'type'        => 'integer',
+					),
+				),
 				array(
-					'methods'             => WP_REST_Server::CREATABLE,
-					'callback'            => array( $this, 'hold_item' ),
-					'permission_callback' => array( $this, 'permissions_check' ),
+					'methods'             => WP_REST_Server::READABLE,
+					'callback'            => array( $this, 'get_item' ),
+					'permission_callback' => array( $this, 'get_item_permissions_check' ),
+					'args'                => array(
+						'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+					),
 				),
 				'schema' => array( $this, 'get_public_item_schema' ),
 			)
@@ -112,137 +113,230 @@ final class ContractsController extends WP_REST_Controller {

 		register_rest_route(
 			self::REST_NAMESPACE,
-			'/' . self::REST_BASE . '/(?P<id>[\d]+)/reactivate',
+			'/' . self::REST_BASE . '/(?P<id>[\d]+)/action',
 			array(
-				'args'   => $this->id_arg(),
+				'args' => array(
+					'id' => array(
+						'description' => __( 'Unique identifier for the contract.', 'woocommerce-subscriptions-engine' ),
+						'type'        => 'integer',
+					),
+				),
 				array(
-					'methods'             => WP_REST_Server::CREATABLE,
-					'callback'            => array( $this, 'reactivate_item' ),
-					'permission_callback' => array( $this, 'permissions_check' ),
+					'methods'             => WP_REST_Server::READABLE,
+					'callback'            => array( $this, 'get_actions' ),
+					'permission_callback' => array( $this, 'get_item_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' ),
+					'callback'            => array( $this, 'run_action' ),
+					'permission_callback' => array( $this, 'run_action_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',
+						'action'         => array(
+							'description' => __( 'Action to run.', 'woocommerce-subscriptions-engine' ),
+							'type'        => 'string',
+							'required'    => true,
+						),
+						'extension_slug' => array(
+							'description' => __( 'Slug of the extension that owns the contract and registered the action.', 'woocommerce-subscriptions-engine' ),
+							'type'        => 'string',
+							'required'    => true,
+						),
+						'action_args'    => array(
+							'description' => __( 'Arguments for the action, as its schema describes them.', 'woocommerce-subscriptions-engine' ),
+							'type'        => 'object',
+							'default'     => array(),
 						),
 					),
 				),
-				'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).
+	 * Check whether the current user may read the contract. Anything that is not a 401 is the
+	 * same 404, so a caller cannot probe for contracts it may not read.
 	 *
 	 * @param WP_REST_Request $request Request.
-	 * @return true|WP_Error True when logged in, else a 401 error.
+	 * @return true|WP_Error
 	 */
-	public function permissions_check( $request ) {
-		return $this->rest_permissions->require_logged_in_permission();
+	public function get_item_permissions_check( $request ) {
+		$logged_in = $this->rest_permissions->require_logged_in_permission();
+		if ( true !== $logged_in ) {
+			return $logged_in;
+		}
+
+		$resolved = $this->resolve_read( $request );
+
+		return $resolved instanceof WP_Error ? $resolved : true;
 	}

 	/**
-	 * POST /{id}/hold.
+	 * Check whether the current user may run the requested action on the contract. Anything
+	 * that is not a 401 is the same 404, so a caller cannot probe for contracts it may not act on.
 	 *
-	 * @param WP_REST_Request $request The request.
-	 * @return WP_REST_Response|WP_Error The domain summary, or an error.
+	 * @param WP_REST_Request $request Request.
+	 * @return true|WP_Error
 	 */
-	public function hold_item( $request ) {
-		return $this->run_action(
-			$request,
-			static function ( int $id ): void {
-				Subscriptions::hold( $id );
-			}
-		);
+	public function run_action_permissions_check( $request ) {
+		$logged_in = $this->rest_permissions->require_logged_in_permission();
+		if ( true !== $logged_in ) {
+			return $logged_in;
+		}
+
+		$resolved = $this->resolve_run( $request );
+
+		return $resolved instanceof WP_Error ? $resolved : true;
 	}

 	/**
-	 * POST /{id}/reactivate.
+	 * List the actions the contract's owner registered that are available for it now, with their
+	 * resolved args.
 	 *
-	 * @param WP_REST_Request $request The request.
-	 * @return WP_REST_Response|WP_Error The domain summary, or an error.
+	 * @param WP_REST_Request $request Request.
+	 * @return WP_REST_Response|WP_Error
 	 */
-	public function reactivate_item( $request ) {
-		return $this->run_action(
-			$request,
-			static function ( int $id ): void {
-				Subscriptions::reactivate( $id );
+	public function get_actions( $request ) {
+		$contract = $this->resolve_read( $request );
+		if ( $contract instanceof WP_Error ) {
+			return $contract;
+		}
+
+		$actions = array();
+		try {
+			foreach ( ContractActionRegistry::get_for_extension( (string) $contract->get_extension_slug() ) as $definition ) {
+				if ( ! ContractActionRegistry::is_available( $definition, $contract ) ) {
+					continue;
+				}
+
+				$actions[] = array(
+					'action'         => $definition['action'],
+					'extension_slug' => $definition['extension_slug'],
+					'description'    => $definition['description'],
+					'args'           => $this->get_args_for_response( ContractActionRegistry::get_args_schema( $definition, $contract ) ),
+				);
 			}
-		);
+		} catch ( Throwable $e ) {
+			return $this->get_action_failed_error( $e, $request );
+		}
+
+		return rest_ensure_response( array( 'actions' => $actions ) );
 	}

 	/**
-	 * POST /{id}/cancel - body `{ at_period_end: bool }` (default true).
+	 * Run the action: 409 when it is not available, 400 for invalid `action_args`, then the
+	 * callback's result. A `WP_Error` passes through (status 400 unless it has one).
 	 *
-	 * `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.
+	 * @param WP_REST_Request $request Request.
+	 * @return WP_REST_Response|WP_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( Coercion::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 );
-				}
+	public function run_action( $request ) {
+		$resolved = $this->resolve_run( $request );
+		if ( $resolved instanceof WP_Error ) {
+			return $resolved;
+		}
+
+		$contract   = $resolved['contract'];
+		$definition = $resolved['definition'];
+		try {
+			if ( ! ContractActionRegistry::is_available( $definition, $contract ) ) {
+				return new WP_Error(
+					'woocommerce_subscriptions_engine_action_not_available',
+					__( 'This action is not available for the contract.', 'woocommerce-subscriptions-engine' ),
+					array( 'status' => 409 )
+				);
 			}
-		);
+
+			$action_args = $this->get_validated_action_args( $request, ContractActionRegistry::get_args_schema( $definition, $contract ) );
+			if ( $action_args instanceof WP_Error ) {
+				return $action_args;
+			}
+
+			$result = ( $definition['callback'] )( $contract, $action_args );
+		} catch ( Throwable $e ) {
+			return $this->get_action_failed_error( $e, $request );
+		}
+
+		if ( $result instanceof ContractView ) {
+			return rest_ensure_response(
+				array(
+					'id'     => $result->get_id(),
+					'status' => $result->get_status(),
+				)
+			);
+		}
+
+		if ( $result instanceof WP_Error ) {
+			$error_data = $result->get_error_data();
+			if ( ! is_array( $error_data ) || ! isset( $error_data['status'] ) ) {
+				$result->add_data( array_merge( is_array( $error_data ) ? $error_data : array(), array( 'status' => 400 ) ) );
+			}
+
+			return $result;
+		}
+
+		return $this->get_action_failed_error( new UnexpectedValueException( 'The action callback must return a ContractView or a WP_Error.' ), $request );
 	}

 	/**
-	 * Serialize a contract as the action-response domain summary.
+	 * Get one contract with its items and addresses.
 	 *
-	 * Domain values only - the id and the resulting status slug - never labels,
-	 * formatted values, or other presentation: consumers own their view shaping.
+	 * @param WP_REST_Request $request Request.
+	 * @return WP_REST_Response|WP_Error
+	 */
+	public function get_item( $request ) {
+		$contract = $this->resolve_read( $request );
+		if ( $contract instanceof WP_Error ) {
+			return $contract;
+		}
+
+		return $this->prepare_item_for_response( $contract, $request );
+	}
+
+	/**
+	 * The contract as response data. Dates are GMT, formatted like other WordPress REST dates.
 	 *
-	 * @param ContractView    $item    Contract view.
+	 * @param ContractView    $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(),
+			'id'                   => $item->get_id(),
+			'extension_slug'       => $item->get_extension_slug(),
+			'status'               => $item->get_status(),
+			'customer_id'          => $item->get_customer_id(),
+			'currency'             => $item->get_currency(),
+			'selling_plan_id'      => $item->get_selling_plan_id(),
+			'origin_order_id'      => $item->get_origin_order_id(),
+			'payment_method'       => $item->get_payment_method(),
+			'payment_method_title' => $item->get_payment_method_title(),
+			'payment_token_id'     => $item->get_payment_token_id(),
+			'start_gmt'            => wc_rest_prepare_date_response( $item->get_start_gmt() ),
+			'next_payment_gmt'     => wc_rest_prepare_date_response( $item->get_next_payment_gmt() ),
+			'last_payment_gmt'     => wc_rest_prepare_date_response( $item->get_last_payment_gmt() ),
+			'last_attempt_gmt'     => wc_rest_prepare_date_response( $item->get_last_attempt_gmt() ),
+			'trial_end_gmt'        => wc_rest_prepare_date_response( $item->get_trial_end_gmt() ),
+			'end_gmt'              => wc_rest_prepare_date_response( $item->get_end_gmt() ),
+			'schedule_source'      => $item->get_schedule_source(),
+			'billing_total'        => $item->get_billing_total(),
+			'discount_total'       => $item->get_discount_total(),
+			'shipping_total'       => $item->get_shipping_total(),
+			'tax_total'            => $item->get_tax_total(),
+			'items'                => $item->get_items() ?? array(),
+			'addresses'            => $item->get_addresses() ?? array(),
 		);
+		if ( array() === $data['addresses'] ) {
+			$data['addresses'] = new \stdClass(); // Encodes as `{}`.
+		}

 		$data = $this->add_additional_fields_to_object( $data, $request );
+		$data = $this->filter_response_by_context( $data, Coercion::coerce_string( $request->get_param( 'context' ), 'view' ) );

 		return rest_ensure_response( $data );
 	}

 	/**
-	 * Get item schema: the action-response domain summary.
+	 * Get the contract schema.
 	 *
 	 * @return array<string, mixed>
 	 */
@@ -251,106 +345,216 @@ final class ContractsController extends WP_REST_Controller {
 			return $this->add_additional_fields_schema( $this->schema );
 		}

+		$properties = array(
+			'id'                   => array( 'integer', __( 'Unique identifier for the contract.', 'woocommerce-subscriptions-engine' ) ),
+			'extension_slug'       => array( array( 'string', 'null' ), __( 'Slug of the extension that owns the contract.', 'woocommerce-subscriptions-engine' ) ),
+			'status'               => array( 'string', __( 'Contract status slug.', 'woocommerce-subscriptions-engine' ) ),
+			'customer_id'          => array( array( 'integer', 'null' ), __( 'Customer user ID.', 'woocommerce-subscriptions-engine' ) ),
+			'currency'             => array( array( 'string', 'null' ), __( 'Currency code.', 'woocommerce-subscriptions-engine' ) ),
+			'selling_plan_id'      => array( array( 'integer', 'null' ), __( 'Plan ID.', 'woocommerce-subscriptions-engine' ) ),
+			'origin_order_id'      => array( array( 'integer', 'null' ), __( 'ID of the order the contract started from.', 'woocommerce-subscriptions-engine' ) ),
+			'payment_method'       => array( array( 'string', 'null' ), __( 'Payment gateway ID.', 'woocommerce-subscriptions-engine' ) ),
+			'payment_method_title' => array( array( 'string', 'null' ), __( 'Payment method title.', 'woocommerce-subscriptions-engine' ) ),
+			'payment_token_id'     => array( array( 'integer', 'null' ), __( 'Payment token ID.', 'woocommerce-subscriptions-engine' ) ),
+			'start_gmt'            => array( array( 'string', 'null' ), __( 'Start date, as GMT.', 'woocommerce-subscriptions-engine' ) ),
+			'next_payment_gmt'     => array( array( 'string', 'null' ), __( 'Next-due moment, as GMT.', 'woocommerce-subscriptions-engine' ) ),
+			'last_payment_gmt'     => array( array( 'string', 'null' ), __( 'Last payment date, as GMT.', 'woocommerce-subscriptions-engine' ) ),
+			'last_attempt_gmt'     => array( array( 'string', 'null' ), __( 'Last payment attempt date, as GMT.', 'woocommerce-subscriptions-engine' ) ),
+			'trial_end_gmt'        => array( array( 'string', 'null' ), __( 'Trial end date, as GMT.', 'woocommerce-subscriptions-engine' ) ),
+			'end_gmt'              => array( array( 'string', 'null' ), __( 'End date, as GMT.', 'woocommerce-subscriptions-engine' ) ),
+			'schedule_source'      => array( 'string', __( 'Who keeps the payment schedule.', 'woocommerce-subscriptions-engine' ) ),
+			'billing_total'        => array( 'string', __( 'Recurring total.', 'woocommerce-subscriptions-engine' ) ),
+			'discount_total'       => array( 'string', __( 'Recurring discount total.', 'woocommerce-subscriptions-engine' ) ),
+			'shipping_total'       => array( 'string', __( 'Recurring shipping total.', 'woocommerce-subscriptions-engine' ) ),
+			'tax_total'            => array( 'string', __( 'Recurring tax total.', 'woocommerce-subscriptions-engine' ) ),
+			'items'                => array( 'array', __( 'Line items.', 'woocommerce-subscriptions-engine' ) ),
+			'addresses'            => array( 'object', __( 'Billing and shipping addresses.', 'woocommerce-subscriptions-engine' ) ),
+		);
+
+		$schema_properties = array();
+		foreach ( $properties as $key => $property ) {
+			$schema_properties[ $key ] = array(
+				'description' => $property[1],
+				'type'        => $property[0],
+				'context'     => array( 'view' ),
+				'readonly'    => true,
+			);
+		}
+
 		$this->schema = array(
 			'$schema'    => 'http://json-schema.org/draft-04/schema#',
-			'title'      => 'subscription_engine_contract_action',
+			'title'      => 'subscription_engine_contract',
 			'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,
-				),
-			),
+			'properties' => $schema_properties,
 		);

 		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.
+	 * The contract, once the current user may read it (`read_subscription_contract`); resolved
+	 * once per request. Unknown and unreadable contracts are the same 404.
 	 *
-	 * A `DomainException` (an action whose preconditions the contract's current state does not meet) maps to
-	 * a 409 Conflict; any other failure maps to a 500. The ownership guard keeps the
-	 * asymmetric 404 for not-owned / unknown.
-	 *
-	 * @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
+	 * @param WP_REST_Request $request Request.
+	 * @return ContractView|WP_Error
 	 */
-	private function run_action( WP_REST_Request $request, callable $action ) {
-		$contract_id = Coercion::coerce_int( $request->get_param( 'id' ) );
-		$customer_id = get_current_user_id();
+	private function resolve_read( WP_REST_Request $request ) {
+		if ( ! isset( $this->resolved_reads[ $request ] ) ) {
+			$contract = Contracts::get( Coercion::coerce_int( $request->get_param( '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 === Contracts::get_for_customer( $contract_id, $customer_id ) ) {
-			return $this->not_found_error();
+			$this->resolved_reads[ $request ] = null !== $contract && current_user_can( ContractCapabilities::READ, $contract )
+				? $contract
+				: $this->get_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 )
-			);
+		return $this->resolved_reads[ $request ];
+	}
+
+	/**
+	 * The contract and the requested action, once the current user is permitted to run it;
+	 * resolved once per request. Unknown contract, wrong `extension_slug`, unknown action and
+	 * no permission are the same 404.
+	 *
+	 * @param WP_REST_Request $request Request.
+	 * @return array{contract: ContractView, definition: ContractActionDefinition}|WP_Error
+	 */
+	private function resolve_run( WP_REST_Request $request ) {
+		if ( ! isset( $this->resolved_runs[ $request ] ) ) {
+			try {
+				$this->resolved_runs[ $request ] = $this->get_permitted_run( $request );
+			} catch ( Throwable $e ) {
+				$this->resolved_runs[ $request ] = $this->get_action_failed_error( $e, $request );
+			}
 		}

-		// 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 = Contracts::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->resolved_runs[ $request ];
+	}
+
+	/**
+	 * Read the contract and the requested action of its owner, and check the user may run it.
+	 *
+	 * @param WP_REST_Request $request Request.
+	 * @return array{contract: ContractView, definition: ContractActionDefinition}|WP_Error
+	 */
+	private function get_permitted_run( WP_REST_Request $request ) {
+		$contract       = Contracts::get( Coercion::coerce_int( $request->get_param( 'id' ) ) );
+		$extension_slug = $request->get_param( 'extension_slug' );
+		$action         = $request->get_param( 'action' );
+		if ( null === $contract || ! is_string( $extension_slug ) || $contract->get_extension_slug() !== $extension_slug || ! is_string( $action ) ) {
+			return $this->get_not_found_error();
 		}

-		return $this->prepare_item_for_response( $refreshed, $request );
+		$definition = ContractActionRegistry::get( $extension_slug, $action );
+		if ( null === $definition || ! ContractActionRegistry::is_permitted( $definition, $contract, $request ) ) {
+			return $this->get_not_found_error();
+		}
+
+		return array(
+			'contract'   => $contract,
+			'definition' => $definition,
+		);
 	}

 	/**
-	 * The shared 404, identical for unknown and not-owned contracts.
+	 * The 404 for an unknown contract, also returned for a contract or action the caller may not see.
 	 */
-	private function not_found_error(): WP_Error {
+	private function get_not_found_error(): WP_Error {
 		return new WP_Error(
 			'woocommerce_subscriptions_engine_contract_not_found',
-			__( 'Subscription not found.', 'woocommerce-subscriptions-engine' ),
+			__( 'Contract not found.', 'woocommerce-subscriptions-engine' ),
 			array( 'status' => 404 )
 		);
 	}

 	/**
-	 * Route-level arg schema for the `{id}` path parameter.
+	 * Validate `action_args` against the action's property schemas: defaults filled in, unknown
+	 * keys dropped, a 400 when a value does not match.
 	 *
-	 * @return array<string, mixed>
+	 * @param WP_REST_Request                     $request    Request.
+	 * @param array<string, array<string, mixed>> $properties Property schemas.
+	 * @return array<string, mixed>|WP_Error
 	 */
-	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',
-			),
+	private function get_validated_action_args( WP_REST_Request $request, array $properties ) {
+		$defaults = array();
+		foreach ( $properties as $name => $property ) {
+			if ( array_key_exists( 'default', $property ) ) {
+				$defaults[ $name ] = $property['default'];
+			}
+		}
+
+		$request_args = $request->get_param( 'action_args' );
+		$action_args  = ( is_array( $request_args ) ? $request_args : array() ) + $defaults;
+		$schema       = array(
+			'type'       => 'object',
+			'properties' => $properties,
+		);
+
+		$valid = rest_validate_value_from_schema( $action_args, $schema, 'action_args' );
+		if ( $valid instanceof WP_Error ) {
+			return $this->get_invalid_action_args_error( $valid );
+		}
+
+		$sanitized = rest_sanitize_value_from_schema( $action_args, $schema + array( 'additionalProperties' => false ), 'action_args' );
+		if ( $sanitized instanceof WP_Error ) {
+			return $this->get_invalid_action_args_error( $sanitized );
+		}
+
+		return is_array( $sanitized ) ? Coercion::coerce_string_keyed( $sanitized ) : array();
+	}
+
+	/**
+	 * The 400 for `action_args` that do not match the schema, carrying the schema error's message.
+	 *
+	 * @param WP_Error $error Schema validation or sanitization error.
+	 */
+	private function get_invalid_action_args_error( WP_Error $error ): WP_Error {
+		return new WP_Error(
+			'woocommerce_subscriptions_engine_invalid_action_args',
+			$error->get_error_message(),
+			array(
+				'status' => 400,
+				'reason' => $error->get_error_code(),
+			)
+		);
+	}
+
+	/**
+	 * The resolved args schemas as discovery shows them: schema keywords and `required` only,
+	 * the way the WordPress REST index describes route args. An object, so no args encodes as `{}`.
+	 *
+	 * @param array<string, array<string, mixed>> $properties Property schemas.
+	 */
+	private function get_args_for_response( array $properties ): object {
+		$keywords = array_flip( rest_get_allowed_schema_keywords() );
+		$args     = array();
+		foreach ( $properties as $name => $property ) {
+			$args[ $name ]             = array_intersect_key( $property, $keywords );
+			$args[ $name ]['required'] = ! empty( $property['required'] );
+		}
+
+		return (object) $args;
+	}
+
+	/**
+	 * Log an extension callback failure and return a generic 500.
+	 *
+	 * @param Throwable       $e       Failure.
+	 * @param WP_REST_Request $request Request.
+	 */
+	private function get_action_failed_error( Throwable $e, WP_REST_Request $request ): WP_Error {
+		$contract_id = Coercion::coerce_int( $request->get_param( 'id' ) );
+		wc_get_logger()->error(
+			sprintf( 'ContractsController: contract action "%s" on contract %d failed: %s', Coercion::coerce_string( $request->get_param( 'action' ) ), $contract_id, $e->getMessage() ),
+			array(
+				'source'      => self::LOG_SOURCE,
+				'contract_id' => $contract_id,
+			)
+		);
+
+		return new WP_Error(
+			'woocommerce_subscriptions_engine_action_failed',
+			__( 'The action could not be completed.', 'woocommerce-subscriptions-engine' ),
+			array( 'status' => 500 )
 		);
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
index df5232ab987..8f0455ae92c 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
@@ -1,9 +1,9 @@
 <?php
 /**
- * Subscriptions - the engine's interim lifecycle and renewal facade.
+ * Subscriptions - the engine's interim renewal facade.
  *
- * Cancel, hold, reactivate, renew now, and read a contract's related orders. It hides
- * the internal `Core\` / `Integration\` collaborators behind a stable boundary.
+ * Renew now and read a contract's related orders. It hides the internal `Core\` /
+ * `Integration\` collaborators behind a stable boundary.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Api
  */
@@ -14,19 +14,15 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Api;

 use WC_Order;
 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;

 defined( 'ABSPATH' ) || exit;

 /**
  * Public subscriptions facade.
  *
- * Interim: removed once lifecycle flows and renewals move to extensions. Add no new
- * methods here; contract reads and writes live in {@see Contracts}.
+ * Interim: removed once renewals move to extensions. Add no new methods here;
+ * contract reads and writes live in {@see Contracts}.
  *
  * Final and static-only: a stateless entry point, not an extension seam.
  */
@@ -50,69 +46,6 @@ final class Subscriptions {
 		return ( new RelatedOrders() )->for_contract( $contract_id, $limit, $offset );
 	}

-	/**
-	 * Cancel a subscription contract.
-	 *
-	 * @param int $contract_id Contract id.
-	 * @return bool True when the contract was found and cancelled; false when not found.
-	 */
-	public static function cancel( int $contract_id ): bool {
-		$contract = ( new ContractRepository() )->find( $contract_id );
-		if ( null === $contract ) {
-			return false;
-		}
-
-		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/ValueObject/BillingPolicy.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
index 7fa2ee8e14b..d9a4eedbb92 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
@@ -4,9 +4,9 @@
  *
  * An extension may use {@see self::from_array()} to parse its plan billing arrays. The
  * engine stores plan policies opaquely and does not construct this on plan writes. It
- * does read one payload with it: renewal and reactivation parse a contract's plan
- * snapshot `billing_policy`, and the live plan's `billing_policy` when the contract has
- * no usable snapshot policy, both through {@see self::from_array()}, so a plan whose
+ * does read one payload with it: renewal parses a contract's plan snapshot
+ * `billing_policy`, and the live plan's `billing_policy` when the contract has no usable
+ * snapshot policy, both through {@see self::from_array()}, so a plan whose
  * contracts the engine renews must store this shape there. A policy always has a
  * usable cadence: construction refuses an unknown period or a non-positive interval.
  * The array
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
index 503d1bd20e7..6db119659ce 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
@@ -16,6 +16,7 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Integration;

 use Automattic\WooCommerce\SubscriptionsEngine\Api\Rest\ContractsController;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway\CapabilityRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ContractCapabilities;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\Rest\PlansController;
@@ -47,6 +48,7 @@ final class Bootstrap {
 		self::$initialized = true;

 		CapabilityRegistry::init();
+		ContractCapabilities::register_hooks();

 		// Register the callbacks that dispatch renewals back into the engine. Plain
 		// add_action calls, safe before Action Scheduler has loaded; must run on every
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
deleted file mode 100644
index ad7b0a3723e..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
+++ /dev/null
@@ -1,199 +0,0 @@
-<?php
-/**
- * Cancellation - cancel a subscription contract, immediately or at period end.
- *
- * 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). Both modes disarm the contract's next-due moment themselves: the batch
- * due scan keys on `next_payment_gmt` and a registered owner, so the flow stops renewals by
- * clearing its own due moment rather than relying on status. Their preconditions are
- * the flow's own, not rules of the status primitive. Lives under `Integration\Contracts`
- * so contract lifecycle stays separate from the renewal money-path.
- *
- * Interim: moves out of the engine with the lifecycle flows (hold / reactivate /
- * cancel and their routes).
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts
- */
-
-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\Core\Entity\CycleStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Cancel a contract.
- */
-final class Cancellation {
-
-	/**
-	 * Action fired after a contract is cancelled, with `( $contract )`.
-	 */
-	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.
-	 *
-	 * @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();
-	}
-
-	/**
-	 * Cancel `$contract`: move it to cancelled, disarm its next-due moment, and close any
-	 * mid-charge cycle.
-	 *
-	 * Only a draft, active, on-hold or pending-cancellation contract can be cancelled (a draft is
-	 * never armed, so there is no due moment to disarm); cancelling an
-	 * already cancelled contract is an idempotent no-op that still succeeds and fires the
-	 * action. Any other status - including one that is not registered - raises a
-	 * `DomainException`. The next-payment date and any hold anchor are cleared so the due scan
-	 * never selects the contract again. When the chain's most-recent cycle is still `pending`
-	 * (a charge caught mid-flight) it is transitioned `cancelled` so a stale claim is not left
-	 * open; a settled cycle is untouched.
-	 *
-	 * @param Contract $contract Contract to cancel. Must have an id.
-	 * @return bool True when the contract was cancelled and persisted.
-	 * @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();
-		if ( null === $id ) {
-			throw new RuntimeException( 'Cancellation::cancel(): cannot cancel a contract that has no id.' );
-		}
-
-		$previous   = $contract->get_status();
-		$cancelable = array( ContractStatus::DRAFT, ContractStatus::ACTIVE, ContractStatus::ON_HOLD, ContractStatus::PENDING_CANCELLATION, ContractStatus::CANCELLED );
-		if ( ! in_array( $previous, $cancelable, true ) ) {
-			throw new \DomainException( 'Cancellation::cancel(): only a draft, active, on-hold or pending-cancellation contract can be cancelled.' );
-		}
-
-		if ( ContractStatus::CANCELLED !== $previous ) {
-			$contract->set_status( ContractStatus::CANCELLED );
-			$contract->set_next_payment_gmt( null );
-		}
-
-		// 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.' );
-		}
-
-		if ( ContractStatus::CANCELLED !== $previous ) {
-			Hold::clear_anchor( $this->contracts, $id );
-		}
-
-		// Close a charge caught mid-flight: a still-pending head cycle is cancelled so no stale
-		// claim is left open. A settled (billed/failed/cancelled) cycle is left as is.
-		$current = $this->contracts->find_chain_head( $id );
-		if ( null !== $current && $current->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ) ) {
-			$current->set_status( new CycleStatus( CycleStatus::CANCELLED ) );
-			$this->contracts->update_cycle( $current );
-		}
-
-		/**
-		 * Fires after a contract is cancelled. Fires immediately after the write, not after a surrounding transaction commits.
-		 *
-		 * @param Contract $contract The cancelled contract.
-		 */
-		do_action( self::CONTRACT_CANCELLED_ACTION, $contract );
-
-		return true;
-	}
-
-	/**
-	 * Wind `$contract` down at the end of the current period: move it to
-	 * pending-cancellation, stamp the end date, and disarm its next-due moment.
-	 *
-	 * Only an active or on-hold contract can be wound down; winding down an already
-	 * pending-cancellation contract is an idempotent no-op that still succeeds and fires the
-	 * action. Any other status - including one that is not registered - raises a
-	 * `DomainException`. The contract keeps serving until the current period ends, so the
-	 * next-due moment (the next-payment date, or for a held contract the hold anchor) is
-	 * recorded as the contract `end_gmt` when not already set, for a first-class "cancels on"
-	 * date. The next-payment date and any hold anchor are then cleared, so no renewal fires
-	 * while the contract winds down.
-	 *
-	 * TODO: terminating a PENDING_CANCELLATION contract when its `end_gmt` arrives - moving
-	 * it to CANCELLED/EXPIRED at period end - is a follow-up slice. The contract now has no
-	 * next-due moment, so it stays PENDING_CANCELLATION (and is never charged) until a later
-	 * terminate-at-date pass ends it at its `end_gmt`.
-	 *
-	 * @param Contract $contract Contract to wind down. Must have an id, and be ACTIVE or ON_HOLD.
-	 * @return bool True when the contract was wound down and persisted.
-	 * @throws RuntimeException If the contract has no id.
-	 * @throws \DomainException If the contract cannot be wound down from its current state, or its state changed concurrently.
-	 */
-	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();
-		if ( ! in_array( $previous, array( ContractStatus::ACTIVE, ContractStatus::ON_HOLD, ContractStatus::PENDING_CANCELLATION ), true ) ) {
-			throw new \DomainException( 'Cancellation::cancel_at_period_end(): only an active or on-hold contract can be cancelled at period end.' );
-		}
-
-		if ( ContractStatus::PENDING_CANCELLATION !== $previous ) {
-			$contract->set_status( ContractStatus::PENDING_CANCELLATION );
-
-			// The end of the current period is the next-due moment: the contract is honoured
-			// up to (not through) it. A held contract's moment lives in the hold anchor.
-			$period_end = $contract->get_next_payment_gmt();
-			if ( null === $period_end && ContractStatus::ON_HOLD === $previous ) {
-				$period_end = Hold::read_anchor( $this->contracts, $id );
-			}
-			if ( null === $contract->get_end_gmt() && null !== $period_end ) {
-				$contract->set_end_gmt( $period_end );
-			}
-
-			$contract->set_next_payment_gmt( null );
-		}
-
-		// 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.' );
-		}
-
-		if ( ContractStatus::PENDING_CANCELLATION !== $previous ) {
-			Hold::clear_anchor( $this->contracts, $id );
-		}
-
-		/**
-		 * Fires after a contract is set to wind down at the end of the current period.
-		 * Fires immediately after the write, not after a surrounding transaction commits.
-		 *
-		 * @param Contract $contract The pending-cancellation contract.
-		 */
-		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
deleted file mode 100644
index 786b61dbd98..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php
+++ /dev/null
@@ -1,208 +0,0 @@
-<?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}: move the contract ACTIVE -> ON_HOLD, disarm its
- * next-due moment, and announce it. The batch due scan keys on `next_payment_gmt` and a
- * registered owner (its active-status predicate is a renewal-flow condition, see
- * {@see ContractRepository::find_due()}), so the flow disarms its own due moment rather
- * than relying on status to stop billing. The cleared moment is kept in contract meta
- * ({@see self::ANCHOR_META_KEY}) so {@see Reactivation} can recompute the schedule
- * forward from it. Its preconditions are its own, not a rule of the status primitive.
- *
- * Interim: moves out of the engine with the lifecycle flows (hold / reactivate /
- * cancel and their routes).
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts;
-
-use DateTimeImmutable;
-use DateTimeZone;
-use RuntimeException;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-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 meta key holding the next-due moment cleared by a hold - the moment
-	 * {@see Reactivation} recomputes forward from.
-	 *
-	 * Interim: moves out of the engine with the lifecycle flows (hold / reactivate /
-	 * cancel and their routes).
-	 */
-	public const ANCHOR_META_KEY = '_hold_next_payment_gmt';
-
-	/**
-	 * Contract repository.
-	 *
-	 * @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`: move it to on-hold and disarm its next-due moment.
-	 *
-	 * Only an active contract can be held; holding an already on-hold contract is an
-	 * idempotent no-op that still succeeds and fires the action (nothing is rewritten,
-	 * so the stored anchor survives). Any other status - including one that is not
-	 * registered - raises a `DomainException`. The current cycle is immutable and is
-	 * NOT touched.
-	 *
-	 * @param Contract $contract Contract to hold. Must have an id, and be ACTIVE (or already ON_HOLD).
-	 * @return bool True when the contract was held and persisted.
-	 * @throws RuntimeException If the contract has no id.
-	 * @throws \DomainException If the contract cannot be held from its current state, or its state changed concurrently.
-	 */
-	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();
-		if ( ContractStatus::ACTIVE !== $previous && ContractStatus::ON_HOLD !== $previous ) {
-			throw new \DomainException( 'Hold::hold(): only an active contract can be held.' );
-		}
-
-		if ( ContractStatus::ACTIVE === $previous ) {
-			$this->persist_anchor( $contract );
-
-			$contract->set_status( ContractStatus::ON_HOLD );
-			$contract->set_next_payment_gmt( null );
-		}
-
-		// Compare-and-set on the status read above: a concurrent transition (another
-		// request, the renewal engine) makes this write miss loudly rather than be
-		// clobbered. The anchor is already stored, so a reader that sees the contract
-		// on hold always finds it.
-		if ( ! $this->contracts->update_if_status( $contract, $previous ) ) {
-			throw new \DomainException( 'Hold::hold(): the contract state changed concurrently; nothing was written.' );
-		}
-
-		/**
-		 * Fires after a contract is put on hold. Fires immediately after the write, not after a surrounding transaction commits.
-		 *
-		 * @param Contract $contract The held contract.
-		 */
-		do_action( self::CONTRACT_HELD_ACTION, $contract );
-
-		return true;
-	}
-
-	/**
-	 * Store the next-due moment as the hold anchor before the hold disarms it.
-	 *
-	 * The anchor lives in contract meta, written apart from the row (no transaction),
-	 * so it is written and read back first: if it did not persist, nothing has been
-	 * disarmed yet and the hold aborts. A contract with no next-due moment stores no
-	 * anchor. An anchor left behind by a hold that then loses its compare-and-set is
-	 * harmless: the next hold overwrites it and cancellation clears it.
-	 *
-	 * @param Contract $contract Active contract about to be held. Must have an id.
-	 * @throws RuntimeException If the anchor could not be stored.
-	 */
-	private function persist_anchor( Contract $contract ): void {
-		$id               = (int) $contract->get_id();
-		$next_payment_gmt = $contract->get_next_payment_gmt();
-
-		try {
-			if ( null === $next_payment_gmt ) {
-				$this->contracts->delete_meta( $id, self::ANCHOR_META_KEY );
-			} else {
-				$this->contracts->update_meta( $id, self::ANCHOR_META_KEY, $next_payment_gmt );
-			}
-		} catch ( RuntimeException $e ) {
-			throw new RuntimeException( 'Hold::hold(): the hold anchor could not be stored; the contract was not held.', 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the previous exception is not output.
-		}
-
-		$stored = $this->contracts->get_meta( $id, self::ANCHOR_META_KEY, true );
-		$anchor = '' === $stored ? null : $stored;
-		if ( $anchor !== $next_payment_gmt ) {
-			throw new RuntimeException( 'Hold::hold(): the hold anchor could not be stored; the contract was not held.' );
-		}
-	}
-
-	/**
-	 * Clear a contract's hold anchor after a status write has committed.
-	 *
-	 * Best effort: a failed delete is logged and swallowed, so the caller still finishes
-	 * the transition it already wrote (cycle close, lifecycle action). A leftover anchor
-	 * is harmless: the next hold overwrites it and only an on-hold contract reads it.
-	 *
-	 * @param ContractRepository $contracts   Contract repository.
-	 * @param int                $contract_id Contract id.
-	 */
-	public static function clear_anchor( ContractRepository $contracts, int $contract_id ): void {
-		try {
-			$contracts->delete_meta( $contract_id, self::ANCHOR_META_KEY );
-		} catch ( RuntimeException $e ) {
-			wc_get_logger()->warning(
-				sprintf( 'Hold: the hold anchor of contract %d could not be cleared: %s', $contract_id, $e->getMessage() ),
-				array(
-					'source'      => 'woocommerce-subscriptions-engine',
-					'contract_id' => $contract_id,
-				)
-			);
-		}
-	}
-
-	/**
-	 * The hold anchor stored for a contract, or null when there is none.
-	 *
-	 * The one reader of {@see self::ANCHOR_META_KEY}: a value that is not a well-formed
-	 * GMT datetime (`Y-m-d H:i:s`) counts as absent and is logged, since a flow resuming
-	 * or ending from it would otherwise act on garbage.
-	 *
-	 * @param ContractRepository $contracts   Contract repository.
-	 * @param int                $contract_id Contract id.
-	 */
-	public static function read_anchor( ContractRepository $contracts, int $contract_id ): ?string {
-		$anchor = $contracts->get_meta( $contract_id, self::ANCHOR_META_KEY, true );
-		if ( '' === $anchor || null === $anchor ) {
-			return null;
-		}
-
-		if ( is_string( $anchor ) ) {
-			$parsed = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $anchor, new DateTimeZone( 'UTC' ) );
-			if ( false !== $parsed && $parsed->format( 'Y-m-d H:i:s' ) === $anchor ) {
-				return $anchor;
-			}
-		}
-
-		wc_get_logger()->warning(
-			sprintf( 'Hold: contract %d has a malformed hold anchor; it is ignored.', $contract_id ),
-			array(
-				'source'      => 'woocommerce-subscriptions-engine',
-				'contract_id' => $contract_id,
-			)
-		);
-
-		return null;
-	}
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
deleted file mode 100644
index 3200a85e860..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
+++ /dev/null
@@ -1,292 +0,0 @@
-<?php
-/**
- * Reactivation - resume a held subscription contract (resume billing).
- *
- * A focused contract-management operation (deliberately not a catch-all manager),
- * mirroring {@see Cancellation}: move the contract ON_HOLD -> ACTIVE, re-arm its
- * next-due moment forward from the hold anchor, and announce it. Writing the forward
- * next-payment date is the re-arm (the batch due scan, keyed on `next_payment_gmt` and a
- * registered owner, picks the contract up at that date), not the status change alone.
- * Lives under `Integration\Contracts` so contract lifecycle stays separate from the
- * renewal money-path. Its preconditions are its own, not a rule of the status primitive.
- *
- * Interim: moves out of the engine with the lifecycle flows (hold / reactivate /
- * cancel and their routes).
- *
- * `$now` is read at this integration boundary (or injected for tests) and the cadence
- * math is delegated to the clock-free {@see RenewalCalculator}, so the engine keeps a
- * 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\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`: move it to active, re-arm the next-payment date forward,
-	 * and persist.
-	 *
-	 * The anchor the date is recomputed from is the stored `next_payment_gmt` when one is
-	 * set (hold clears it, so a value means it was re-armed deliberately, or the contract
-	 * was held before hold disarmed it), else the next-due moment stashed by {@see Hold}
-	 * ({@see Hold::ANCHOR_META_KEY}), read through {@see Hold::read_anchor()}, which logs
-	 * and ignores a malformed value. The
-	 * anchor meta is removed. The date is recomputed through the single seam
-	 * ({@see self::recompute_next_payment()}) so a contract that sat on hold past its due
-	 * date does not fire an immediate, back-dated renewal the moment it resumes; with no
-	 * anchor the contract simply stays unscheduled.
-	 *
-	 * @param Contract               $contract Contract to reactivate. Must have an id, and be ON_HOLD.
-	 * @param DateTimeImmutable|null $now      The current moment; read from the wall clock (UTC) when omitted.
-	 * @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. In particular an already-ACTIVE contract must
-		// not reach the recompute below: rolling a past-due active contract's next-payment
-		// date forward would skip the charge the due scan owes it.
-		if ( ContractStatus::ON_HOLD !== $contract->get_status() ) {
-			throw new DomainException( 'Reactivation::reactivate(): only an on-hold contract can be reactivated.' );
-		}
-
-		// Read the clock at the integration boundary so the Core cadence math stays clock-free.
-		$now = ( $now ?? new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->setTimezone( new DateTimeZone( 'UTC' ) );
-
-		// A next-due moment set while held was re-armed deliberately (hold clears it), so
-		// it wins; otherwise resume from the hold anchor, ignoring a malformed one.
-		$anchor = $contract->get_next_payment_gmt() ?? Hold::read_anchor( $this->contracts, $id );
-
-		$contract->set_next_payment_gmt( $this->recompute_next_payment( $contract, $anchor, $now, $this->billing_policy( $contract ) ) );
-		$contract->set_status( ContractStatus::ACTIVE );
-
-		// 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.' );
-		}
-
-		Hold::clear_anchor( $this->contracts, $id );
-
-		/**
-		 * Fires after a held contract is reactivated: its renewal is re-armed, or left
-		 * unscheduled when there was no next-due moment to resume from. Fires immediately after the write, not after a surrounding transaction commits.
-		 *
-		 * @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 anchor date is kept as-is - resuming before the date arrives changes
-	 *    nothing.
-	 *  - A past-due date (the contract sat on hold past it) is rolled forward by whole
-	 *    billing cadences (via {@see RenewalCalculator::next_bill_date()}) until it is in
-	 *    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 anchor (no scheduled next payment when held) stays unscheduled.
-	 *
-	 * Models 2 (resume immediately and charge for the held period) and 3 (extend the end
-	 * date by the held duration) are deliberately NOT implemented - do not add them here
-	 * until the product decision lands.
-	 *
-	 * @param Contract           $contract The contract being reactivated (for the log line).
-	 * @param string|null        $anchor   The GMT next-due moment to recompute from, or null.
-	 * @param DateTimeImmutable  $now      The current moment (UTC; injected at the boundary).
-	 * @param BillingPolicy|null $policy   The plan billing policy for the forward roll, or null.
-	 * @return string|null The recomputed next-payment GMT string, or null when unscheduled.
-	 */
-	private function recompute_next_payment( Contract $contract, ?string $anchor, DateTimeImmutable $now, ?BillingPolicy $policy ): ?string {
-		if ( null === $anchor ) {
-			return null;
-		}
-
-		$next = new DateTimeImmutable( $anchor, 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
-	 * (parsing its billing payload) when the contract has no snapshot or its snapshot
-	 * policy does not parse or has no usable cadence (logged), and null when neither
-	 * resolves (a live plan with a null or unusable billing payload is logged too). Both sources are read with the renewal rule
-	 * ({@see BillingPolicy::from_array()}), so the forward roll never
-	 * throws on a stored payload.
-	 *
-	 * @param Contract $contract The contract.
-	 */
-	private function billing_policy( Contract $contract ): ?BillingPolicy {
-		$snapshot = $contract->get_plan_snapshot();
-		if ( null !== $snapshot ) {
-			try {
-				$policy = $snapshot->read_billing_policy();
-				if ( null !== $policy ) {
-					return $policy;
-				}
-			} catch ( DomainException $e ) {
-				wc_get_logger()->warning(
-					sprintf( 'Reactivation: contract %d has an unreadable plan-snapshot billing policy; falling back to the live plan. %s', (int) $contract->get_id(), $e->getMessage() ),
-					array(
-						'source'      => self::LOG_SOURCE,
-						'contract_id' => (int) $contract->get_id(),
-					)
-				);
-			}
-		}
-
-		$plan_id = $contract->get_selling_plan_id();
-		if ( null === $plan_id ) {
-			return null;
-		}
-
-		$plan = $this->plans->find( $plan_id );
-		if ( null === $plan ) {
-			return null;
-		}
-
-		$billing = $plan->get_billing_policy();
-		if ( null === $billing ) {
-			wc_get_logger()->warning(
-				sprintf( 'Reactivation: contract %d has a live plan %d with no billing policy; a past-due next payment is floored at now.', (int) $contract->get_id(), (int) $plan_id ),
-				array(
-					'source'      => self::LOG_SOURCE,
-					'contract_id' => (int) $contract->get_id(),
-					'plan_id'     => (int) $plan_id,
-				)
-			);
-
-			return null;
-		}
-
-		try {
-			return BillingPolicy::from_array( $billing );
-		} catch ( DomainException $e ) {
-			wc_get_logger()->warning(
-				sprintf( 'Reactivation: contract %d has an unreadable live plan billing policy; a past-due next payment is floored at now. %s', (int) $contract->get_id(), $e->getMessage() ),
-				array(
-					'source'      => self::LOG_SOURCE,
-					'contract_id' => (int) $contract->get_id(),
-					'plan_id'     => (int) $plan_id,
-				)
-			);
-
-			return null;
-		}
-	}
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ContractCapabilities.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ContractCapabilities.php
new file mode 100644
index 00000000000..ddfe813d0ae
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ContractCapabilities.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * ContractCapabilities - the `read_subscription_contract` and `manage_subscription_contract` meta capabilities.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Maps the contract capabilities: the contract's customer needs `read`, anyone else
+ * `manage_woocommerce`. Checked as `current_user_can( $capability, $contract )` with a
+ * `ContractView`; anything else is refused. The mapping runs first (priority 0), so extensions
+ * adjust each capability with `map_meta_cap` at the default priority, or with `user_has_cap`.
+ */
+final class ContractCapabilities {
+
+	/**
+	 * Read one contract and the actions available for it.
+	 */
+	public const READ = 'read_subscription_contract';
+
+	/**
+	 * Manage one contract: run actions on it.
+	 */
+	public const MANAGE = 'manage_subscription_contract';
+
+	/**
+	 * Hook the mapping ahead of other `map_meta_cap` filters, so theirs build on it.
+	 */
+	public static function register_hooks(): void {
+		add_filter( 'map_meta_cap', array( self::class, 'map_meta_cap' ), 0, 4 );
+	}
+
+	/**
+	 * Map the meta capability to the primitive capabilities the user needs.
+	 *
+	 * @param mixed $caps    Primitive capabilities so far.
+	 * @param mixed $cap     Capability being checked.
+	 * @param mixed $user_id User id.
+	 * @param mixed $args    Extra `current_user_can()` arguments; the first is the contract.
+	 * @return mixed
+	 */
+	public static function map_meta_cap( $caps, $cap, $user_id, $args ) {
+		if ( self::READ !== $cap && self::MANAGE !== $cap ) {
+			return $caps;
+		}
+
+		$contract = is_array( $args ) ? ( $args[0] ?? null ) : null;
+		if ( ! $contract instanceof ContractView ) {
+			return array( 'do_not_allow' );
+		}
+
+		$customer_id = $contract->get_customer_id();
+		if ( null !== $customer_id && $customer_id > 0 && Coercion::coerce_int( $user_id ) === $customer_id ) {
+			return array( 'read' );
+		}
+
+		return array( 'manage_woocommerce' );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Rest/ContractActionRegistry.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Rest/ContractActionRegistry.php
new file mode 100644
index 00000000000..d1ef6131512
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Rest/ContractActionRegistry.php
@@ -0,0 +1,156 @@
+<?php
+/**
+ * ContractActionRegistry - the contract actions extensions register for the action endpoint.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Rest
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Rest;
+
+use UnexpectedValueException;
+use WP_REST_Request;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registered contract actions, keyed by extension slug then action, and their per-request
+ * resolution (permission, availability, args schema) against a contract.
+ *
+ * @internal Written through {@see \Automattic\WooCommerce\SubscriptionsEngine\Api\ContractActions::register()} only, which validates every definition.
+ *
+ * @phpstan-type ContractActionDefinition array{extension_slug: string, action: string, callback: callable, permission: string|callable, description: string, args: array<string, array<string, mixed>>|callable, is_available: callable|null}
+ */
+final class ContractActionRegistry {
+
+	/**
+	 * Registered actions keyed by extension slug, then action.
+	 *
+	 * @var array<string, array<string, ContractActionDefinition>>
+	 */
+	private static $actions = array();
+
+	/**
+	 * Store a validated action definition.
+	 *
+	 * @param array $definition Action definition.
+	 * @phpstan-param ContractActionDefinition $definition
+	 */
+	public static function add( array $definition ): void {
+		self::$actions[ $definition['extension_slug'] ][ $definition['action'] ] = $definition;
+	}
+
+	/**
+	 * Whether the extension registered the action.
+	 *
+	 * @param string $extension_slug Extension slug.
+	 * @param string $action         Action slug.
+	 */
+	public static function has( string $extension_slug, string $action ): bool {
+		return isset( self::$actions[ $extension_slug ][ $action ] );
+	}
+
+	/**
+	 * The extension's action definition, or null when not registered.
+	 *
+	 * @param string $extension_slug Extension slug.
+	 * @param string $action         Action slug.
+	 * @return ContractActionDefinition|null
+	 */
+	public static function get( string $extension_slug, string $action ): ?array {
+		return self::$actions[ $extension_slug ][ $action ] ?? null;
+	}
+
+	/**
+	 * The extension's action definitions, in registration order.
+	 *
+	 * @param string $extension_slug Extension slug.
+	 * @return array<int, ContractActionDefinition>
+	 */
+	public static function get_for_extension( string $extension_slug ): array {
+		return array_values( self::$actions[ $extension_slug ] ?? array() );
+	}
+
+	/**
+	 * Whether the current user may run the action on the contract.
+	 *
+	 * @param array           $definition Action definition.
+	 * @param ContractView    $contract   Contract.
+	 * @param WP_REST_Request $request    Request.
+	 * @phpstan-param ContractActionDefinition $definition
+	 */
+	public static function is_permitted( array $definition, ContractView $contract, WP_REST_Request $request ): bool {
+		$permission = $definition['permission'];
+		if ( is_string( $permission ) ) {
+			// phpcs:ignore WordPress.WP.Capabilities.Undetermined -- the extension names the capability.
+			return current_user_can( $permission, $contract );
+		}
+
+		return true === $permission( $contract, $request );
+	}
+
+	/**
+	 * Whether the action is available for the contract now; actions without `is_available` always are.
+	 *
+	 * @param array        $definition Action definition.
+	 * @param ContractView $contract   Contract.
+	 * @phpstan-param ContractActionDefinition $definition
+	 */
+	public static function is_available( array $definition, ContractView $contract ): bool {
+		return null === $definition['is_available'] || true === ( $definition['is_available'] )( $contract );
+	}
+
+	/**
+	 * The `action_args` property schemas for the contract, resolving a callable `args`.
+	 *
+	 * @param array        $definition Action definition.
+	 * @param ContractView $contract   Contract.
+	 * @phpstan-param ContractActionDefinition $definition
+	 * @return array<string, array<string, mixed>>
+	 * @throws UnexpectedValueException If a callable `args` returns something other than an array of schemas.
+	 */
+	public static function get_args_schema( array $definition, ContractView $contract ): array {
+		$args = $definition['args'];
+		if ( ! is_callable( $args ) ) {
+			return $args;
+		}
+
+		$resolved_args = $args( $contract );
+		if ( ! self::is_args_schema( $resolved_args ) ) {
+			throw new UnexpectedValueException( sprintf( 'The "args" callback of contract action "%s" must return an array of property schemas.', esc_html( $definition['action'] ) ) );
+		}
+
+		return $resolved_args;
+	}
+
+	/**
+	 * Whether the value is an `args` schema map: string property names to schema arrays.
+	 *
+	 * @param mixed $value Value.
+	 * @phpstan-assert-if-true array<string, array<string, mixed>> $value
+	 */
+	public static function is_args_schema( $value ): bool {
+		if ( ! is_array( $value ) ) {
+			return false;
+		}
+
+		foreach ( $value as $name => $schema ) {
+			if ( ! is_string( $name ) || ! is_array( $schema ) ) {
+				return false;
+			}
+		}
+
+		return true;
+	}
+
+	/**
+	 * Clear every registration.
+	 *
+	 * @internal Public only so tests can isolate per-test state.
+	 */
+	public static function reset(): void {
+		self::$actions = array();
+	}
+}
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 a4358b88b20..91b34e0d915 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
@@ -193,10 +193,10 @@ final class ContractRepository {
 	 * `$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
+	 * The renewal engine's schedule advances read-validate-write the contract row while an
+	 * extension may change its status (a customer cancel); unconditioned, the slower writer
+	 * silently clobbers the faster one - a 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.
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 1e81dd6cadd..0c1b189e8c2 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/RESTPermissions.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/RESTPermissions.php
@@ -19,10 +19,9 @@ class RESTPermissions {
 	/**
 	 * Require a logged in user.
 	 *
-	 * 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.
+	 * The authentication floor under {@see self::require_admin_permission()}. 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.
 	 */
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractActionsTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractActionsTest.php
new file mode 100644
index 00000000000..7087fede5c7
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractActionsTest.php
@@ -0,0 +1,233 @@
+<?php
+/**
+ * Integration tests for the contract actions registration facade.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api;
+
+use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractActions;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Rest\ContractActionRegistry;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\ContractActions
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Rest\ContractActionRegistry
+ */
+class ContractActionsTest extends EngineIntegrationTestCase {
+
+	private const EXTENSION_SLUG = 'test-extension';
+
+	private const REGISTER = 'Automattic\WooCommerce\SubscriptionsEngine\Api\ContractActions::register';
+
+	public function set_up(): void {
+		parent::set_up();
+		ContractActionRegistry::reset();
+	}
+
+	public function tear_down(): void {
+		ContractActionRegistry::reset();
+		parent::tear_down();
+	}
+
+	/**
+	 * A valid registration is stored with its defaults.
+	 */
+	public function test_registers_an_action_with_defaults(): void {
+		$callback = array( $this, 'return_contract' );
+
+		ContractActions::register(
+			self::EXTENSION_SLUG,
+			'pause',
+			array(
+				'callback'   => $callback,
+				'permission' => 'manage_subscription_contract',
+			)
+		);
+
+		$definition = ContractActionRegistry::get( self::EXTENSION_SLUG, 'pause' );
+		$this->assertNotNull( $definition );
+		$this->assertSame( self::EXTENSION_SLUG, $definition['extension_slug'] );
+		$this->assertSame( 'pause', $definition['action'] );
+		$this->assertSame( $callback, $definition['callback'] );
+		$this->assertSame( 'manage_subscription_contract', $definition['permission'] );
+		$this->assertSame( '', $definition['description'] );
+		$this->assertSame( array(), $definition['args'] );
+		$this->assertNull( $definition['is_available'] );
+	}
+
+	/**
+	 * Actions are scoped to the registering extension.
+	 */
+	public function test_actions_are_scoped_to_the_extension(): void {
+		ContractActions::register( self::EXTENSION_SLUG, 'pause', $this->valid_args() );
+		ContractActions::register( 'other-extension', 'pause', $this->valid_args() );
+		ContractActions::register( self::EXTENSION_SLUG, 'resume', $this->valid_args() );
+
+		$this->assertSame(
+			array( 'pause', 'resume' ),
+			array_column( ContractActionRegistry::get_for_extension( self::EXTENSION_SLUG ), 'action' )
+		);
+		$this->assertCount( 1, ContractActionRegistry::get_for_extension( 'other-extension' ) );
+		$this->assertSame( array(), ContractActionRegistry::get_for_extension( 'unknown' ) );
+	}
+
+	/**
+	 * A duplicate registration raises a notice and keeps the first one.
+	 */
+	public function test_duplicate_registration_keeps_the_first(): void {
+		$this->setExpectedIncorrectUsage( self::REGISTER );
+
+		ContractActions::register( self::EXTENSION_SLUG, 'pause', $this->valid_args() + array( 'description' => 'First' ) );
+		ContractActions::register( self::EXTENSION_SLUG, 'pause', $this->valid_args() + array( 'description' => 'Second' ) );
+
+		$definition = ContractActionRegistry::get( self::EXTENSION_SLUG, 'pause' );
+		$this->assertNotNull( $definition );
+		$this->assertSame( 'First', $definition['description'] );
+	}
+
+	/**
+	 * Unknown keys raise a notice and are ignored; the action still registers.
+	 */
+	public function test_unknown_keys_are_ignored(): void {
+		$this->setExpectedIncorrectUsage( self::REGISTER );
+
+		ContractActions::register( self::EXTENSION_SLUG, 'pause', $this->valid_args() + array( 'label' => 'Pause' ) );
+
+		$this->assertTrue( ContractActionRegistry::has( self::EXTENSION_SLUG, 'pause' ) );
+	}
+
+	/**
+	 * Invalid registrations raise a notice and are not registered.
+	 *
+	 * @dataProvider invalid_registrations
+	 *
+	 * @param string               $extension_slug Extension slug.
+	 * @param string               $action         Action slug.
+	 * @param array<string, mixed> $overrides      Registration args to replace.
+	 */
+	public function test_invalid_registration_is_rejected( string $extension_slug, string $action, array $overrides ): void {
+		$this->setExpectedIncorrectUsage( self::REGISTER );
+
+		// A null override removes the key.
+		$args = array_filter(
+			$overrides + $this->valid_args(),
+			static function ( $value ): bool {
+				return null !== $value;
+			}
+		);
+
+		ContractActions::register( $extension_slug, $action, $args );
+
+		$this->assertSame( array(), ContractActionRegistry::get_for_extension( $extension_slug ) );
+	}
+
+	/**
+	 * Invalid registration cases.
+	 *
+	 * @return array<string, array{0: string, 1: string, 2: array<string, mixed>}>
+	 */
+	public function invalid_registrations(): array {
+		return array(
+			'empty extension slug'      => array( ' ', 'pause', array() ),
+			'action with capitals'      => array( self::EXTENSION_SLUG, 'Pause', array() ),
+			'action with a slash'       => array( self::EXTENSION_SLUG, 'pause/now', array() ),
+			'action with a newline'     => array( self::EXTENSION_SLUG, "pause\n", array() ),
+			'empty action'              => array( self::EXTENSION_SLUG, '', array() ),
+			'missing callback'          => array( self::EXTENSION_SLUG, 'pause', array( 'callback' => null ) ),
+			'non-callable callback'     => array( self::EXTENSION_SLUG, 'pause', array( 'callback' => 'not_a_function_anywhere' ) ),
+			'missing permission'        => array( self::EXTENSION_SLUG, 'pause', array( 'permission' => null ) ),
+			'empty permission'          => array( self::EXTENSION_SLUG, 'pause', array( 'permission' => ' ' ) ),
+			'non-callable permission'   => array( self::EXTENSION_SLUG, 'pause', array( 'permission' => 42 ) ),
+			'non-string description'    => array( self::EXTENSION_SLUG, 'pause', array( 'description' => 5 ) ),
+			'args not a schema map'     => array( self::EXTENSION_SLUG, 'pause', array( 'args' => array( 'type' => 'boolean' ) ) ),
+			'args a list'               => array( self::EXTENSION_SLUG, 'pause', array( 'args' => array( array( 'type' => 'boolean' ) ) ) ),
+			'non-callable is_available' => array( self::EXTENSION_SLUG, 'pause', array( 'is_available' => true ) ),
+		);
+	}
+
+	/**
+	 * Callable `args` resolve per contract; a non-array result is an error.
+	 */
+	public function test_callable_args_resolve_per_contract(): void {
+		ContractActions::register(
+			self::EXTENSION_SLUG,
+			'pause',
+			$this->valid_args() + array(
+				'args' => static function ( ContractView $contract ): array {
+					return array(
+						'reason' => array(
+							'type'        => 'string',
+							'description' => $contract->get_status(),
+						),
+					);
+				},
+			)
+		);
+		ContractActions::register(
+			self::EXTENSION_SLUG,
+			'broken',
+			$this->valid_args() + array(
+				'args' => static function (): string {
+					return 'nope';
+				},
+			)
+		);
+		$contract = $this->create_contract();
+
+		$pause = ContractActionRegistry::get( self::EXTENSION_SLUG, 'pause' );
+		$this->assertNotNull( $pause );
+		$this->assertSame(
+			array(
+				'reason' => array(
+					'type'        => 'string',
+					'description' => 'active',
+				),
+			),
+			ContractActionRegistry::get_args_schema( $pause, $contract )
+		);
+
+		$broken = ContractActionRegistry::get( self::EXTENSION_SLUG, 'broken' );
+		$this->assertNotNull( $broken );
+		$this->expectException( \UnexpectedValueException::class );
+		ContractActionRegistry::get_args_schema( $broken, $contract );
+	}
+
+	/**
+	 * Valid registration args.
+	 *
+	 * @return array<string, mixed>
+	 */
+	private function valid_args(): array {
+		return array(
+			'callback'   => array( $this, 'return_contract' ),
+			'permission' => 'manage_woocommerce',
+		);
+	}
+
+	/**
+	 * An active contract owned by the test extension.
+	 */
+	private function create_contract(): ContractView {
+		return Contracts::create(
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'status'         => 'active',
+			)
+		);
+	}
+
+	/**
+	 * Action callback that returns the contract unchanged.
+	 *
+	 * @param ContractView $contract Contract.
+	 */
+	public function return_contract( ContractView $contract ): ContractView {
+		return $contract;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php
index 89c629be8f6..42b1aec01cd 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php
@@ -1,9 +1,6 @@
 <?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
- * precondition 409.
+ * Integration tests for the contracts REST controller.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine
  */
@@ -13,12 +10,15 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api\Rest;

 use EngineIntegrationTestCase;
+use RuntimeException;
+use WP_Error;
 use WP_REST_Request;
 use WP_REST_Response;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractActions;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
 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;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Rest\ContractActionRegistry;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\Rest\ContractsController
@@ -27,235 +27,697 @@ class ContractsControllerTest extends EngineIntegrationTestCase {

 	private const BASE = '/wc/v3/subscriptions-engine/contracts';

-	/**
-	 * @var ContractRepository
-	 */
-	private $contracts;
-
-	/**
-	 * @var int
-	 */
-	private $owner_id;
+	private const EXTENSION_SLUG = 'test-extension';

 	/**
-	 * @var int
+	 * Action callback calls, as `array( action, contract id, action_args )`.
+	 *
+	 * @var array<int, array{0: string, 1: int, 2: array<string, mixed>}>
 	 */
-	private $other_id;
+	private $calls = array();

 	public function set_up(): void {
 		parent::set_up();

-		$this->contracts = new ContractRepository();
+		ContractActionRegistry::reset();
+		$this->calls = array();

-		// 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();
+		// A fresh server fires `rest_api_init`, so the routes come from the engine's own wiring.
+		$GLOBALS['wp_rest_server'] = null;
+		rest_get_server();
 	}

 	public function tear_down(): void {
+		ContractActionRegistry::reset();
+		$GLOBALS['wp_rest_server'] = null;
 		wp_set_current_user( 0 );
 		parent::tear_down();
 	}

 	/**
-	 * Create a customer user and return its id.
+	 * A store manager reads the stored contract facts, children included.
 	 */
-	private function create_customer(): int {
-		$user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
-		$this->assertIsInt( $user_id );
+	public function test_get_returns_the_contract_to_a_store_manager(): void {
+		$contract = Contracts::create(
+			array(
+				'extension_slug'   => 'test-extension',
+				'status'           => 'active',
+				'customer_id'      => 7,
+				'currency'         => 'EUR',
+				'next_payment_gmt' => '2026-11-01 10:00:00',
+				'billing_total'    => '20.00',
+				'items'            => array(
+					array(
+						'item_name'  => 'Coffee',
+						'product_id' => 9,
+						'quantity'   => '2',
+						'total'      => '20',
+					),
+				),
+				'addresses'        => array(
+					'billing' => array(
+						'first_name' => 'Ada',
+						'country'    => 'PT',
+					),
+				),
+			)
+		);
+		wp_set_current_user( $this->create_user( 'administrator' ) );

-		return $user_id;
+		$response = $this->get( $contract->get_id() );
+
+		$this->assertSame( 200, $response->get_status() );
+		$data = $this->response_data( $response );
+		$this->assertSame( $contract->get_id(), $data['id'] );
+		$this->assertSame( 'test-extension', $data['extension_slug'] );
+		$this->assertSame( 'active', $data['status'] );
+		$this->assertSame( 7, $data['customer_id'] );
+		$this->assertSame( 'EUR', $data['currency'] );
+		$this->assertSame( '2026-11-01T10:00:00', $data['next_payment_gmt'] );
+		$this->assertNull( $data['end_gmt'] );
+		$stored = Contracts::get( $contract->get_id() );
+		$this->assertNotNull( $stored );
+		$this->assertSame( $stored->get_billing_total(), $data['billing_total'] );
+		$this->assertSame( $stored->get_items(), $data['items'] );
+		$this->assertSame( $stored->get_addresses(), $data['addresses'] );
+		$this->assertNotEmpty( $stored->get_items() );
+		$this->assertNotEmpty( $stored->get_addresses() );
 	}

 	/**
-	 * Seed a contract for a customer.
-	 *
-	 * @param int    $customer_id Owning customer.
-	 * @param string $status      Status.
+	 * A contract without addresses encodes them as an empty JSON object.
 	 */
-	private function seed( int $customer_id, string $status = ContractStatus::ACTIVE ): int {
-		$contract = Contract::create(
+	public function test_get_encodes_no_addresses_as_an_object(): void {
+		$contract = Contracts::create(
 			array(
-				'extension_slug'       => 'engine-tests',
-				'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',
+				'extension_slug' => 'test-extension',
+				'status'         => 'active',
+				'customer_id'    => 7,
+				'currency'       => 'EUR',
 			)
 		);
+		wp_set_current_user( $this->create_user( 'administrator' ) );

-		return $this->contracts->insert( $contract );
+		$response = $this->get( $contract->get_id() );
+
+		$this->assertStringContainsString( '"addresses":{}', (string) wp_json_encode( $response->get_data() ) );
 	}

-	public function test_anonymous_request_is_unauthorized(): void {
-		wp_set_current_user( 0 );
-		$id = $this->seed( $this->owner_id );
+	/**
+	 * An unknown id is a 404.
+	 */
+	public function test_get_unknown_contract_is_not_found(): void {
+		wp_set_current_user( $this->create_user( 'administrator' ) );

-		$response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/hold' ) );
+		$response = $this->get( 999999 );

-		$this->assertSame( 401, $response->get_status() );
-		// The contract is untouched.
-		$this->assertSame( ContractStatus::ACTIVE, $this->reload( $id )->get_status() );
+		$this->assertSame( 404, $response->get_status() );
+		$this->assertSame( 'woocommerce_subscriptions_engine_contract_not_found', $this->response_data( $response )['code'] );
 	}

-	public function test_unknown_contract_is_not_found_indistinguishably_from_foreign(): void {
-		wp_set_current_user( $this->other_id );
+	/**
+	 * The contract's customer reads it; guests get a 401 and other customers the same 404 as an
+	 * unknown contract.
+	 */
+	public function test_get_needs_read_subscription_contract(): void {
+		$customer_id = $this->create_user( 'customer' );
+		$contract    = $this->create_contract( $customer_id );
+
+		$this->assertSame( 401, $this->get( $contract->get_id() )->get_status() );

-		$response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/4242424/hold' ) );
+		wp_set_current_user( $customer_id );
+		$this->assertSame( 200, $this->get( $contract->get_id() )->get_status() );

+		wp_set_current_user( $this->create_user( 'customer' ) );
+		$response = $this->get( $contract->get_id() );
 		$this->assertSame( 404, $response->get_status() );
+		$this->assertSame( 'woocommerce_subscriptions_engine_contract_not_found', $this->response_data( $response )['code'] );
 	}

-	public function test_options_exposes_the_action_schema(): void {
-		wp_set_current_user( $this->owner_id );
-		$id = $this->seed( $this->owner_id );
+	/**
+	 * A `map_meta_cap` filter on the read capability decides the read.
+	 */
+	public function test_get_follows_read_subscription_contract_filters(): void {
+		$customer_id = $this->create_user( 'customer' );
+		$contract    = $this->create_contract( $customer_id );
+		$deny        = static function ( $caps, $cap ) {
+			return 'read_subscription_contract' === $cap ? array( 'do_not_allow' ) : $caps;
+		};
+		add_filter( 'map_meta_cap', $deny, 20, 2 );
+		wp_set_current_user( $customer_id );
+
+		try {
+			$this->assertSame( 404, $this->get( $contract->get_id() )->get_status() );
+		} finally {
+			remove_filter( 'map_meta_cap', $deny, 20 );
+		}
+	}

-		$response = rest_get_server()->dispatch( new WP_REST_Request( 'OPTIONS', self::BASE . '/' . $id . '/hold' ) );
+	/**
+	 * The engine serves the read route and the action route, nothing else.
+	 */
+	public function test_registers_the_read_and_action_routes(): void {
+		$routes = array_filter(
+			array_keys( rest_get_server()->get_routes() ),
+			static function ( string $route ): bool {
+				return 0 === strpos( $route, self::BASE );
+			}
+		);

-		$this->assertSame( 200, $response->get_status() );
-		$data = $this->data_array( $response );
-		$this->assertIsArray( $data['schema'] );
-		$this->assertSame( 'subscription_engine_contract_action', $data['schema']['title'] );
+		$this->assertSame( array( self::BASE . '/(?P<id>[\d]+)', self::BASE . '/(?P<id>[\d]+)/action' ), array_values( $routes ) );
 	}

-	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 );
+	/**
+	 * Anonymous callers get a 401 on both action routes.
+	 */
+	public function test_actions_require_a_logged_in_user(): void {
+		$this->register_action( 'pause', 'manage_subscription_contract' );
+		$contract = $this->create_contract( $this->create_user( 'customer' ) );

-		$response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/hold' ) );
+		$this->assertSame( 401, $this->list_actions( $contract->get_id() )->get_status() );
+		$this->assertSame( 401, $this->run_action( $contract->get_id(), 'pause' )->get_status() );
+		$this->assertSame( array(), $this->calls );
+	}

-		$this->assertSame( 404, $response->get_status() );
-		// The contract is untouched.
-		$this->assertSame( ContractStatus::ACTIVE, $this->reload( $id )->get_status() );
+	/**
+	 * A run without an action is a 404 even past the route schema, never the first permitted action.
+	 */
+	public function test_run_without_an_action_resolves_nothing(): void {
+		$customer_id = $this->create_user( 'customer' );
+		$contract    = $this->create_contract( $customer_id );
+		$this->register_action( 'pause', 'manage_subscription_contract' );
+		wp_set_current_user( $customer_id );
+		$request = new WP_REST_Request( 'POST', self::BASE . '/' . $contract->get_id() . '/action' );
+		$request->set_url_params( array( 'id' => (string) $contract->get_id() ) );
+		$request->set_body_params( array( 'extension_slug' => self::EXTENSION_SLUG ) );
+
+		$result = ( new ContractsController() )->run_action_permissions_check( $request );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$error_data = $result->get_error_data();
+		$this->assertIsArray( $error_data );
+		$this->assertSame( 404, $error_data['status'] ?? null );
+		$this->assertSame( array(), $this->calls );
 	}

-	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 );
+	/**
+	 * `manage_subscription_contract` lets the contract's customer run the action: the callback gets the
+	 * contract and the validated args, and the response is the resulting id and status.
+	 */
+	public function test_customer_runs_an_action_on_their_contract(): void {
+		$customer_id = $this->create_user( 'customer' );
+		$contract    = $this->create_contract( $customer_id );
+		$this->register_action( 'pause', 'manage_subscription_contract', array( 'args' => array( 'note' => array( 'type' => 'string' ) ) ) );
+		wp_set_current_user( $customer_id );

-		$response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/hold' ) );
+		$response = $this->run_action( $contract->get_id(), 'pause', array( 'note' => 'Away' ) );

 		$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() );
+		$this->assertSame(
+			array(
+				'id'     => $contract->get_id(),
+				'status' => 'on-hold',
+			),
+			$response->get_data()
+		);
+		$this->assertSame( array( array( 'pause', $contract->get_id(), array( 'note' => 'Away' ) ) ), $this->calls );
+	}
+
+	/**
+	 * Unknown contract, a contract of another customer, an unregistered action and a wrong
+	 * `extension_slug` all get the same 404, and nothing runs.
+	 */
+	public function test_post_hides_contracts_the_caller_cannot_act_on(): void {
+		$customer_id = $this->create_user( 'customer' );
+		$contract    = $this->create_contract( $customer_id );
+		$foreign     = $this->create_contract( $this->create_user( 'customer' ) );
+		$this->register_action( 'pause', 'manage_subscription_contract' );
+		wp_set_current_user( $customer_id );
+
+		$responses = array(
+			$this->run_action( 999999, 'pause' ),
+			$this->run_action( $foreign->get_id(), 'pause' ),
+			$this->run_action( $contract->get_id(), 'resume' ),
+			$this->run_action( $contract->get_id(), 'pause', array(), 'other-extension' ),
+		);
+
+		$not_found = $this->response_data( $responses[0] );
+		$this->assertSame( 'woocommerce_subscriptions_engine_contract_not_found', $not_found['code'] );
+		foreach ( $responses as $response ) {
+			$this->assertSame( 404, $response->get_status() );
+			$this->assertSame( $not_found, $response->get_data() );
+		}
+		$this->assertSame( array(), $this->calls );
+	}
+
+	/**
+	 * Only the contract owner's action runs, even when another extension registered the same name.
+	 */
+	public function test_dispatches_only_to_the_contract_owner(): void {
+		$this->register_action( 'pause', 'manage_woocommerce' );
+		ContractActions::register(
+			'other-extension',
+			'pause',
+			array(
+				'callback'   => function ( ContractView $contract ): ContractView {
+					$this->calls[] = array( 'other-extension', $contract->get_id(), array() );
+					return $contract;
+				},
+				'permission' => 'manage_woocommerce',
+			)
+		);
+		$contract = $this->create_contract( null, 'other-extension' );
+		wp_set_current_user( $this->create_user( 'administrator' ) );
+
+		$this->assertSame( 404, $this->run_action( $contract->get_id(), 'pause' )->get_status() );
+		$this->assertSame( 200, $this->run_action( $contract->get_id(), 'pause', array(), 'other-extension' )->get_status() );
+		$this->assertSame( array( array( 'other-extension', $contract->get_id(), array() ) ), $this->calls );
 	}

-	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 );
+	/**
+	 * A capability permission is checked for the current user: `manage_woocommerce` admits store
+	 * managers and hides the contract from its own customer.
+	 */
+	public function test_capability_permission(): void {
+		$customer_id = $this->create_user( 'customer' );
+		$contract    = $this->create_contract( $customer_id );
+		$this->register_action( 'pause', 'manage_woocommerce' );

-		$response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/reactivate' ) );
+		wp_set_current_user( $customer_id );
+		$this->assertSame( 404, $this->run_action( $contract->get_id(), 'pause' )->get_status() );

-		$this->assertSame( 200, $response->get_status() );
-		$this->assertSame( ContractStatus::ACTIVE, $this->data_array( $response )['status'] );
-		$this->assertSame( ContractStatus::ACTIVE, $this->reload( $id )->get_status() );
+		wp_set_current_user( $this->create_user( 'shop_manager' ) );
+		$this->assertSame( 200, $this->run_action( $contract->get_id(), 'pause' )->get_status() );
+	}
+
+	/**
+	 * A callable permission gets the contract and the request; anything but true is a 404.
+	 */
+	public function test_callable_permission(): void {
+		$contract = $this->create_contract( null );
+		$seen     = array();
+		$this->register_action(
+			'pause',
+			static function ( ContractView $view, WP_REST_Request $request ) use ( &$seen ): bool {
+				$seen[] = array( $view->get_id(), $request->get_param( 'action' ) );
+				return 'yes' === $request->get_header( 'x-test-permission' );
+			}
+		);
+		wp_set_current_user( $this->create_user( 'customer' ) );
+
+		$this->assertSame( 404, $this->run_action( $contract->get_id(), 'pause' )->get_status() );
+
+		$request = $this->action_request( $contract->get_id(), 'pause' );
+		$request->set_header( 'x-test-permission', 'yes' );
+		$this->assertSame( 200, rest_get_server()->dispatch( $request )->get_status() );
+		$this->assertSame( array( array( $contract->get_id(), 'pause' ), array( $contract->get_id(), 'pause' ) ), $seen );
 	}

-	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();
+	/**
+	 * An action that is not available for the contract is a 409, and nothing runs.
+	 */
+	public function test_unavailable_action_is_a_conflict(): void {
+		$contract = $this->create_contract( null );
+		$this->register_action(
+			'pause',
+			'manage_woocommerce',
+			array(
+				'is_available' => static function ( ContractView $view ): bool {
+					return 'on-hold' !== $view->get_status();
+				},
+			)
+		);
+		wp_set_current_user( $this->create_user( 'administrator' ) );
+		$this->assertSame( 200, $this->run_action( $contract->get_id(), 'pause' )->get_status() );

-		$response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/reactivate' ) );
+		$response = $this->run_action( $contract->get_id(), 'pause' );

 		$this->assertSame( 409, $response->get_status() );
-		$this->assertSame( $before, $this->reload( $id )->get_next_payment_gmt(), 'The schedule is untouched.' );
+		$this->assertSame( 'woocommerce_subscriptions_engine_action_not_available', $this->response_data( $response )['code'] );
+		$this->assertCount( 1, $this->calls );
+	}
+
+	/**
+	 * `action_args` are checked against the schema: required and typed properties reject with a
+	 * 400, defaults fill in, and unknown keys are dropped.
+	 */
+	public function test_action_args_are_validated_against_the_schema(): void {
+		$contract = $this->create_contract( null );
+		$this->register_action(
+			'cancel',
+			'manage_woocommerce',
+			array(
+				'args' => array(
+					'at_period_end' => array(
+						'type'    => 'boolean',
+						'default' => true,
+					),
+					'reason'        => array(
+						'type'     => 'string',
+						'required' => true,
+					),
+				),
+			)
+		);
+		wp_set_current_user( $this->create_user( 'administrator' ) );
+
+		$missing = $this->run_action( $contract->get_id(), 'cancel' );
+		$this->assertSame( 400, $missing->get_status() );
+		$this->assertSame( 'woocommerce_subscriptions_engine_invalid_action_args', $this->response_data( $missing )['code'] );
+
+		$mistyped = $this->run_action(
+			$contract->get_id(),
+			'cancel',
+			array(
+				'reason'        => 'Moving',
+				'at_period_end' => 'sometimes',
+			)
+		);
+		$this->assertSame( 400, $mistyped->get_status() );
+		$this->assertCount( 0, $this->calls );
+
+		$response = $this->run_action(
+			$contract->get_id(),
+			'cancel',
+			array(
+				'reason' => 'Moving',
+				'extra'  => 'dropped',
+			)
+		);
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame(
+			array(
+				'reason'        => 'Moving',
+				'at_period_end' => true,
+			),
+			$this->get_recorded_action_args( 0 )
+		);
+
+		$this->assertSame(
+			200,
+			$this->run_action(
+				$contract->get_id(),
+				'cancel',
+				array(
+					'reason'        => 'Moving',
+					'at_period_end' => 'false',
+				)
+			)->get_status()
+		);
+		$this->assertFalse( $this->get_recorded_action_args( 1 )['at_period_end'] );
+	}
+
+	/**
+	 * A callable `args` resolves per contract, the same way for discovery and for running.
+	 */
+	public function test_callable_args_resolve_per_contract(): void {
+		$contract = $this->create_contract( null );
+		$this->register_action(
+			'cancel',
+			'manage_woocommerce',
+			array(
+				'args' => static function ( ContractView $view ): array {
+					return array(
+						'at_period_end' => array(
+							'type'    => 'boolean',
+							'default' => 'active' === $view->get_status(),
+						),
+					);
+				},
+			)
+		);
+		wp_set_current_user( $this->create_user( 'administrator' ) );
+
+		$actions = $this->response_data( $this->list_actions( $contract->get_id() ) )['actions'];
+		$this->assertEquals(
+			array(
+				array(
+					'action'         => 'cancel',
+					'extension_slug' => self::EXTENSION_SLUG,
+					'description'    => '',
+					'args'           => (object) array(
+						'at_period_end' => array(
+							'type'     => 'boolean',
+							'default'  => true,
+							'required' => false,
+						),
+					),
+				),
+			),
+			$actions
+		);
+
+		$this->assertSame( 200, $this->run_action( $contract->get_id(), 'cancel' )->get_status() );
+		$this->assertSame( array( 'at_period_end' => true ), $this->get_recorded_action_args( 0 ) );
+	}
+
+	/**
+	 * A callback `WP_Error` passes through with its status, or 400 without one.
+	 */
+	public function test_callback_errors_pass_through(): void {
+		$contract = $this->create_contract( null );
+		ContractActions::register(
+			self::EXTENSION_SLUG,
+			'teapot',
+			array(
+				'callback'   => static function (): WP_Error {
+					return new WP_Error( 'teapot', 'Short and stout.', array( 'status' => 418 ) );
+				},
+				'permission' => 'manage_woocommerce',
+			)
+		);
+		ContractActions::register(
+			self::EXTENSION_SLUG,
+			'refuse',
+			array(
+				'callback'   => static function (): WP_Error {
+					return new WP_Error( 'refused', 'No.' );
+				},
+				'permission' => 'manage_woocommerce',
+			)
+		);
+		wp_set_current_user( $this->create_user( 'administrator' ) );
+
+		$teapot = $this->run_action( $contract->get_id(), 'teapot' );
+		$this->assertSame( 418, $teapot->get_status() );
+		$this->assertSame( 'teapot', $this->response_data( $teapot )['code'] );
+
+		$refused = $this->run_action( $contract->get_id(), 'refuse' );
+		$this->assertSame( 400, $refused->get_status() );
+		$this->assertSame( 'refused', $this->response_data( $refused )['code'] );
+	}
+
+	/**
+	 * A throwing callback, availability check or permission callable is a generic 500.
+	 */
+	public function test_throwing_extension_callables_are_a_server_error(): void {
+		$contract = $this->create_contract( null );
+		$throw    = static function (): bool {
+			throw new RuntimeException( 'Boom.' );
+		};
+		ContractActions::register(
+			self::EXTENSION_SLUG,
+			'callback',
+			array(
+				'callback'   => $throw,
+				'permission' => 'manage_woocommerce',
+			)
+		);
+		$this->register_action( 'availability', 'manage_woocommerce', array( 'is_available' => $throw ) );
+		$this->register_action( 'permission', $throw );
+		wp_set_current_user( $this->create_user( 'administrator' ) );
+
+		foreach ( array( 'callback', 'availability', 'permission' ) as $action ) {
+			$response = $this->run_action( $contract->get_id(), $action );
+			$this->assertSame( 500, $response->get_status(), $action );
+			$this->assertSame( 'woocommerce_subscriptions_engine_action_failed', $this->response_data( $response )['code'] );
+		}
 	}

-	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 );
+	/**
+	 * Discovery lists the owner's available actions to a store manager, whatever their permission.
+	 */
+	public function test_discovery_lists_available_actions(): void {
+		$contract = $this->create_contract( $this->create_user( 'customer' ) );
+		$this->register_action( 'pause', 'manage_subscription_contract', array( 'description' => 'Pause deliveries.' ) );
+		$this->register_action( 'resume', 'manage_subscription_contract', array( 'is_available' => '__return_false' ) );
+		$this->register_action( 'refund', '__return_false' );
+		wp_set_current_user( $this->create_user( 'administrator' ) );

-		$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 );
+		$response = $this->list_actions( $contract->get_id() );

 		$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() );
+		$this->assertEquals(
+			array(
+				'actions' => array(
+					array(
+						'action'         => 'pause',
+						'extension_slug' => self::EXTENSION_SLUG,
+						'description'    => 'Pause deliveries.',
+						'args'           => new \stdClass(),
+					),
+					array(
+						'action'         => 'refund',
+						'extension_slug' => self::EXTENSION_SLUG,
+						'description'    => '',
+						'args'           => new \stdClass(),
+					),
+				),
+			),
+			$response->get_data()
+		);
+		$this->assertStringContainsString( '"args":{}', (string) wp_json_encode( $response->get_data() ), 'No args encode as an empty JSON object.' );
+		$this->assertSame( 404, $this->list_actions( 999999 )->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 );
+	/**
+	 * Discovery has the contract read's permission: its customer lists the actions, guests get a
+	 * 401 and other customers a 404.
+	 */
+	public function test_discovery_needs_read_subscription_contract(): void {
+		$customer_id = $this->create_user( 'customer' );
+		$contract    = $this->create_contract( $customer_id );
+		$this->register_action( 'pause', 'manage_woocommerce' );

-		$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( 401, $this->list_actions( $contract->get_id() )->get_status() );

+		wp_set_current_user( $customer_id );
+		$response = $this->list_actions( $contract->get_id() );
 		$this->assertSame( 200, $response->get_status() );
-		$this->assertSame( ContractStatus::CANCELLED, $this->data_array( $response )['status'] );
-		$this->assertSame( ContractStatus::CANCELLED, $this->reload( $id )->get_status() );
+		$actions = $this->response_data( $response )['actions'];
+		$this->assertIsArray( $actions );
+		$this->assertSame( array( 'pause' ), array_column( $actions, 'action' ) );
+
+		wp_set_current_user( $this->create_user( 'customer' ) );
+		$this->assertSame( 404, $this->list_actions( $contract->get_id() )->get_status() );
+	}
+
+	/**
+	 * An action registered after the routes still dispatches.
+	 */
+	public function test_actions_registered_after_rest_api_init_dispatch(): void {
+		$contract = $this->create_contract( null );
+		wp_set_current_user( $this->create_user( 'administrator' ) );
+		$this->assertSame( 404, $this->run_action( $contract->get_id(), 'pause' )->get_status() );
+
+		$this->register_action( 'pause', 'manage_woocommerce' );
+
+		$this->assertSame( 200, $this->run_action( $contract->get_id(), 'pause' )->get_status() );
+	}
+
+	/**
+	 * Register a test-extension action whose callback records the call and puts the contract on hold.
+	 *
+	 * @param string               $action     Action slug.
+	 * @param string|callable      $permission Permission.
+	 * @param array<string, mixed> $extra      Further registration args.
+	 */
+	private function register_action( string $action, $permission, array $extra = array() ): void {
+		ContractActions::register(
+			self::EXTENSION_SLUG,
+			$action,
+			array(
+				'callback'   => function ( ContractView $contract, array $action_args ) use ( $action ): ?ContractView {
+					$this->calls[] = array( $action, $contract->get_id(), $action_args );
+					return Contracts::update( $contract->get_id(), array( 'status' => 'on-hold' ) );
+				},
+				'permission' => $permission,
+			) + $extra
+		);
 	}

-	public function test_illegal_transition_is_a_conflict(): void {
-		// There is no status state machine: each flow guards its own preconditions, and
-		// a precondition the current state does not meet (holding a cancelled contract)
-		// surfaces as a 409.
-		wp_set_current_user( $this->owner_id );
-		$id = $this->seed( $this->owner_id, ContractStatus::CANCELLED );
+	/**
+	 * The `action_args` an action callback received, by call order.
+	 *
+	 * @param int $index Call index.
+	 * @return array<string, mixed>
+	 */
+	private function get_recorded_action_args( int $index ): array {
+		$this->assertArrayHasKey( $index, $this->calls );

-		$response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/hold' ) );
+		return $this->calls[ $index ][2];
+	}

-		$this->assertSame( 409, $response->get_status() );
+	/**
+	 * An active contract.
+	 *
+	 * @param int|null $customer_id    Customer user id.
+	 * @param string   $extension_slug Owning extension.
+	 */
+	private function create_contract( ?int $customer_id, string $extension_slug = self::EXTENSION_SLUG ): ContractView {
+		return Contracts::create(
+			array(
+				'extension_slug' => $extension_slug,
+				'status'         => 'active',
+				'customer_id'    => $customer_id,
+			)
+		);
 	}

-	public function test_hold_on_an_expired_contract_is_a_conflict(): void {
-		wp_set_current_user( $this->owner_id );
-		$id = $this->seed( $this->owner_id, ContractStatus::EXPIRED );
+	/**
+	 * Build a POST to the action route.
+	 *
+	 * @param int                  $contract_id    Contract id.
+	 * @param string               $action         Action slug.
+	 * @param array<string, mixed> $action_args    Action args.
+	 * @param string               $extension_slug Extension slug sent in the body.
+	 */
+	private function action_request( int $contract_id, string $action, array $action_args = array(), string $extension_slug = self::EXTENSION_SLUG ): WP_REST_Request {
+		$request = new WP_REST_Request( 'POST', self::BASE . '/' . $contract_id . '/action' );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body(
+			(string) wp_json_encode(
+				array(
+					'action'         => $action,
+					'extension_slug' => $extension_slug,
+					'action_args'    => (object) $action_args,
+				)
+			)
+		);

-		$response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/hold' ) );
+		return $request;
+	}

-		$this->assertSame( 409, $response->get_status() );
-		$stored = $this->reload( $id );
-		$this->assertSame( ContractStatus::EXPIRED, $stored->get_status() );
-		$this->assertSame( '2099-02-01 00:00:00', $stored->get_next_payment_gmt(), 'The rejected action writes nothing.' );
+	/**
+	 * POST an action.
+	 *
+	 * @param int                  $contract_id    Contract id.
+	 * @param string               $action         Action slug.
+	 * @param array<string, mixed> $action_args    Action args.
+	 * @param string               $extension_slug Extension slug sent in the body.
+	 */
+	private function run_action( int $contract_id, string $action, array $action_args = array(), string $extension_slug = self::EXTENSION_SLUG ): WP_REST_Response {
+		return rest_get_server()->dispatch( $this->action_request( $contract_id, $action, $action_args, $extension_slug ) );
 	}

-	public function test_cancel_on_an_expired_contract_is_a_conflict(): void {
-		wp_set_current_user( $this->owner_id );
-		$id = $this->seed( $this->owner_id, ContractStatus::EXPIRED );
+	/**
+	 * GET the action list.
+	 *
+	 * @param int $contract_id Contract id.
+	 */
+	private function list_actions( int $contract_id ): WP_REST_Response {
+		return rest_get_server()->dispatch( new WP_REST_Request( 'GET', self::BASE . '/' . $contract_id . '/action' ) );
+	}

-		$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 );
+	/**
+	 * Create a user with a role.
+	 *
+	 * @param string $role Role slug.
+	 */
+	private function create_user( string $role ): int {
+		$user_id = self::factory()->user->create( array( 'role' => $role ) );
+		$this->assertIsInt( $user_id );

-		$this->assertSame( 409, $response->get_status() );
-		$stored = $this->reload( $id );
-		$this->assertSame( ContractStatus::EXPIRED, $stored->get_status() );
-		$this->assertSame( '2099-02-01 00:00:00', $stored->get_next_payment_gmt(), 'The rejected action writes nothing.' );
+		return $user_id;
 	}

 	/**
-	 * The response body as an array (asserts it is one, narrowing offset access).
+	 * Get response data as an array.
 	 *
-	 * @param WP_REST_Response $response The dispatched response.
-	 * @return array<int|string, mixed>
+	 * @param WP_REST_Response $response Response.
+	 * @return array<array-key, mixed>
 	 */
-	private function data_array( WP_REST_Response $response ): array {
+	private function response_data( WP_REST_Response $response ): array {
 		$data = $response->get_data();
 		$this->assertIsArray( $data );

@@ -263,14 +725,11 @@ class ContractsControllerTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * Reload a contract, asserting it still exists (narrows the nullable read).
+	 * Dispatch a GET for one contract.
 	 *
-	 * @param int $id Contract id.
+	 * @param int $contract_id Contract id.
 	 */
-	private function reload( int $id ): Contract {
-		$contract = $this->contracts->find( $id );
-		$this->assertInstanceOf( Contract::class, $contract );
-
-		return $contract;
+	private function get( int $contract_id ): WP_REST_Response {
+		return rest_get_server()->dispatch( new WP_REST_Request( 'GET', self::BASE . '/' . $contract_id ) );
 	}
 }
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 8885475dacd..4b39432aed5 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
@@ -1,6 +1,6 @@
 <?php
 /**
- * Integration tests for the interim Subscriptions lifecycle and renewal facade.
+ * Integration tests for the interim Subscriptions renewal facade.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine
  */
@@ -15,7 +15,6 @@ use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\Subscriptions;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
@@ -27,7 +26,7 @@ use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepos
 class SubscriptionsTest extends EngineIntegrationTestCase {

 	/**
-	 * Gateway id used for the lifecycle charge - declares `recurring` and completes
+	 * Gateway id used for the renewal charge - declares `recurring` and completes
 	 * the charge inline (the dummy-gateway shape), matching the real gateway used in CI.
 	 */
 	private const GATEWAY = 'dummy';
@@ -202,13 +201,6 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
 		$this->assertSame( array( 2, 4, 6 ), $limits, 'The linked-order query is bounded to offset + limit.' );
 	}

-	/**
-	 * @testdox cancel returns false for an unknown contract.
-	 */
-	public function test_cancel_unknown_contract_returns_false(): void {
-		$this->assertFalse( Subscriptions::cancel( 999999 ) );
-	}
-
 	/**
 	 * @testdox renew_now returns null for an unknown contract.
 	 */
@@ -217,9 +209,9 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox The full lifecycle runs through the facade: buy, renew, cancel.
+	 * @testdox A purchased contract renews through the facade.
 	 */
-	public function test_full_lifecycle_buy_renew_cancel(): void {
+	public function test_buy_then_renew_through_the_facade(): void {
 		// Buy: signup builds cycle 1 (billed).
 		$contract    = $this->sign_up_contract();
 		$contract_id = $contract->get_id();
@@ -245,49 +237,5 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
 		$after_renew = Contracts::get( $contract_id );
 		$this->assertInstanceOf( ContractView::class, $after_renew );
 		$this->assertSame( '2026-03-15 00:00:00', $after_renew->get_next_payment_gmt() );
-
-		// Cancel: the contract goes terminal.
-		$this->assertTrue( Subscriptions::cancel( $contract_id ) );
-
-		$after_cancel = Contracts::get( $contract_id );
-		$this->assertInstanceOf( ContractView::class, $after_cancel );
-		$this->assertSame( ContractStatus::CANCELLED, $after_cancel->get_status() );
-	}
-
-	/**
-	 * @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 = Contracts::get( $contract_id );
-		$this->assertInstanceOf( ContractView::class, $held );
-		$this->assertSame( ContractStatus::ON_HOLD, $held->get_status() );
-		$this->assertNull( $held->get_next_payment_gmt(), 'Hold disarms the next-due moment.' );
-
-		$this->assertTrue( Subscriptions::reactivate( $contract_id ) );
-		$active = Contracts::get( $contract_id );
-		$this->assertInstanceOf( ContractView::class, $active );
-		$this->assertSame( ContractStatus::ACTIVE, $active->get_status() );
-		$this->assertNotNull( $active->get_next_payment_gmt(), 'Reactivate re-arms the next-due moment.' );
-
-		$this->assertTrue( Subscriptions::cancel_at_period_end( $contract_id ) );
-		$pending = Contracts::get( $contract_id );
-		$this->assertInstanceOf( ContractView::class, $pending );
-		$this->assertSame( ContractStatus::PENDING_CANCELLATION, $pending->get_status() );
-		$this->assertNull( $pending->get_next_payment_gmt(), 'Cancel at period end disarms the next-due moment.' );
-		$this->assertNotNull( $pending->get_end_gmt(), 'The former next-due moment becomes the end date.' );
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php
deleted file mode 100644
index 78f598cfa2d..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php
+++ /dev/null
@@ -1,440 +0,0 @@
-<?php
-/**
- * Integration tests for the Cancellation contract operation: the period-end mode (->
- * PENDING_CANCELLATION, the end date stamped, the next-due moment disarmed) and the
- * immediate mode's guards and disarm (its order/cycle effects are covered through the
- * facade suite).
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Contracts;
-
-use DomainException;
-use EngineIntegrationTestCase;
-use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
-use Automattic\WooCommerce\SubscriptionsEngine\Api\Subscriptions;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation
- */
-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 {
-		return $this->seed( ContractStatus::ACTIVE, $end_gmt );
-	}
-
-	/**
-	 * Seed a contract at a status with a future next-payment date.
-	 *
-	 * @param string      $status  Contract status.
-	 * @param string|null $end_gmt Optional pre-set end date.
-	 */
-	private function seed( string $status, ?string $end_gmt = null ): int {
-		$contract = Contract::create(
-			array(
-				'extension_slug'   => 'engine-tests',
-				'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',
-				'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_cancel_at_period_end_stamps_the_end_and_clears_the_next_payment(): void {
-		// The flow disarms the next-due moment itself, so the due scan never selects the
-		// winding-down contract; its end_gmt is the date it terminates at.
-		$id = $this->seed_active();
-
-		$this->sut->cancel_at_period_end( $this->reload( $id ) );
-
-		$stored = $this->reload( $id );
-		$this->assertSame( '2099-01-01 00:00:00', $stored->get_end_gmt() );
-		$this->assertNull( $stored->get_next_payment_gmt() );
-	}
-
-	public function test_preserves_an_existing_end_date(): void {
-		$id = $this->seed_active( '2026-09-09 00:00:00' );
-
-		$this->sut->cancel_at_period_end( $this->reload( $id ) );
-
-		$stored = $this->reload( $id );
-		$this->assertSame( '2026-09-09 00:00:00', $stored->get_end_gmt() );
-		$this->assertNull( $stored->get_next_payment_gmt() );
-	}
-
-	public function test_cancel_at_period_end_from_on_hold_stamps_the_end_from_the_hold_anchor(): void {
-		$id = $this->seed_active();
-		( new Hold( $this->contracts ) )->hold( $this->reload( $id ) );
-
-		$this->assertTrue( $this->sut->cancel_at_period_end( $this->reload( $id ) ) );
-
-		$stored = $this->reload( $id );
-		$this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
-		$this->assertSame( '2099-01-01 00:00:00', $stored->get_end_gmt() );
-		$this->assertNull( $stored->get_next_payment_gmt() );
-		$this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
-	}
-
-	public function test_cancel_at_period_end_on_a_pending_cancellation_contract_is_a_no_op(): void {
-		$id = $this->seed_active();
-		$this->sut->cancel_at_period_end( $this->reload( $id ) );
-
-		$fired = 0;
-		add_action(
-			Cancellation::CONTRACT_PENDING_CANCELLATION_ACTION,
-			static function () use ( &$fired ): void {
-				++$fired;
-			}
-		);
-
-		$this->assertTrue( $this->sut->cancel_at_period_end( $this->reload( $id ) ) );
-
-		$stored = $this->reload( $id );
-		$this->assertSame( 1, $fired );
-		$this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
-		$this->assertSame( '2099-01-01 00:00:00', $stored->get_end_gmt() );
-		$this->assertNull( $stored->get_next_payment_gmt() );
-	}
-
-	public function test_fires_the_pending_cancellation_action(): void {
-		$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(
-				'extension_slug'  => 'engine-tests',
-				'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 ) );
-	}
-
-	public function test_cancel_clears_the_next_payment(): void {
-		$id = $this->seed_active();
-
-		$this->assertTrue( $this->sut->cancel( $this->reload( $id ) ) );
-
-		$stored = $this->reload( $id );
-		$this->assertSame( ContractStatus::CANCELLED, $stored->get_status() );
-		$this->assertNull( $stored->get_next_payment_gmt() );
-	}
-
-	public function test_cancel_clears_the_next_payment_and_hold_anchor_of_a_held_contract(): void {
-		$id = $this->seed_active();
-		( new Hold( $this->contracts ) )->hold( $this->reload( $id ) );
-
-		$this->sut->cancel( $this->reload( $id ) );
-
-		$stored = $this->reload( $id );
-		$this->assertSame( ContractStatus::CANCELLED, $stored->get_status() );
-		$this->assertNull( $stored->get_next_payment_gmt() );
-		$this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
-	}
-
-	/**
-	 * The anchor is cleared after the status write has committed, so a failed delete must
-	 * not abort the transition: the lifecycle action still fires.
-	 *
-	 * @dataProvider provide_cancel_modes
-	 *
-	 * @param string $method Cancellation method under test.
-	 * @param string $action Action the method fires.
-	 * @param string $status Status the method writes.
-	 */
-	public function test_a_failed_anchor_clear_does_not_abort_the_transition( string $method, string $action, string $status ): void {
-		$id = $this->seed_active();
-		( new Hold( $this->contracts ) )->hold( $this->reload( $id ) );
-
-		$fired = 0;
-		add_action(
-			$action,
-			static function () use ( &$fired ): void {
-				++$fired;
-			}
-		);
-		$break = $this->break_meta_deletes();
-
-		try {
-			$this->assertTrue( $this->sut->$method( $this->reload( $id ) ) );
-		} finally {
-			remove_filter( 'query', $break );
-		}
-
-		$this->assertSame( 1, $fired, 'The lifecycle action fires.' );
-		$this->assertSame( $status, $this->reload( $id )->get_status() );
-		$this->assertSame( '2099-01-01 00:00:00', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ), 'The anchor is left behind.' );
-	}
-
-	/**
-	 * A transition that loses its compare-and-set leaves the hold anchor in place, so the
-	 * contract that won can still resume from it.
-	 *
-	 * @dataProvider provide_cancel_modes
-	 *
-	 * @param string $method Cancellation method under test.
-	 */
-	public function test_a_lost_race_keeps_the_hold_anchor( string $method ): void {
-		$id = $this->seed_active();
-		( new Hold( $this->contracts ) )->hold( $this->reload( $id ) );
-		$stale = $this->reload( $id );
-
-		$concurrent = $this->reload( $id );
-		$concurrent->set_status( ContractStatus::EXPIRED );
-		$this->contracts->update( $concurrent );
-
-		try {
-			$this->sut->$method( $stale );
-			$this->fail( 'Expected a DomainException when the conditional write misses.' );
-		} catch ( DomainException $e ) {
-			$this->assertSame( '2099-01-01 00:00:00', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
-		}
-	}
-
-	/**
-	 * Both cancellation modes: method, action, resulting status.
-	 *
-	 * @return array<string, array{0: string, 1: string, 2: string}>
-	 */
-	public function provide_cancel_modes(): array {
-		return array(
-			'cancel'               => array( 'cancel', Cancellation::CONTRACT_CANCELLED_ACTION, ContractStatus::CANCELLED ),
-			'cancel at period end' => array( 'cancel_at_period_end', Cancellation::CONTRACT_PENDING_CANCELLATION_ACTION, ContractStatus::PENDING_CANCELLATION ),
-		);
-	}
-
-	/**
-	 * Make every contract meta DELETE fail until the returned filter is removed.
-	 *
-	 * @return callable The `query` filter to remove.
-	 */
-	private function break_meta_deletes(): callable {
-		$meta_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META );
-		$break      = static function ( string $query ) use ( $meta_table ): string {
-			return 0 === strpos( $query, "DELETE FROM `{$meta_table}`" ) ? 'SELECT broken syntax (' : $query;
-		};
-		add_filter( 'query', $break );
-
-		return $break;
-	}
-
-	public function test_cancel_accepts_a_pending_cancellation_contract(): void {
-		$id = $this->seed( ContractStatus::PENDING_CANCELLATION );
-
-		$this->assertTrue( $this->sut->cancel( $this->reload( $id ) ) );
-
-		$stored = $this->reload( $id );
-		$this->assertSame( ContractStatus::CANCELLED, $stored->get_status() );
-		$this->assertNull( $stored->get_next_payment_gmt() );
-	}
-
-	public function test_cancel_accepts_a_draft_contract(): void {
-		// A stuck draft (created, never activated) has no cycles and no due moment.
-		$id = Contracts::create(
-			array(
-				'extension_slug' => 'test-owner',
-				'customer_id'    => 1,
-				'currency'       => 'USD',
-			)
-		)->get_id();
-		$this->assertSame( ContractStatus::DRAFT, $this->reload( $id )->get_status() );
-
-		$fired = 0;
-		add_action(
-			Cancellation::CONTRACT_CANCELLED_ACTION,
-			static function () use ( &$fired ): void {
-				++$fired;
-			}
-		);
-
-		$this->assertTrue( Subscriptions::cancel( $id ) );
-
-		$stored = $this->reload( $id );
-		$this->assertSame( ContractStatus::CANCELLED, $stored->get_status() );
-		$this->assertNull( $stored->get_next_payment_gmt() );
-		$this->assertSame( 1, $fired );
-	}
-
-	public function test_cancel_on_a_cancelled_contract_is_a_no_op_that_refires_the_action(): void {
-		$id = $this->seed( ContractStatus::CANCELLED );
-
-		$fired = 0;
-		add_action(
-			Cancellation::CONTRACT_CANCELLED_ACTION,
-			static function () use ( &$fired ): void {
-				++$fired;
-			}
-		);
-
-		$this->assertTrue( $this->sut->cancel( $this->reload( $id ) ) );
-
-		$this->assertSame( 1, $fired );
-		$this->assertSame( ContractStatus::CANCELLED, $this->reload( $id )->get_status() );
-	}
-
-	public function test_cancel_rejects_an_expired_contract(): void {
-		$id     = $this->seed( ContractStatus::EXPIRED );
-		$before = did_action( Cancellation::CONTRACT_CANCELLED_ACTION );
-
-		try {
-			$this->sut->cancel( $this->reload( $id ) );
-			$this->fail( 'Expected a DomainException for an expired contract.' );
-		} catch ( DomainException $e ) {
-			$this->assertSame( ContractStatus::EXPIRED, $this->reload( $id )->get_status() );
-			$this->assertSame( $before, did_action( Cancellation::CONTRACT_CANCELLED_ACTION ), 'The cancelled action does not fire.' );
-		}
-	}
-
-	public function test_cancel_rejects_an_unregistered_stored_status(): void {
-		global $wpdb;
-
-		$id = $this->seed_active();
-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-		$wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'status' => 'legacy-paused' ), array( 'id' => $id ) );
-
-		try {
-			$this->sut->cancel( $this->reload( $id ) );
-			$this->fail( 'Expected a DomainException for an unregistered stored status.' );
-		} catch ( DomainException $e ) {
-			$this->assertSame( 'legacy-paused', $this->reload( $id )->get_status() );
-		}
-	}
-
-	public function test_cancel_at_period_end_ignores_a_malformed_hold_anchor(): void {
-		$id = $this->seed( ContractStatus::ON_HOLD );
-		$this->contracts->update_meta( $id, Hold::ANCHOR_META_KEY, 'not-a-date' );
-
-		$this->sut->cancel_at_period_end( $this->reload( $id ) );
-
-		$stored = $this->reload( $id );
-		$this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
-		$this->assertSame( '2099-01-01 00:00:00', $stored->get_end_gmt(), 'The stored next payment, not the malformed anchor, is the period end.' );
-		$this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
-	}
-
-	public function test_cancel_at_period_end_from_on_hold_without_a_date_leaves_no_end(): void {
-		$id   = $this->seed( ContractStatus::ON_HOLD );
-		$held = $this->reload( $id );
-		$held->set_next_payment_gmt( null );
-		$this->contracts->update( $held );
-		$this->contracts->update_meta( $id, Hold::ANCHOR_META_KEY, 'not-a-date' );
-
-		$this->sut->cancel_at_period_end( $this->reload( $id ) );
-
-		$stored = $this->reload( $id );
-		$this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
-		$this->assertNull( $stored->get_end_gmt(), 'A malformed anchor is never written as the end date.' );
-	}
-
-	public function test_cancel_at_period_end_rejects_an_expired_contract(): void {
-		$id     = $this->seed( ContractStatus::EXPIRED );
-		$before = did_action( Cancellation::CONTRACT_PENDING_CANCELLATION_ACTION );
-
-		try {
-			$this->sut->cancel_at_period_end( $this->reload( $id ) );
-			$this->fail( 'Expected a DomainException for an expired contract.' );
-		} catch ( DomainException $e ) {
-			$reloaded = $this->reload( $id );
-			$this->assertSame( ContractStatus::EXPIRED, $reloaded->get_status() );
-			$this->assertSame( '2099-01-01 00:00:00', $reloaded->get_next_payment_gmt(), 'Nothing was written.' );
-			$this->assertSame( $before, did_action( Cancellation::CONTRACT_PENDING_CANCELLATION_ACTION ), 'The action does not fire.' );
-		}
-	}
-
-	public function test_cancel_at_period_end_rejects_an_unregistered_stored_status(): void {
-		global $wpdb;
-
-		$id = $this->seed_active();
-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-		$wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'status' => 'legacy-paused' ), array( 'id' => $id ) );
-
-		try {
-			$this->sut->cancel_at_period_end( $this->reload( $id ) );
-			$this->fail( 'Expected a DomainException for an unregistered stored status.' );
-		} catch ( DomainException $e ) {
-			$reloaded = $this->reload( $id );
-			$this->assertSame( 'legacy-paused', $reloaded->get_status() );
-			$this->assertSame( '2099-01-01 00:00:00', $reloaded->get_next_payment_gmt(), 'Nothing was written.' );
-		}
-	}
-
-	/**
-	 * Reload a contract, asserting it still exists (narrows the nullable read).
-	 *
-	 * @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
deleted file mode 100644
index 1973b59a226..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php
+++ /dev/null
@@ -1,241 +0,0 @@
-<?php
-/**
- * Integration tests for the Hold contract operation: ACTIVE -> ON_HOLD, the
- * next-due moment disarmed (kept as the hold anchor), 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;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
-
-/**
- * @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 next-payment date (a future one by default).
-	 *
-	 * @param string      $status       Contract status.
-	 * @param string|null $next_payment Next-payment GMT string, or null.
-	 */
-	private function seed( string $status, ?string $next_payment = '2099-01-01 00:00:00' ): int {
-		$contract = Contract::create(
-			array(
-				'extension_slug'   => 'engine-tests',
-				'customer_id'      => 1,
-				'status'           => $status,
-				'currency'         => 'USD',
-				'selling_plan_id'  => 1,
-				'start_gmt'        => '2026-01-01 00:00:00',
-				'next_payment_gmt' => $next_payment,
-				'billing_total'    => '19.99',
-			)
-		);
-
-		return $this->contracts->insert( $contract );
-	}
-
-	/**
-	 * The stored hold anchor for a contract ('' when absent).
-	 *
-	 * @param int $id Contract id.
-	 * @return mixed
-	 */
-	private function anchor( int $id ) {
-		return $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true );
-	}
-
-	public function test_hold_suspends_billing_by_moving_to_on_hold(): void {
-		$id = $this->seed( ContractStatus::ACTIVE );
-
-		$result = $this->sut->hold( $this->reload( $id ) );
-
-		$this->assertTrue( $result );
-		// No charge while held: hold disarms the next-due moment the batch due scan keys on.
-		$this->assertSame( ContractStatus::ON_HOLD, $this->reload( $id )->get_status() );
-	}
-
-	public function test_hold_clears_the_next_payment_and_keeps_the_anchor(): void {
-		$id = $this->seed( ContractStatus::ACTIVE );
-
-		$this->sut->hold( $this->reload( $id ) );
-
-		$held = $this->reload( $id );
-		$this->assertNull( $held->get_next_payment_gmt() );
-		$this->assertSame( '2099-01-01 00:00:00', $this->anchor( $id ) );
-	}
-
-	public function test_hold_without_a_next_payment_stores_no_anchor(): void {
-		$id = $this->seed( ContractStatus::ACTIVE, null );
-
-		$this->sut->hold( $this->reload( $id ) );
-
-		$held = $this->reload( $id );
-		$this->assertSame( ContractStatus::ON_HOLD, $held->get_status() );
-		$this->assertNull( $held->get_next_payment_gmt() );
-		$this->assertSame( '', $this->anchor( $id ) );
-	}
-
-	public function test_hold_on_an_on_hold_contract_is_an_idempotent_no_op(): void {
-		$id = $this->seed( ContractStatus::ACTIVE );
-		$this->sut->hold( $this->reload( $id ) );
-
-		$fired = 0;
-		add_action(
-			Hold::CONTRACT_HELD_ACTION,
-			static function () use ( &$fired ): void {
-				++$fired;
-			}
-		);
-
-		// A second hold must not wipe the anchor stashed by the first.
-		$this->assertTrue( $this->sut->hold( $this->reload( $id ) ) );
-
-		$held = $this->reload( $id );
-		$this->assertSame( 1, $fired );
-		$this->assertSame( ContractStatus::ON_HOLD, $held->get_status() );
-		$this->assertNull( $held->get_next_payment_gmt() );
-		$this->assertSame( '2099-01-01 00:00:00', $this->anchor( $id ) );
-	}
-
-	public function test_the_anchor_survives_a_whole_contract_update(): void {
-		$id = $this->seed( ContractStatus::ACTIVE );
-		$this->sut->hold( $this->reload( $id ) );
-
-		$held = $this->reload( $id );
-		$held->set_end_gmt( '2099-12-31 00:00:00' );
-		$this->contracts->update( $held );
-
-		$this->assertSame( '2099-01-01 00:00:00', $this->anchor( $id ) );
-		$this->assertSame( '2099-01-01 00:00:00', Hold::read_anchor( $this->contracts, $id ) );
-	}
-
-	public function test_hold_fires_the_held_action(): void {
-		$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.' );
-		}
-	}
-
-	/**
-	 * The anchor is stored and read back before the hold disarms the contract, so a failed
-	 * meta write aborts the hold with nothing disarmed instead of leaving a held contract
-	 * that reactivation could not re-arm.
-	 */
-	public function test_hold_aborts_without_disarming_when_the_anchor_cannot_be_stored(): void {
-		$id         = $this->seed( ContractStatus::ACTIVE );
-		$meta_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META );
-		$break      = static function ( string $query ) use ( $meta_table ): string {
-			return 0 === strpos( $query, "INSERT INTO `{$meta_table}`" ) ? 'SELECT broken syntax (' : $query;
-		};
-		add_filter( 'query', $break );
-
-		try {
-			$this->sut->hold( $this->reload( $id ) );
-			$this->fail( 'Expected the hold to abort when the anchor cannot be stored.' );
-		} catch ( \RuntimeException $e ) {
-			$this->assertStringContainsString( 'anchor could not be stored', $e->getMessage() );
-		} finally {
-			remove_filter( 'query', $break );
-		}
-
-		$stored = $this->reload( $id );
-		$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
-		$this->assertSame( '2099-01-01 00:00:00', $stored->get_next_payment_gmt(), 'The next-due moment was not disarmed.' );
-	}
-
-	public function test_hold_rejects_a_cancelled_contract(): void {
-		$id = $this->seed( ContractStatus::CANCELLED );
-
-		$this->expectException( DomainException::class );
-		$this->sut->hold( $this->reload( $id ) );
-	}
-
-	public function test_hold_rejects_a_pending_cancellation_contract(): void {
-		$id = $this->seed( ContractStatus::PENDING_CANCELLATION );
-
-		$this->expectException( DomainException::class );
-		$this->sut->hold( $this->reload( $id ) );
-	}
-
-	public function test_hold_rejects_an_unregistered_stored_status(): void {
-		global $wpdb;
-
-		$id = $this->seed( ContractStatus::ACTIVE );
-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-		$wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'status' => 'legacy-paused' ), array( 'id' => $id ) );
-
-		try {
-			$this->sut->hold( $this->reload( $id ) );
-			$this->fail( 'Expected a DomainException for an unregistered stored status.' );
-		} catch ( DomainException $e ) {
-			$this->assertSame( 'legacy-paused', $this->reload( $id )->get_status() );
-			$this->assertSame( '2099-01-01 00:00:00', $this->reload( $id )->get_next_payment_gmt() );
-		}
-	}
-
-	/**
-	 * Reload a contract, asserting it still exists (narrows the nullable read).
-	 *
-	 * @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
deleted file mode 100644
index a1fccbae5c2..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php
+++ /dev/null
@@ -1,511 +0,0 @@
-<?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\ValueObject\PlanSnapshot;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Reactivation;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
-
-/**
- * @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();
-	}
-
-	/**
-	 * Seed a contract with a next-payment date and the given selling plan.
-	 *
-	 * An on-hold row that still carries `next_payment_gmt` and no hold anchor meta is the
-	 * shape of a contract held before hold started disarming the next-due moment; it is the
-	 * reactivation fallback these seeds exercise.
-	 *
-	 * @param string|null           $next_payment_gmt Next-payment GMT string, or null.
-	 * @param int                   $selling_plan_id  Selling plan id.
-	 * @param string                $status           Contract status. Default ON_HOLD.
-	 * @param array<string, string> $meta             Contract meta.
-	 */
-	private function seed_on_hold( ?string $next_payment_gmt, int $selling_plan_id, string $status = ContractStatus::ON_HOLD, array $meta = array() ): int {
-		$contract = Contract::create(
-			array(
-				'extension_slug'   => 'engine-tests',
-				'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',
-			)
-		);
-
-		$id = $this->contracts->insert( $contract );
-		foreach ( $meta as $key => $value ) {
-			$this->contracts->add_meta( $id, $key, $value );
-		}
-
-		return $id;
-	}
-
-	private function utc( string $datetime ): DateTimeImmutable {
-		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_rearms_from_the_hold_anchor(): void {
-		$id = $this->seed_on_hold( '2099-01-01 00:00:00', $this->make_monthly_plan(), ContractStatus::ACTIVE );
-		( new Hold( $this->contracts ) )->hold( $this->reload( $id ) );
-		$this->assertNull( $this->reload( $id )->get_next_payment_gmt(), 'Hold disarmed the next-due moment.' );
-
-		$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
-
-		$stored = $this->reload( $id );
-		$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
-		$this->assertSame( '2099-01-01 00:00:00', $stored->get_next_payment_gmt() );
-		$this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
-	}
-
-	public function test_reactivate_rolls_a_past_due_anchor_forward(): void {
-		// Held with a 2026-02-01 anchor, resumed 2026-04-15: rolled forward by whole
-		// cadences exactly like a stored past-due date -> 2026-05-01.
-		$id = $this->seed_on_hold( null, $this->make_monthly_plan(), ContractStatus::ON_HOLD, array( Hold::ANCHOR_META_KEY => '2026-02-01 00:00:00' ) );
-
-		$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-04-15 00:00:00' ) );
-
-		$stored = $this->reload( $id );
-		$this->assertSame( '2026-05-01 00:00:00', $stored->get_next_payment_gmt() );
-		$this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
-	}
-
-	/**
-	 * Hold clears the next payment, so one set while held was re-armed deliberately and wins.
-	 */
-	public function test_reactivate_prefers_a_stored_next_payment_over_the_anchor(): void {
-		$id = $this->seed_on_hold( '2099-06-01 00:00:00', $this->make_monthly_plan(), ContractStatus::ON_HOLD, array( Hold::ANCHOR_META_KEY => '2099-01-01 00:00:00' ) );
-
-		$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
-
-		$stored = $this->reload( $id );
-		$this->assertSame( '2099-06-01 00:00:00', $stored->get_next_payment_gmt() );
-		$this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
-	}
-
-	public function test_reactivate_ignores_a_malformed_anchor(): void {
-		$id = $this->seed_on_hold( null, $this->make_monthly_plan(), ContractStatus::ON_HOLD, array( Hold::ANCHOR_META_KEY => 'not-a-date' ) );
-
-		$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
-
-		$stored = $this->reload( $id );
-		$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
-		$this->assertNull( $stored->get_next_payment_gmt(), 'A malformed anchor counts as absent.' );
-		$this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
-	}
-
-	public function test_reactivate_rolls_by_the_frozen_snapshot_cadence_over_the_live_plan(): void {
-		// 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(
-				array(
-					'billing_policy' => array(
-						'period'   => 'day',
-						'interval' => 1,
-					),
-				)
-			)
-		);
-
-		$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() );
-	}
-
-	/**
-	 * @dataProvider provide_unusable_live_billing_payloads
-	 *
-	 * @param array<string, mixed>|null $billing The live plan's billing payload.
-	 */
-	public function test_reactivate_floors_past_due_at_now_when_the_live_billing_is_unusable( ?array $billing ): void {
-		$plan_id = $this->make_plan( array( 'billing_policy' => $billing ) );
-		$id      = $this->seed_on_hold( '2026-02-01 00:00:00', $plan_id );
-
-		$warnings = $this->capture_engine_log(
-			'warning',
-			array(
-				'contract_id' => $id,
-				'plan_id'     => $plan_id,
-			),
-			function () use ( $id ): void {
-				$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() );
-		$this->assertNotEmpty( $warnings, 'A null or unusable live billing payload is logged with the contract and plan.' );
-	}
-
-	/**
-	 * @return array<string, array{0: array<string, mixed>|null}>
-	 */
-	public function provide_unusable_live_billing_payloads(): array {
-		return array(
-			'null payload'     => array( null ),
-			'missing interval' => array( array( 'period' => 'month' ) ),
-			'unknown period'   => array(
-				array(
-					'period'   => 'decade',
-					'interval' => 1,
-				),
-			),
-			'zero interval'    => array(
-				array(
-					'period'   => 'month',
-					'interval' => 0,
-				),
-			),
-		);
-	}
-
-	/**
-	 * A snapshot policy with no usable cadence falls through to the live plan (logged),
-	 * the same as renewal, instead of throwing out of the forward roll.
-	 *
-	 * @dataProvider provide_unusable_snapshot_billing_payloads
-	 *
-	 * @param array<string, mixed>      $snapshot_billing The snapshot's billing payload.
-	 * @param array<string, mixed>|null $live_billing     The live plan's billing payload.
-	 * @param string                    $expected_next    The expected next payment.
-	 */
-	public function test_reactivate_falls_back_to_the_live_plan_when_the_snapshot_billing_is_unusable( array $snapshot_billing, ?array $live_billing, string $expected_next ): void {
-		$id = $this->seed_on_hold( '2026-02-01 00:00:00', $this->make_plan( array( 'billing_policy' => $live_billing ) ) );
-
-		$contract = $this->reload( $id );
-		$contract->set_plan_snapshot(
-			PlanSnapshot::from_array(
-				array(
-					'selling_plan_id' => $contract->get_selling_plan_id(),
-					'billing_policy'  => $snapshot_billing,
-				)
-			)
-		);
-
-		$warnings = $this->capture_engine_log(
-			'warning',
-			array( 'contract_id' => $id ),
-			function () use ( $contract ): void {
-				$this->assertTrue( $this->sut->reactivate( $contract, $this->utc( '2026-04-15 09:30:00' ) ) );
-			}
-		);
-
-		$stored = $this->reload( $id );
-		$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
-		$this->assertSame( $expected_next, $stored->get_next_payment_gmt() );
-		$this->assertNotEmpty( $warnings, 'An unusable snapshot billing payload is logged.' );
-	}
-
-	/**
-	 * @return array<string, array{0: array<string, mixed>, 1: array<string, mixed>|null, 2: string}>
-	 */
-	public function provide_unusable_snapshot_billing_payloads(): array {
-		$monthly = array(
-			'period'   => 'month',
-			'interval' => 1,
-		);
-		$decade  = array(
-			'period'   => 'decade',
-			'interval' => 1,
-		);
-		$zero    = array(
-			'period'   => 'month',
-			'interval' => 0,
-		);
-		$rolled  = '2026-05-01 00:00:00';
-		$floored = '2026-04-15 09:30:00';
-
-		return array(
-			'unknown period, live monthly'     => array( $decade, $monthly, $rolled ),
-			'zero interval, live monthly'      => array( $zero, $monthly, $rolled ),
-			'unknown period, live unusable'    => array( $decade, $zero, $floored ),
-			'zero interval, live null payload' => array( $zero, null, $floored ),
-		);
-	}
-
-	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 );
-	}
-
-	/**
-	 * The anchor is cleared after the status write has committed, so a failed delete must
-	 * not abort the reactivation, which could not be retried on an active contract.
-	 */
-	public function test_a_failed_anchor_clear_does_not_abort_the_reactivation(): void {
-		$id = $this->seed_on_hold( null, $this->make_monthly_plan(), ContractStatus::ON_HOLD, array( Hold::ANCHOR_META_KEY => '2099-01-01 00:00:00' ) );
-
-		$fired = 0;
-		add_action(
-			Reactivation::CONTRACT_REACTIVATED_ACTION,
-			static function () use ( &$fired ): void {
-				++$fired;
-			}
-		);
-		$meta_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META );
-		$break      = static function ( string $query ) use ( $meta_table ): string {
-			return 0 === strpos( $query, "DELETE FROM `{$meta_table}`" ) ? 'SELECT broken syntax (' : $query;
-		};
-		add_filter( 'query', $break );
-
-		try {
-			$this->assertTrue( $this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) ) );
-		} finally {
-			remove_filter( 'query', $break );
-		}
-
-		$stored = $this->reload( $id );
-		$this->assertSame( 1, $fired, 'The reactivated action fires.' );
-		$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
-		$this->assertSame( '2099-01-01 00:00:00', $stored->get_next_payment_gmt() );
-	}
-
-	public function test_a_lost_race_keeps_the_hold_anchor(): void {
-		$id    = $this->seed_on_hold( null, $this->make_monthly_plan(), ContractStatus::ON_HOLD, array( Hold::ANCHOR_META_KEY => '2099-01-01 00:00:00' ) );
-		$stale = $this->reload( $id );
-
-		$concurrent = $this->reload( $id );
-		$concurrent->set_status( ContractStatus::CANCELLED );
-		$this->contracts->update( $concurrent );
-
-		try {
-			$this->sut->reactivate( $stale, $this->utc( '2026-06-01 00:00:00' ) );
-			$this->fail( 'Expected a DomainException when the conditional write misses.' );
-		} catch ( DomainException $e ) {
-			$this->assertSame( '2099-01-01 00:00:00', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
-		}
-	}
-
-	public function test_reactivate_rejects_an_already_active_contract(): void {
-		// An active contract past its due date must NOT reach the recompute: rolling its
-		// date forward would skip the charge the due scan owes it.
-		$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(
-				'extension_slug'  => 'engine-tests',
-				'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' ) );
-	}
-
-	public function test_reactivate_rejects_a_pending_cancellation_contract(): void {
-		$id = $this->seed_on_hold( '2099-01-01 00:00:00', $this->make_monthly_plan(), ContractStatus::PENDING_CANCELLATION );
-
-		try {
-			$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
-			$this->fail( 'Expected a DomainException for a pending-cancellation contract.' );
-		} catch ( DomainException $e ) {
-			$stored = $this->reload( $id );
-			$this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
-			$this->assertSame( '2099-01-01 00:00:00', $stored->get_next_payment_gmt(), 'Nothing was written.' );
-		}
-	}
-
-	public function test_reactivate_rejects_an_unregistered_stored_status(): void {
-		global $wpdb;
-
-		$id = $this->seed_on_hold( '2099-01-01 00:00:00', $this->make_monthly_plan() );
-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-		$wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'status' => 'legacy-paused' ), array( 'id' => $id ) );
-
-		try {
-			$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
-			$this->fail( 'Expected a DomainException for an unregistered stored status.' );
-		} catch ( DomainException $e ) {
-			$stored = $this->reload( $id );
-			$this->assertSame( 'legacy-paused', $stored->get_status() );
-			$this->assertSame( '2099-01-01 00:00:00', $stored->get_next_payment_gmt(), 'Nothing was written.' );
-		}
-	}
-
-	/**
-	 * Reload a contract, asserting it still exists (narrows the nullable read).
-	 *
-	 * @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/Ownership/ContractCapabilitiesTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Ownership/ContractCapabilitiesTest.php
new file mode 100644
index 00000000000..578f3b0aefb
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Ownership/ContractCapabilitiesTest.php
@@ -0,0 +1,112 @@
+<?php
+/**
+ * Integration tests for the `manage_subscription_contract` meta capability.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Tests
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Ownership;
+
+use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ContractCapabilities;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ContractCapabilities
+ */
+class ContractCapabilitiesTest extends EngineIntegrationTestCase {
+
+	/**
+	 * The contract's customer and store managers may read and manage it; other customers and
+	 * guests may not.
+	 *
+	 * @testWith ["read_subscription_contract"]
+	 *           ["manage_subscription_contract"]
+	 *
+	 * @param string $capability Capability.
+	 */
+	public function test_customer_and_store_managers_hold_the_capability( string $capability ): void {
+		$customer_id = $this->create_user( 'customer' );
+		$contract    = $this->create_contract( $customer_id );
+
+		$this->assertTrue( user_can( $customer_id, $capability, $contract ) );
+		$this->assertTrue( user_can( $this->create_user( 'shop_manager' ), $capability, $contract ) );
+		$this->assertFalse( user_can( $this->create_user( 'customer' ), $capability, $contract ) );
+		$this->assertFalse( user_can( 0, $capability, $contract ) );
+	}
+
+	/**
+	 * A contract without a customer is for store managers only.
+	 */
+	public function test_contract_without_a_customer_is_for_store_managers(): void {
+		$contract = $this->create_contract( null );
+
+		$this->assertFalse( user_can( 0, 'manage_subscription_contract', $contract ) );
+		$this->assertTrue( user_can( $this->create_user( 'administrator' ), 'manage_subscription_contract', $contract ) );
+	}
+
+	/**
+	 * Anything but a `ContractView` is refused, even for an administrator.
+	 */
+	public function test_requires_a_contract_view(): void {
+		$administrator_id = $this->create_user( 'administrator' );
+		$contract         = $this->create_contract( null );
+
+		$this->assertFalse( user_can( $administrator_id, 'manage_subscription_contract', $contract->get_id() ) );
+		$this->assertFalse( user_can( $administrator_id, 'manage_subscription_contract' ) );
+	}
+
+	/**
+	 * An extension's `map_meta_cap` filter at the default priority builds on the mapping, whichever
+	 * was hooked first.
+	 */
+	public function test_extensions_override_the_mapping_at_the_default_priority(): void {
+		$customer_id = $this->create_user( 'customer' );
+		$contract    = $this->create_contract( $customer_id );
+		$deny_owner  = static function ( $caps, $cap ) {
+			return 'manage_subscription_contract' === $cap && array( 'read' ) === $caps ? array( 'do_not_allow' ) : $caps;
+		};
+
+		remove_filter( 'map_meta_cap', array( ContractCapabilities::class, 'map_meta_cap' ), 0 );
+		add_filter( 'map_meta_cap', $deny_owner, 10, 2 );
+		ContractCapabilities::register_hooks();
+
+		try {
+			$this->assertFalse( user_can( $customer_id, 'manage_subscription_contract', $contract ) );
+			$this->assertTrue( user_can( $customer_id, 'read_subscription_contract', $contract ) );
+		} finally {
+			remove_filter( 'map_meta_cap', $deny_owner, 10 );
+		}
+	}
+
+	/**
+	 * Create a contract.
+	 *
+	 * @param int|null $customer_id Customer id.
+	 */
+	private function create_contract( ?int $customer_id ): ContractView {
+		return Contracts::create(
+			array(
+				'extension_slug' => 'test-extension',
+				'status'         => 'active',
+				'customer_id'    => $customer_id,
+				'currency'       => 'USD',
+			)
+		);
+	}
+
+	/**
+	 * Create a user with a role.
+	 *
+	 * @param string $role Role slug.
+	 */
+	private function create_user( string $role ): int {
+		$user_id = self::factory()->user->create( array( 'role' => $role ) );
+		$this->assertIsInt( $user_id );
+
+		return $user_id;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php
index c0e16f372f5..aa961e25cab 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php
@@ -21,13 +21,12 @@ use DateTimeImmutable;
 use DateTimeZone;
 use EngineIntegrationTestCase;
 use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
@@ -78,7 +77,13 @@ class OwnerScopedDueScanTest extends EngineIntegrationTestCase {

 	public function test_a_held_contract_is_not_renewed_at_or_after_its_former_date(): void {
 		$id = $this->sign_up( self::OWNER );
-		( new Hold( $this->contracts ) )->hold( $this->reload( $id ) );
+		Contracts::update(
+			$id,
+			array(
+				'status'           => ContractStatus::ON_HOLD,
+				'next_payment_gmt' => null,
+			)
+		);

 		$this->run_batch_at( self::FIRST_DUE );
 		$this->run_batch_at( '2026-06-01 00:00:00' );
@@ -91,7 +96,14 @@ class OwnerScopedDueScanTest extends EngineIntegrationTestCase {

 	public function test_a_pending_cancellation_contract_is_not_renewed_at_its_end_date(): void {
 		$id = $this->sign_up( self::OWNER );
-		( new Cancellation( $this->contracts ) )->cancel_at_period_end( $this->reload( $id ) );
+		Contracts::update(
+			$id,
+			array(
+				'status'           => ContractStatus::PENDING_CANCELLATION,
+				'next_payment_gmt' => null,
+				'end_gmt'          => self::FIRST_DUE,
+			)
+		);

 		$this->run_batch_at( self::FIRST_DUE );

diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php
index f9990ab4f58..fa156bf7d06 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php
@@ -21,7 +21,6 @@ use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine;
@@ -1081,84 +1080,6 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$this->assertNull( $this->run_scheduled_renewal( 999999 ) );
 	}

-	/**
-	 * @testdox cancel transitions the contract to cancelled and disarms its next-due moment.
-	 *
-	 * Cancellation clears `next_payment_gmt` itself - the flow disarms its own due moment
-	 * rather than relying on the scan's status predicate.
-	 */
-	public function test_cancel_transitions_the_contract(): void {
-		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
-
-		$plan_id     = $this->make_plan();
-		$order       = $this->make_origin_order();
-		$contract    = $this->make_contract( $plan_id, $order->get_id() );
-		$contract_id = $contract->get_id();
-		$this->assertNotNull( $contract_id );
-
-		$this->assertTrue( ( new Cancellation() )->cancel( $contract ) );
-
-		$reloaded = ( new ContractRepository() )->find( $contract_id );
-		$this->assertInstanceOf( Contract::class, $reloaded );
-		$this->assertSame( ContractStatus::CANCELLED, $reloaded->get_status() );
-		$this->assertNull( $reloaded->get_next_payment_gmt() );
-	}
-
-	/**
-	 * @testdox cancel closes a mid-charge pending cycle.
-	 */
-	public function test_cancel_closes_a_pending_cycle(): void {
-		$contract    = $this->sign_up_contract( self::GATEWAY );
-		$contract_id = $contract->get_id();
-		$this->assertNotNull( $contract_id );
-
-		// Append a pending cycle 2 (a charge caught mid-flight).
-		$repo     = new ContractRepository();
-		$previous = $repo->find_chain_head( $contract_id );
-		$this->assertInstanceOf( Cycle::class, $previous );
-		$pending = Cycle::create(
-			array(
-				'contract_id'    => $contract_id,
-				'sequence_no'    => $previous->get_sequence_no() + 1,
-				'count'          => 2,
-				'status'         => new CycleStatus( CycleStatus::PENDING ),
-				'starts_at_gmt'  => '2026-02-15 00:00:00',
-				'ends_at_gmt'    => '2026-03-15 00:00:00',
-				'expected_total' => '19.99',
-				'currency'       => 'USD',
-			)
-		);
-		$repo->append_cycle( $pending, $previous );
-
-		$this->assertTrue( ( new Cancellation() )->cancel( $contract ) );
-
-		// The contract is terminal and the pending cycle is cancelled.
-		$reloaded = $repo->find( $contract_id );
-		$this->assertInstanceOf( Contract::class, $reloaded );
-		$this->assertSame( ContractStatus::CANCELLED, $reloaded->get_status() );
-
-		$head = $repo->find_chain_head( $contract_id );
-		$this->assertInstanceOf( Cycle::class, $head );
-		$this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::CANCELLED ) ) );
-	}
-
-	/**
-	 * @testdox cancel with only settled cycles leaves them untouched.
-	 */
-	public function test_cancel_leaves_a_settled_cycle_untouched(): void {
-		$contract    = $this->sign_up_contract( self::GATEWAY );
-		$contract_id = $contract->get_id();
-		$this->assertNotNull( $contract_id );
-
-		$this->assertTrue( ( new Cancellation() )->cancel( $contract ) );
-
-		// Cycle 1 stays billed (only a pending head is closed by cancel).
-		$head = ( new ContractRepository() )->find_chain_head( $contract_id );
-		$this->assertInstanceOf( Cycle::class, $head );
-		$this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
-	}
-
-
 	/**
 	 * @testdox the scheduled scan leaves the cycle processing when the charge is neither paid nor failed.
 	 *