Commit 1f4b9b0a3e3 for woocommerce

commit 1f4b9b0a3e343b5519a48522b3148b05cb3ec1fd
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date:   Wed Oct 21 11:41:35 2026 +0200

    Subscriptions engine: enforce a per-contract lock on every contract write (#69875)

    Add a per-contract lock to subscriptions engine writes

    - Add Contracts::lock() and run every facade write under a lock, taking an internal one when none is passed
    - Create every contract locked, with an optional creation key that refuses duplicates
    - Require the lock for cycle writes and refuse child writes once a lock is lost
    - Run due dispatch under a lock conditioned on the due moment and drop the cycle claim
    - Add lock, write_seq and creation_key columns in schema 2.8.0

diff --git a/packages/php/woocommerce-subscriptions-engine/README.md b/packages/php/woocommerce-subscriptions-engine/README.md
index 9a93aa51ef3..997ed95c6a4 100644
--- a/packages/php/woocommerce-subscriptions-engine/README.md
+++ b/packages/php/woocommerce-subscriptions-engine/README.md
@@ -4,6 +4,7 @@ This package implements a general-purpose subscriptions engine for WooCommerce.

 The engine records subscription facts (plans, contracts, billing cycles) and offers tools to work with them; extensions own every flow and decision. In particular:

-- **Due dispatch:** an extension registers a contract due handler with its slug, and the engine calls it when one of its contracts reaches its next due moment. What happens then (renew, skip, clear the due moment) is the extension's decision.
-- **Cycle tools:** `Api\Contracts` offers claimed cycle appends, cycle claims and compare-and-set status transitions, so an extension can run its billing flow safely under concurrency.
+- **Due dispatch:** an extension registers a contract due handler with its slug, and the engine calls it when one of its contracts reaches its next due moment, passing the dispatcher's contract lock. What happens then (renew, skip, clear the due moment) is the extension's decision.
+- **Contract lock:** one lock per contract on the facade. `Contracts::lock()` takes it for an operation (null when another operation holds it) and `Contracts::create()` creates every contract locked from birth (null when its optional creation key is taken); every write takes the lock as its last parameter. Every write runs under a lock: the caller's, or, for an update or meta write made without one, an internal lock the engine takes for that call. Cycle writes require the caller's lock. A write is refused with `ContractLockedException` while another operation holds the lock.
+- **Cycle tools:** cycle appends are append-if-absent on the chain's positions, and status transitions are compare-and-set.
 - **Payments broker:** `Api\Payments` lets an extension ask the order's gateway to take a scheduled payment off-session (`Payments::process_scheduled_payment( $order )`). Gateways opt in by adding the `subscriptions_engine` feature to their `$supports` and charging the order on `woocommerce_subscriptions_engine_scheduled_payment_{gateway_id}( $order )`, reporting the outcome through the order status. What a gateway can do beyond that (renewal payments, payment method changes, ...) it declares with the existing WooCommerce gateway features; extensions check the ones an operation needs with `Payments::gateway_supports( $gateway_id, $features )`, and the engine does not interpret them.
diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-contract-lock b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-contract-lock
new file mode 100644
index 00000000000..793d0264ce4
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-contract-lock
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Add a per-contract lock and creation key to the unreleased subscriptions engine; no changelog entry needed.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/ContractLock.php b/packages/php/woocommerce-subscriptions-engine/src/Api/ContractLock.php
new file mode 100644
index 00000000000..6103a1f1eba
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/ContractLock.php
@@ -0,0 +1,181 @@
+<?php
+/**
+ * ContractLock - a held per-contract lock, as returned by {@see Contracts::lock()},
+ * {@see Contracts::create()} and the due dispatcher.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
+
+use LogicException;
+use Serializable;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractLockState;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * One operation's hold on one contract for one request: writes that pass it belong to the
+ * holder's operation, and other writes are refused with {@see ContractLockedException}. It
+ * guards honest callers against accidental interleaving, not code set on bypassing it
+ * (reflection, closures, direct SQL).
+ */
+final class ContractLock implements Serializable {
+
+	/**
+	 * Lock lifetime in seconds, from the database clock at acquire or extend.
+	 */
+	public const TTL_SECONDS = 600;
+
+	/**
+	 * Contract id.
+	 *
+	 * @var int
+	 */
+	private $contract_id;
+
+	/**
+	 * Operation label.
+	 *
+	 * @var string
+	 */
+	private $operation;
+
+	/**
+	 * Token and trust state.
+	 *
+	 * @var ContractLockState
+	 */
+	private $state;
+
+	/**
+	 * Take locks through {@see Contracts::lock()} or {@see Contracts::create()}. A lock built
+	 * by any caller but the engine repository gets `_doing_it_wrong()`: it would bypass the
+	 * lock lifetime.
+	 *
+	 * @param int    $contract_id Contract id.
+	 * @param string $operation   Operation label.
+	 * @param string $token       The stored token.
+	 */
+	public function __construct( int $contract_id, string $operation, string $token ) {
+		if ( ! self::is_called_by_repository() ) {
+			_doing_it_wrong( __METHOD__, esc_html__( 'Take a contract lock with Contracts::lock() or Contracts::create(); a lock built from a stored token bypasses the lock lifetime.', 'woocommerce-subscriptions-engine' ), '0.0.1' );
+		}
+
+		$this->contract_id = $contract_id;
+		$this->operation   = $operation;
+		$this->state       = new ContractLockState( $token );
+	}
+
+	/**
+	 * The locked contract's id.
+	 */
+	public function get_contract_id(): int {
+		return $this->contract_id;
+	}
+
+	/**
+	 * The operation label the lock was taken with.
+	 */
+	public function get_operation(): string {
+		return $this->operation;
+	}
+
+	/**
+	 * The token and trust state, engine-only: pass the lock to the `Contracts` write methods
+	 * instead. Any caller but the engine repository gets `_doing_it_wrong()`.
+	 */
+	public function get_state(): ContractLockState {
+		if ( ! self::is_called_by_repository() ) {
+			_doing_it_wrong( __METHOD__, esc_html__( 'The lock state is engine-only; pass the lock to Contracts write methods instead.', 'woocommerce-subscriptions-engine' ), '0.0.1' );
+		}
+
+		return $this->state;
+	}
+
+	/**
+	 * Release the lock. Idempotent; never clears a lock another operation took over.
+	 *
+	 * @throws \RuntimeException If the release write fails (the lock then expires on its own).
+	 */
+	public function release(): void {
+		( new ContractRepository() )->release_lock( $this );
+	}
+
+	/**
+	 * Debug output without the token.
+	 *
+	 * @return array<string, mixed>
+	 */
+	public function __debugInfo(): array {
+		return array(
+			'contract_id' => $this->contract_id,
+			'operation'   => $this->operation,
+		);
+	}
+
+	// phpcs:disable Squiz.Commenting.FunctionComment.InvalidNoReturn -- always throws.
+	/**
+	 * A lock never leaves the request.
+	 *
+	 * @return array<string, mixed>
+	 * @throws LogicException Always.
+	 */
+	public function __serialize(): array {
+		throw new LogicException( 'A contract lock cannot be serialized.' );
+	}
+	// phpcs:enable Squiz.Commenting.FunctionComment.InvalidNoReturn
+
+	/**
+	 * A lock never leaves the request.
+	 *
+	 * @param array<string, mixed> $data Serialized data.
+	 * @throws LogicException Always.
+	 */
+	public function __unserialize( array $data ): void {
+		throw new LogicException( 'A contract lock cannot be unserialized.' );
+	}
+
+	// phpcs:disable Squiz.Commenting.FunctionComment.InvalidNoReturn -- always throws.
+	/**
+	 * A lock never leaves the request. {@see Serializable} is implemented only so that a legacy
+	 * `C:` payload, which skips `__unserialize()`, is refused too.
+	 *
+	 * @return string|null
+	 * @throws LogicException Always.
+	 */
+	public function serialize() {
+		throw new LogicException( 'A contract lock cannot be serialized.' );
+	}
+	// phpcs:enable Squiz.Commenting.FunctionComment.InvalidNoReturn
+
+	/**
+	 * Refuse a legacy `C:` payload, which would otherwise build a lock without its constructor.
+	 *
+	 * @param string $data Serialized data.
+	 * @throws LogicException Always.
+	 */
+	public function unserialize( $data ): void {
+		throw new LogicException( 'A contract lock cannot be unserialized.' );
+	}
+
+	/**
+	 * A lock has one holder.
+	 *
+	 * @throws LogicException Always.
+	 */
+	public function __clone() {
+		throw new LogicException( 'A contract lock cannot be cloned.' );
+	}
+
+	/**
+	 * Whether the caller of the public method that asks (two frames up) is the engine repository.
+	 */
+	private static function is_called_by_repository(): bool {
+		// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- three frames, no arguments: only the caller's class is read.
+		return ContractRepository::class === ( debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 3 )[2]['class'] ?? '' );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/ContractLockedException.php b/packages/php/woocommerce-subscriptions-engine/src/Api/ContractLockedException.php
new file mode 100644
index 00000000000..9491a50e804
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/ContractLockedException.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * ContractLockedException - a contract operation refused because another operation
+ * holds the contract's lock.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
+
+use RuntimeException;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Thrown when a write is refused because another operation holds the contract's lock,
+ * or because the caller's own lock was lost. Carries the holder's operation label (null
+ * when the holder released meanwhile), never a token.
+ */
+final class ContractLockedException extends RuntimeException {
+
+	/**
+	 * Contract id.
+	 *
+	 * @var int
+	 */
+	private $contract_id;
+
+	/**
+	 * The holder's operation label, or null.
+	 *
+	 * @var string|null
+	 */
+	private $operation;
+
+	/**
+	 * Describe the conflict.
+	 *
+	 * @param int         $contract_id Contract id.
+	 * @param string|null $operation   The holder's operation label, or null when unknown.
+	 */
+	public function __construct( int $contract_id, ?string $operation ) {
+		$message = null === $operation
+			? sprintf( 'Contract %d is locked by another operation.', $contract_id )
+			: sprintf( 'Contract %d is locked by another operation (%s).', $contract_id, $operation );
+
+		parent::__construct( $message ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the message is not output.
+
+		$this->contract_id = $contract_id;
+		$this->operation   = $operation;
+	}
+
+	/**
+	 * Contract id.
+	 */
+	public function get_contract_id(): int {
+		return $this->contract_id;
+	}
+
+	/**
+	 * The holder's operation label, or null when unknown.
+	 */
+	public function get_operation(): ?string {
+		return $this->operation;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
index 4e53c422c55..9f72b0a4853 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
@@ -3,11 +3,14 @@
  * Contracts - the engine's public contract facade (reads and writes).
  *
  * Extensions create and progressively build contracts from explicit argument arrays:
- * the engine records the facts it is given and decides nothing about them. Cycle tools
- * (claimed appends, cycle claims, compare-and-set transitions) let an extension run its own
- * billing flow safely under concurrency. Any caller may read or write any contract
- * (authorization is the caller's concern). Reads return read-only views ({@see ContractView},
- * {@see CycleView}). The engine opens no transaction and keeps no cache. No hooks fire.
+ * the engine records the facts it is given and decides nothing about them. Any caller may
+ * read or write any contract (authorization is the caller's concern). Reads return read-only
+ * views ({@see ContractView}, {@see CycleView}). No transactions, no cache, no hooks.
+ *
+ * One lock per contract ({@see self::lock()}): every write runs under a lock, the caller's or,
+ * for an update or meta write made without one, an internal one the engine takes for that call.
+ * Cycle writes require the caller's lock. A write is refused with {@see ContractLockedException}
+ * while another operation holds the lock.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Api
  */
@@ -66,11 +69,29 @@ final class Contracts {
 	);

 	/**
-	 * Keys accepted by {@see self::create()}: the update keys plus the create-only `extension_slug`.
+	 * Keys accepted by {@see self::create()}: the update keys plus the create-only `extension_slug`
+	 * and `creation_key`.
 	 *
 	 * @var array<string, true>
 	 */
-	private const CREATE_KEYS = array( 'extension_slug' => true ) + self::CONTRACT_KEYS;
+	private const CREATE_KEYS = array(
+		'extension_slug' => true,
+		'creation_key'   => true,
+	) + self::CONTRACT_KEYS;
+
+	/**
+	 * Option keys accepted by {@see self::create()}.
+	 *
+	 * @var array<string, true>
+	 */
+	private const CREATE_OPTION_KEYS = array(
+		'operation' => true,
+	);
+
+	/**
+	 * Lock operation label of a new contract when the caller names none.
+	 */
+	private const CREATE_OPERATION = 'create';

 	/**
 	 * Keys accepted by {@see self::add_cycle()}, as a key map.
@@ -87,7 +108,6 @@ final class Contracts {
 		'expected_total' => true,
 		'currency'       => true,
 		'order_id'       => true,
-		'claim_seconds'  => true,
 	);

 	/**
@@ -96,74 +116,127 @@ final class Contracts {
 	 * @var array<string, true>
 	 */
 	private const TRANSITION_KEYS = array(
-		'order_id'      => true,
-		'reason'        => true,
-		'claim_seconds' => true,
+		'from_status' => true,
+		'to_status'   => true,
+		'order_id'    => true,
+		'reason'      => true,
 	);

 	/**
-	 * Create a contract from explicit fields.
-	 *
-	 * Only `extension_slug` is required; the status defaults to `draft`. Dates accept a
-	 * `DateTimeInterface` or a GMT `Y-m-d H:i:s` string; money accepts numbers or numeric
-	 * strings, and a non-null money value requires a currency. `items` is a list of item
-	 * rows; `addresses` is keyed `billing` / `shipping`. `schedule_source` says who keeps the
-	 * payment schedule: `subscriptions_engine` (the default; the engine notifies the owner when
-	 * `next_payment_gmt` passes) or the slug (1-64 of `a-z0-9_-`) of a gateway or extension that
-	 * keeps its own schedule and triggers its own renewals; the engine never dispatches those
-	 * contracts. Unknown keys, also inside item rows and addresses, raise a `_doing_it_wrong()`
-	 * notice and are ignored. The contract row, items and addresses are separate writes and the
-	 * engine opens no transaction: wrap the call in one when a failed write must leave nothing
-	 * behind.
-	 *
-	 * @param array<string, mixed> $args Contract fields: `extension_slug` (required, the owning
-	 *                                   extension), `status` (a registered contract status,
-	 *                                   default `draft`), and any of {@see self::CONTRACT_KEYS}.
-	 * @return ContractView The new contract, built from the written fields (no re-read): items
-	 *                      and addresses as given.
-	 * @throws InvalidArgumentException If `extension_slug` is missing or a value is invalid.
+	 * Take the contract's lock for `$operation`, for the synchronous section of this request.
+	 * Pass the lock to every write of the operation and release it when done; an unreleased
+	 * lock expires after {@see ContractLock::TTL_SECONDS}. An expired lock is taken over. A lock
+	 * gives no fresh read snapshot: inside the caller's own transaction, read after taking it.
+	 *
+	 * @param int    $contract_id Contract id.
+	 * @param string $operation   Operation label (1-64 of `[a-z0-9_-]`), shown to callers that conflict.
+	 * @return ContractLock|null The held lock; null when another operation holds it or the contract does not exist.
+	 * @throws InvalidArgumentException If the label is invalid.
 	 */
-	public static function create( array $args ): ContractView {
-		$filtered_args  = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::CREATE_KEYS );
-		$extension_slug = ArgumentValidator::validate_nullable_string( 'extension_slug', $filtered_args['extension_slug'] ?? null );
-		unset( $filtered_args['extension_slug'] );
+	public static function lock( int $contract_id, string $operation ): ?ContractLock {
+		return ( new ContractRepository() )->acquire_lock( $contract_id, $operation );
+	}

-		try {
-			$contract = Contract::create( array( 'extension_slug' => $extension_slug ) );
-			self::apply( $contract, $filtered_args );
-		} catch ( DomainException $e ) {
-			throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
-		}
+	/**
+	 * Create a contract locked from birth: finish building it (cycles, status) with the returned
+	 * lock, then release it. A `creation_key` (1-100 characters, trimmed) makes the create
+	 * idempotent per extension, also inside a caller's transaction (there is no lock to take
+	 * first, so a taken key is recognised by its error number, not a read). Without a key every
+	 * call creates a contract. Unknown keys and options get `_doing_it_wrong()` and are ignored.
+	 *
+	 * @param array<string, mixed> $args    `extension_slug` (required, the owner), `status` (default
+	 *                                      `draft`), `creation_key`, and any of {@see self::CONTRACT_KEYS}.
+	 *                                      Dates take a `DateTimeInterface` or GMT `Y-m-d H:i:s`;
+	 *                                      money needs a currency. `schedule_source` other than
+	 *                                      `subscriptions_engine` (the default) is never dispatched.
+	 * @param array<string, mixed> $options `operation`: the lock's label (1-64 of `[a-z0-9_-]`, default `create`).
+	 * @return array{contract: ContractView, lock: ContractLock}|null The contract as written (no re-read) and its
+	 *                                                                lock; null when `creation_key` is taken.
+	 * @throws InvalidArgumentException If `extension_slug` is missing, or a value or the operation label is invalid.
+	 */
+	public static function create( array $args, array $options = array() ): ?array {
+		$filtered_options = ArgumentValidator::filter_known_keys( __METHOD__, $options, self::CREATE_OPTION_KEYS, 'option' );
+		$operation        = ArgumentValidator::validate_string( 'operation', $filtered_options['operation'] ?? self::CREATE_OPERATION );
+		$contract         = self::build( $args );

-		( new ContractRepository() )->insert( $contract );
+		$lock = ( new ContractRepository() )->insert( $contract, $operation );
+		if ( null === $lock ) {
+			return null;
+		}

-		return ContractView::from_contract( $contract, true );
+		return array(
+			'contract' => ContractView::from_contract( $contract, true ),
+			'lock'     => $lock,
+		);
 	}

+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
 	/**
-	 * Write the given fields to an existing contract.
-	 *
-	 * Takes the keys of {@see self::create()} except `extension_slug`. Only the columns of
-	 * the present keys are written, so fields a concurrent writer changed in between keep
-	 * its values; `items` and `addresses` replace the whole set; `null` clears a
-	 * nullable field (and resets a money field to 0). Unknown keys (`extension_slug`
-	 * included) raise a `_doing_it_wrong()` notice and are ignored. Items and addresses are
-	 * replaced delete-then-insert and the engine opens no transaction: wrap the call in one
-	 * when a failed replacement must keep the previous rows.
+	 * Write the given fields (the keys of {@see self::create()} except `extension_slug`) to an
+	 * existing contract. Only the present keys are written; `items` and `addresses` replace the
+	 * whole set; `null` clears a nullable field (a money field to 0). Unknown keys raise a
+	 * `_doing_it_wrong()` notice and are ignored.
 	 *
 	 * @param int                  $contract_id Contract id.
 	 * @param array<string, mixed> $args        Fields to write.
-	 * @return ContractView|null The row as read before the write plus the written fields (a column
-	 *                           another writer changed meanwhile may be stale here, not in storage);
-	 *                           null when the contract does not exist (also when it is deleted
-	 *                           before the write).
-	 * @throws InvalidArgumentException If a value is invalid.
+	 * @param ContractLock|null    $lock        The caller's lock on this contract, or null for an internal one.
+	 * @return ContractView|null The row as read before the write plus the written fields; null
+	 *                           when the contract does not exist (also when it is deleted before the write).
+	 * @throws InvalidArgumentException If a value is invalid, or the lock is for another contract.
+	 * @throws ContractLockedException If another operation holds the lock, or `$lock` was lost.
 	 */
-	public static function update( int $contract_id, array $args ): ?ContractView {
+	public static function update( int $contract_id, array $args, ?ContractLock $lock = null ): ?ContractView {
+		if ( null !== $lock ) {
+			ContractRepository::assert_lock_for( $lock, $contract_id );
+		}
+
 		$filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::CONTRACT_KEYS );
+		$repository    = new ContractRepository();
+
+		if ( array() === $filtered_args ) {
+			$contract = $repository->find( $contract_id );
+			return null === $contract ? null : ContractView::from_contract( $contract, true );
+		}
+
+		if ( null !== $lock ) {
+			$contract = self::update_contract( $repository, $contract_id, $filtered_args, $lock );
+			return null === $contract ? null : ContractView::from_contract( $contract, true );
+		}
+
+		// The internal lock covers the read the write is validated against, not only the write.
+		$internal_lock = $repository->acquire_internal_lock( $contract_id );
+		if ( null === $internal_lock ) {
+			return null;
+		}
+		try {
+			$contract = self::update_contract( $repository, $contract_id, $filtered_args, $internal_lock );
+		} finally {
+			$repository->release_internal_lock( $internal_lock );
+		}
+		if ( null === $contract ) {
+			return null;
+		}
+
+		// The view reports no lock: the one read with the row was the internal lock, now released.
+		$contract->clear_lock_state();
+
+		return ContractView::from_contract( $contract, true );
+	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

-		$repository = new ContractRepository();
-		$contract   = $repository->find( $contract_id );
+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
+	/**
+	 * Read the contract, apply and validate the fields, and write them behind {@see self::update()}.
+	 *
+	 * @param ContractRepository   $repository    Repository.
+	 * @param int                  $contract_id   Contract id.
+	 * @param array<string, mixed> $filtered_args Known fields to write.
+	 * @param ContractLock         $lock          The held lock.
+	 * @return Contract|null The contract with the written fields; null when it does not exist.
+	 * @throws InvalidArgumentException If a value is invalid.
+	 */
+	private static function update_contract( ContractRepository $repository, int $contract_id, array $filtered_args, ContractLock $lock ): ?Contract {
+		$contract = $repository->find( $contract_id );
 		if ( null === $contract ) {
 			return null;
 		}
@@ -174,44 +247,38 @@ final class Contracts {
 			throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
 		}

-		$fields = array_keys( $filtered_args );
-		if ( array() === $fields ) {
-			return ContractView::from_contract( $contract, true );
-		}
-
-		if ( ! $repository->update_fields( $contract, $fields ) ) {
+		$updated = $repository->update_fields( $contract, array_keys( $filtered_args ), $lock );
+		if ( ! $updated ) {
 			return null;
 		}

-		return ContractView::from_contract( $contract, true );
+		return $contract;
 	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
 	/**
-	 * Append a cycle to a contract's chain `(contract_id, kind)`.
-	 *
-	 * The first public form of the cycle append tool: append-if-absent on the chain's
-	 * unique positions. `starts_at_gmt`, `ends_at_gmt` and `currency` are required.
-	 * `status` (a registered cycle status) defaults to `pending`; `kind` defaults to
-	 * `billing`; an absent or null `sequence_no` is assigned on append as the head's plus one;
-	 * `count` is the caller's chargeable number (absent or null for a non-counting cycle;
-	 * unique within the chain); `expected_total` defaults to 0; `order_id` is optional.
-	 * `claim_seconds` (positive int) appends the cycle claimed: the caller holds its claim for
-	 * that long, measured on the database clock like {@see self::claim_cycle()}. The contract is not read: appending to an
-	 * unknown contract id is a caller error. Unknown keys raise a `_doing_it_wrong()` notice
-	 * and are ignored.
-	 *
-	 * Losing the append race is an outcome, not an error: when the chain position or count is
-	 * already taken (another caller appended it first), nothing is written and the call returns
-	 * null, as {@see self::claim_cycle()} and {@see self::transition_cycle()} return false when
-	 * their compare-and-set loses. A storage failure, such as a value longer than its column
-	 * (`kind`), throws a `RuntimeException` and writes nothing.
+	 * Append a cycle to a contract's chain `(contract_id, kind)`, append-if-absent on the chain's
+	 * unique positions: a taken position or count is a lost race and returns null. A storage
+	 * failure throws a `RuntimeException` and writes nothing. The lock is the contract's
+	 * existence check: the engine deletes a contract only to discard a failed create, before its
+	 * lock is handed out. Unknown keys raise a `_doing_it_wrong()` notice.
 	 *
 	 * @param int                  $contract_id Contract id.
-	 * @param array<string, mixed> $args        Cycle fields.
+	 * @param array<string, mixed> $args        Cycle fields: `starts_at_gmt`, `ends_at_gmt` and
+	 *                                          `currency` (required); `status` (registered, default
+	 *                                          `pending`); `kind` (default `billing`); `sequence_no`
+	 *                                          (null: the head's plus one); `count` (null: not
+	 *                                          counted; unique in the chain); `expected_total`
+	 *                                          (default 0); `order_id`.
+	 * @param ContractLock         $lock        The caller's lock on this contract, from {@see self::lock()},
+	 *                                          {@see self::create()} or the due handler's context.
 	 * @return CycleView|null The appended cycle, or null when the position or count is already taken.
-	 * @throws InvalidArgumentException If a required key is missing or a value is invalid.
+	 * @throws InvalidArgumentException If a required key is missing, a value is invalid, or the lock
+	 *                                  is for another contract.
+	 * @throws ContractLockedException If `$lock` was lost.
 	 */
-	public static function add_cycle( int $contract_id, array $args ): ?CycleView {
+	public static function add_cycle( int $contract_id, array $args, ContractLock $lock ): ?CycleView {
 		$filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::CYCLE_KEYS );

 		$cycle_args = array(
@@ -227,10 +294,6 @@ final class Contracts {
 			'order_id'       => ArgumentValidator::validate_nullable_id( 'order_id', $filtered_args['order_id'] ?? null ),
 		);

-		$claim_seconds = array_key_exists( 'claim_seconds', $filtered_args )
-			? ArgumentValidator::validate_positive_int( 'claim_seconds', $filtered_args['claim_seconds'] )
-			: null;
-
 		try {
 			$cycle = Cycle::create( $cycle_args );
 		} catch ( DomainException $e ) {
@@ -238,49 +301,38 @@ final class Contracts {
 		}

 		try {
-			( new ContractRepository() )->append_cycle( $cycle, $claim_seconds );
+			( new ContractRepository() )->append_cycle( $cycle, $lock );
 		} catch ( DuplicateCycleException $e ) {
 			return null;
 		}

 		return CycleView::from_cycle( $cycle );
 	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
 	/**
-	 * Take the claim on a cycle still in `$status` whose claim expired or was never set. A
-	 * compare-and-set on the status and the claim: of concurrent callers that read `$status`,
-	 * exactly one gets true and holds the cycle for `$claim_seconds`; the others get false.
-	 * Claims are measured on the database clock.
-	 *
-	 * @param int    $cycle_id      Cycle id.
-	 * @param string $status        The status the caller read; the cycle must still carry it.
-	 * @param int    $claim_seconds Claim length in seconds (positive).
-	 * @return bool True when the caller now holds the cycle.
-	 * @throws InvalidArgumentException If `$claim_seconds` is not positive.
-	 */
-	public static function claim_cycle( int $cycle_id, string $status, int $claim_seconds ): bool {
-		return ( new ContractRepository() )->claim_cycle( $cycle_id, $status, ArgumentValidator::validate_positive_int( 'claim_seconds', $claim_seconds ) );
-	}
-
-	/**
-	 * Move a cycle from `$from_status` to `$to_status` as a compare-and-set on its status: of
-	 * concurrent callers that read `$from_status`, exactly one gets true. `$args`: `order_id`
-	 * (?int) and `reason` (?string) are written when present (omitted keeps the stored value,
-	 * null clears it); `claim_seconds` (int) holds the cycle for that long, and without it the
-	 * claim is released. Unknown keys raise a `_doing_it_wrong()` notice and are ignored.
-	 * A same-status call (`$from_status === $to_status`) only writes `$args`, so it returns
-	 * true whenever the cycle still carries the status, even when the values were already stored.
-	 *
-	 * @param int                  $cycle_id    Cycle id.
-	 * @param string               $from_status The status the caller read.
-	 * @param string               $to_status   The status to write; a registered cycle status.
-	 * @param array<string, mixed> $args        Optional `order_id`, `reason`, `claim_seconds`.
+	 * Move a cycle between statuses as a compare-and-set: of concurrent callers that read
+	 * `from_status`, exactly one gets true. A same-status call returns true once the cycle
+	 * carries the status and the values. Unknown keys raise a `_doing_it_wrong()` notice.
+	 *
+	 * @param int                  $cycle_id Cycle id.
+	 * @param array<string, mixed> $args     `from_status` (required, the status the caller read),
+	 *                                       `to_status` (required, registered), and `order_id`
+	 *                                       (?int) and `reason` (?string), written when present
+	 *                                       (null clears).
+	 * @param ContractLock         $lock     The caller's lock on the cycle's contract, from {@see self::lock()},
+	 *                                       {@see self::create()} or the due handler's context.
 	 * @return bool True when this call made the transition, or for a same-status call when the
-	 *              cycle carries the status.
-	 * @throws InvalidArgumentException If `$to_status` is not registered or a value is invalid.
+	 *              cycle carries the status and the values.
+	 * @throws InvalidArgumentException If a status is missing or unregistered, a value is invalid,
+	 *                                  the cycle does not exist, or it belongs to another contract than the lock.
+	 * @throws ContractLockedException If `$lock` was lost.
 	 */
-	public static function transition_cycle( int $cycle_id, string $from_status, string $to_status, array $args = array() ): bool {
+	public static function transition_cycle( int $cycle_id, array $args, ContractLock $lock ): bool {
 		$filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::TRANSITION_KEYS );
+		$from_status   = ArgumentValidator::validate_non_empty_string( 'from_status', $filtered_args['from_status'] ?? null );
+		$to_status     = ArgumentValidator::validate_non_empty_string( 'to_status', $filtered_args['to_status'] ?? null );
 		$changes       = array();

 		if ( array_key_exists( 'order_id', $filtered_args ) ) {
@@ -289,16 +341,14 @@ final class Contracts {
 		if ( array_key_exists( 'reason', $filtered_args ) ) {
 			$changes['reason'] = ArgumentValidator::validate_nullable_string( 'reason', $filtered_args['reason'] );
 		}
-		if ( array_key_exists( 'claim_seconds', $filtered_args ) ) {
-			$changes['claim_seconds'] = ArgumentValidator::validate_positive_int( 'claim_seconds', $filtered_args['claim_seconds'] );
-		}

 		try {
-			return ( new ContractRepository() )->transition_cycle( $cycle_id, $from_status, $to_status, $changes );
+			return ( new ContractRepository() )->transition_cycle( $cycle_id, $from_status, $to_status, $changes, $lock );
 		} catch ( DomainException $e ) {
 			throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the repository message is not output.
 		}
 	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

 	/**
 	 * The cycles linked to an order, newest first, across contracts and chain kinds (one
@@ -313,50 +363,52 @@ final class Contracts {

 	/**
 	 * Add a meta value to a contract, like `add_post_meta()`. A key may hold several values.
-	 * The contract is not looked up: meta for an unknown contract id is a caller error.
 	 *
-	 * @param int    $contract_id Contract id.
-	 * @param string $key         Meta key.
-	 * @param mixed  $value       Meta value; serialized when not scalar.
-	 * @param bool   $unique      When true, add nothing if the key already exists. Advisory: checked
-	 *                            before the insert with no unique index, so concurrent adds can both write.
+	 * @param int               $contract_id Contract id.
+	 * @param string            $key         Meta key.
+	 * @param mixed             $value       Meta value; serialized when not scalar.
+	 * @param bool              $unique      When true, add nothing if the key already exists.
+	 * @param ContractLock|null $lock        The caller's lock on this contract, or null for an internal one.
 	 * @return int|null The meta row id; null when `$unique` and the key exists.
-	 * @throws InvalidArgumentException If `$key` is empty.
+	 * @throws InvalidArgumentException If `$key` is empty, the contract does not exist, or the lock is for another contract.
+	 * @throws ContractLockedException If another operation holds the lock, or `$lock` was lost.
 	 */
-	public static function add_meta( int $contract_id, string $key, $value, bool $unique = false ): ?int {
-		return ( new ContractRepository() )->add_meta( $contract_id, $key, $value, $unique );
+	public static function add_meta( int $contract_id, string $key, $value, bool $unique = false, ?ContractLock $lock = null ): ?int {
+		return ( new ContractRepository() )->add_meta( $contract_id, $key, $value, $unique, $lock );
 	}

 	/**
 	 * Update a contract's meta values for `$key`, like `update_post_meta()`: adds the key
 	 * when absent, else rewrites every value, or only the values equal to `$prev_value`.
-	 * The absent-key check runs before the write with no unique index, so it is not a lock.
-	 * The contract is not looked up: meta for an unknown contract id is a caller error.
 	 *
-	 * @param int    $contract_id Contract id.
-	 * @param string $key         Meta key.
-	 * @param mixed  $value       New value; serialized when not scalar.
-	 * @param mixed  $prev_value  Only update values equal to this; null updates all. Any other
-	 *                            value ('' and false included) matches literally.
+	 * @param int               $contract_id Contract id.
+	 * @param string            $key         Meta key.
+	 * @param mixed             $value       New value; serialized when not scalar.
+	 * @param mixed             $prev_value  Only update values equal to this; null updates all. Any other
+	 *                                       value ('' and false included) matches literally.
+	 * @param ContractLock|null $lock        The caller's lock on this contract, or null for an internal one.
 	 * @return bool True when a value was added or changed; false when nothing changed.
-	 * @throws InvalidArgumentException If `$key` is empty.
+	 * @throws InvalidArgumentException If `$key` is empty, the contract does not exist, or the lock is for another contract.
+	 * @throws ContractLockedException If another operation holds the lock, or `$lock` was lost.
 	 */
-	public static function update_meta( int $contract_id, string $key, $value, $prev_value = null ): bool {
-		return ( new ContractRepository() )->update_meta( $contract_id, $key, $value, $prev_value );
+	public static function update_meta( int $contract_id, string $key, $value, $prev_value = null, ?ContractLock $lock = null ): bool {
+		return ( new ContractRepository() )->update_meta( $contract_id, $key, $value, $prev_value, $lock );
 	}

 	/**
 	 * Delete a contract's meta values for `$key`, like `delete_post_meta()`.
 	 *
-	 * @param int    $contract_id Contract id.
-	 * @param string $key         Meta key.
-	 * @param mixed  $value       Only delete values equal to this; null deletes every value for the key.
-	 *                            Any other value ('' and false included) matches literally.
-	 * @return bool True when at least one value was deleted.
-	 * @throws InvalidArgumentException If `$key` is empty.
+	 * @param int               $contract_id Contract id.
+	 * @param string            $key         Meta key.
+	 * @param mixed             $value       Only delete values equal to this; null deletes every value for the key.
+	 *                                       Any other value ('' and false included) matches literally.
+	 * @param ContractLock|null $lock        The caller's lock on this contract, or null for an internal one.
+	 * @return bool True when at least one value was deleted; false also for a missing contract.
+	 * @throws InvalidArgumentException If `$key` is empty, or the lock is for another contract.
+	 * @throws ContractLockedException If another operation holds the lock, or `$lock` was lost.
 	 */
-	public static function delete_meta( int $contract_id, string $key, $value = null ): bool {
-		return ( new ContractRepository() )->delete_meta( $contract_id, $key, $value );
+	public static function delete_meta( int $contract_id, string $key, $value = null, ?ContractLock $lock = null ): bool {
+		return ( new ContractRepository() )->delete_meta( $contract_id, $key, $value, $lock );
 	}

 	/**
@@ -521,6 +573,33 @@ final class Contracts {
 		return self::cycle_views( ( new ContractRepository() )->find_cycle_history( $contract_id, Cycle::KIND_BILLING, $limit, $offset ) );
 	}

+	/**
+	 * Validate create arguments into a new, unsaved contract.
+	 *
+	 * @param array<string, mixed> $args Caller fields.
+	 * @throws InvalidArgumentException If `extension_slug` is missing or a value is invalid.
+	 */
+	private static function build( array $args ): Contract {
+		$filtered_args  = ArgumentValidator::filter_known_keys( self::class . '::create', $args, self::CREATE_KEYS );
+		$extension_slug = ArgumentValidator::validate_nullable_string( 'extension_slug', $filtered_args['extension_slug'] ?? null );
+		$creation_key   = ArgumentValidator::validate_nullable_string( 'creation_key', $filtered_args['creation_key'] ?? null );
+		unset( $filtered_args['extension_slug'], $filtered_args['creation_key'] );
+
+		try {
+			$contract = Contract::create(
+				array(
+					'extension_slug' => $extension_slug,
+					'creation_key'   => $creation_key,
+				)
+			);
+			self::apply( $contract, $filtered_args );
+		} catch ( DomainException $e ) {
+			throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
+		}
+
+		return $contract;
+	}
+
 	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- the DomainException comes from the entity setters, not a throw in this method.
 	/**
 	 * Validate the caller's field shapes and apply them to a contract through its setters,
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/View/ContractView.php b/packages/php/woocommerce-subscriptions-engine/src/Api/View/ContractView.php
index 0d651e317f3..a371fb83d4c 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/View/ContractView.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/View/ContractView.php
@@ -14,6 +14,8 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Api\View;

+use DateTimeImmutable;
+use DateTimeZone;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;

 defined( 'ABSPATH' ) || exit;
@@ -44,6 +46,13 @@ final class ContractView {
 	 */
 	private $addresses;

+	/**
+	 * The live lock's expiry and operation label as of the read, or null.
+	 *
+	 * @var array{locked_until: DateTimeImmutable, operation: string}|null
+	 */
+	private $lock_state;
+
 	/**
 	 * Use {@see self::from_contract()}.
 	 */
@@ -89,6 +98,12 @@ final class ContractView {
 		$view->items     = $with_children ? array_map( array( self::class, 'item' ), $contract->get_items() ) : null;
 		$view->addresses = $with_children ? array_map( array( self::class, 'address' ), $contract->get_addresses() ) : null;

+		$lock_state       = $contract->get_lock_state();
+		$view->lock_state = null === $lock_state ? null : array(
+			'locked_until' => new DateTimeImmutable( $lock_state['locked_until_gmt'], new DateTimeZone( 'UTC' ) ),
+			'operation'    => $lock_state['operation'],
+		);
+
 		return $view;
 	}

@@ -294,4 +309,15 @@ final class ContractView {
 	public function get_addresses(): ?array {
 		return $this->addresses;
 	}
+
+	/**
+	 * The contract's live lock as of the read, whoever holds it (the caller included): its
+	 * expiry (UTC) and operation label, or null when it was not locked or the view was built from
+	 * written fields (as from `create()`). Never carries the lock token.
+	 *
+	 * @return array{locked_until: DateTimeImmutable, operation: string}|null
+	 */
+	public function get_lock_state(): ?array {
+		return $this->lock_state;
+	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/View/CycleView.php b/packages/php/woocommerce-subscriptions-engine/src/Api/View/CycleView.php
index eec4e3c508e..eb84a6c0480 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/View/CycleView.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/View/CycleView.php
@@ -43,7 +43,7 @@ final class CycleView {
 	private $kind;

 	/**
-	 * Position in the chain.
+	 * Position in the chain, from 1.
 	 *
 	 * @var int
 	 */
@@ -158,7 +158,7 @@ final class CycleView {
 	}

 	/**
-	 * Position in the chain.
+	 * Position in the chain, from 1.
 	 */
 	public function get_sequence_no(): int {
 		return $this->sequence_no;
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
index 29fbc8508eb..35f9c649a6e 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
@@ -238,6 +238,21 @@ final class Contract {
 	 */
 	private $schedule_source;

+	/**
+	 * The extension's create-only idempotency key, or null. Unique per extension slug.
+	 *
+	 * @var string|null
+	 */
+	private $creation_key;
+
+	/**
+	 * Read-only lock state, or null when no lock is live. Hydrated from the repository's
+	 * computed `lock_live` flag (the database clock decides liveness); never the token.
+	 *
+	 * @var array{locked_until_gmt: string, operation: string}|null
+	 */
+	private $lock_state;
+
 	/**
 	 * Line items, each a plain associative array matching the items table shape.
 	 *
@@ -282,20 +297,30 @@ final class Contract {
 		$this->trial_end_gmt        = Coercion::coerce_nullable_string( $data['trial_end_gmt'] ?? null );
 		$this->end_gmt              = Coercion::coerce_nullable_string( $data['end_gmt'] ?? null );
 		$this->schedule_source      = Coercion::coerce_string( $data['schedule_source'] ?? null, self::SCHEDULE_SOURCE_ENGINE );
+		$this->creation_key         = Coercion::coerce_nullable_string( $data['creation_key'] ?? null );
+		$this->lock_state           = self::lock_state_from( $data );
 		$this->items                = Coercion::coerce_list_of_arrays( $data['items'] ?? null );
 		$this->addresses            = Coercion::coerce_map_of_arrays( $data['addresses'] ?? null );
 		$this->plan_snapshot        = ( $data['plan_snapshot'] ?? null ) instanceof PlanSnapshot ? $data['plan_snapshot'] : null;
 	}

 	/**
-	 * Build a new, unsaved contract. `extension_slug` is required.
+	 * Build a new, unsaved contract. `extension_slug` is required; `creation_key` is an
+	 * optional idempotency key of 1-100 characters, trimmed of surrounding whitespace.
 	 *
 	 * @param array<string, mixed> $args Contract attributes.
 	 * @throws DomainException If the contract attributes are not valid.
 	 */
 	public static function create( array $args ): self {
-		// A new contract is always unsaved; never adopt a caller-supplied id.
-		unset( $args['id'] );
+		// A new contract is always unsaved and unlocked; never adopt a caller-supplied id or lock.
+		unset( $args['id'], $args['lock_live'], $args['locked_until_gmt'], $args['lock_operation'] );
+
+		// The key column ignores trailing spaces when comparing, so they never tell keys apart.
+		if ( isset( $args['creation_key'] ) && is_string( $args['creation_key'] ) ) {
+			$args['creation_key'] = trim( $args['creation_key'] );
+		}
+
+		self::assert_creation_key( $args['creation_key'] ?? null );

 		$contract = new self( $args );

@@ -310,8 +335,7 @@ final class Contract {
 	/**
 	 * Refuse money without a currency: a contract with no currency must have every total at zero.
 	 * Cross-field, so callers that change several fields check it once after the last change.
-	 * Checked against the state the caller holds: the engine opens no transaction or lock, so
-	 * concurrent writers to the currency and totals of one contract coordinate themselves.
+	 * Checked against the state the caller holds; the contract lock serializes facade writers.
 	 *
 	 * @throws DomainException If a total is non-zero while the currency is unset.
 	 */
@@ -720,6 +744,25 @@ final class Contract {
 		$this->schedule_source = $schedule_source;
 	}

+	/**
+	 * The live lock's expiry (GMT string) and operation label, or null when the contract
+	 * is not locked. Read-only, as of the read; the token is never part of the entity.
+	 *
+	 * @return array{locked_until_gmt: string, operation: string}|null
+	 */
+	public function get_lock_state(): ?array {
+		return $this->lock_state;
+	}
+
+	/**
+	 * Forget the lock state read with the row, once the lock it reported has been released.
+	 *
+	 * @internal Used by the contracts facade after its internal lock.
+	 */
+	public function clear_lock_state(): void {
+		$this->lock_state = null;
+	}
+
 	/**
 	 * Line items.
 	 *
@@ -785,9 +828,47 @@ final class Contract {
 			'trial_end_gmt'        => $this->trial_end_gmt,
 			'end_gmt'              => $this->end_gmt,
 			'schedule_source'      => $this->schedule_source,
+			'creation_key'         => $this->creation_key,
+		);
+	}
+
+	/**
+	 * Build the read-only lock state from a stored row: only when the row's computed
+	 * `lock_live` flag is truthy and both the expiry and the operation are present.
+	 *
+	 * @param array<string, mixed> $data Raw attributes.
+	 * @return array{locked_until_gmt: string, operation: string}|null
+	 */
+	private static function lock_state_from( array $data ): ?array {
+		$locked_until_gmt = Coercion::coerce_nullable_string( $data['locked_until_gmt'] ?? null );
+		$operation        = Coercion::coerce_nullable_string( $data['lock_operation'] ?? null );
+
+		if ( empty( $data['lock_live'] ) || null === $locked_until_gmt || null === $operation ) {
+			return null;
+		}
+
+		return array(
+			'locked_until_gmt' => $locked_until_gmt,
+			'operation'        => $operation,
 		);
 	}

+	/**
+	 * Refuse a creation key that is not null or a string of 1-100 characters.
+	 *
+	 * @param mixed $creation_key Creation key to check.
+	 * @throws DomainException If `$creation_key` is not valid.
+	 */
+	private static function assert_creation_key( $creation_key ): void {
+		if ( null === $creation_key ) {
+			return;
+		}
+
+		if ( ! is_string( $creation_key ) || '' === $creation_key || mb_strlen( $creation_key ) > 100 ) {
+			throw new DomainException( 'Contract: creation_key must be a string of 1-100 characters.' );
+		}
+	}
+
 	/**
 	 * Refuse a status that is not a registered contract status.
 	 *
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
index 95db5d2b9a4..5d59fd446bf 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
@@ -5,7 +5,7 @@
  *
  * An immutable billing record frozen at billing (snapshot references, period
  * boundaries, `expected_total`). Only the repository's compare-and-set tools change a
- * stored cycle's `status`, `order_id`, `reason` and claim afterwards. The `count` is the chargeable number and idempotency anchor, nullable
+ * stored cycle's `status`, `order_id` and `reason` afterwards. The `count` is the chargeable number and idempotency anchor, nullable
  * for non-counting cycles (e.g. a future trial). Money is a decimal-safe string;
  * timestamps are GMT strings (`Y-m-d H:i:s`).
  *
@@ -142,15 +142,6 @@ final class Cycle {
 	 */
 	private $extension_slug;

-	/**
-	 * Claim expiry (GMT string), or null. Set when a caller appends a cycle claimed, claims
-	 * it, or transitions it with a claim; once it passes, another caller may claim the
-	 * cycle. The chain's UNIQUE positions remain the primary guard against a duplicate cycle.
-	 *
-	 * @var string|null
-	 */
-	private $claimed_until_gmt;
-
 	/**
 	 * Reserved retry moment (GMT string), or null. Additive column for a later
 	 * retry/dunning pass; not wired by the dispatcher yet.
@@ -195,7 +186,6 @@ final class Cycle {
 		$this->items_snapshot_id = Coercion::coerce_nullable_int( $data['items_snapshot_id'] ?? null );
 		$this->order_id          = Coercion::coerce_nullable_int( $data['order_id'] ?? null );
 		$this->extension_slug    = Coercion::coerce_nullable_string( $data['extension_slug'] ?? null );
-		$this->claimed_until_gmt = Coercion::coerce_nullable_string( $data['claimed_until'] ?? null );
 		$this->retry_at_gmt      = Coercion::coerce_nullable_string( $data['retry_at'] ?? null );
 	}

@@ -213,9 +203,8 @@ final class Cycle {
 	 * @throws DomainException If the attributes are not valid.
 	 */
 	public static function create( array $args ): self {
-		// A new cycle is always unsaved; never adopt a caller-supplied id. A claim is
-		// stamped by the database when the cycle is appended, never carried in.
-		unset( $args['id'], $args['claimed_until'] );
+		// A new cycle is always unsaved; never adopt a caller-supplied id.
+		unset( $args['id'] );

 		$cycle = new self( $args );

@@ -392,13 +381,6 @@ final class Cycle {
 		return $this->extension_slug;
 	}

-	/**
-	 * Crash-recovery claim expiry (GMT string), or null.
-	 */
-	public function get_claimed_until_gmt(): ?string {
-		return $this->claimed_until_gmt;
-	}
-
 	/**
 	 * Reserved retry moment (GMT string), or null.
 	 */
@@ -470,7 +452,6 @@ final class Cycle {
 			'items_snapshot_id' => $this->items_snapshot_id,
 			'order_id'          => $this->order_id,
 			'extension_slug'    => $this->extension_slug,
-			'claimed_until'     => $this->claimed_until_gmt,
 			'retry_at'          => $this->retry_at_gmt,
 		);
 	}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Dispatch/DueDispatcher.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Dispatch/DueDispatcher.php
index aa113aeec00..484b0340897 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Dispatch/DueDispatcher.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Dispatch/DueDispatcher.php
@@ -4,12 +4,12 @@
  *
  * One recurring Action Scheduler job scans for contracts whose `next_payment_gmt` has
  * passed and whose owner registered a `contract_due_handler` ({@see ConsumerRegistry}),
- * claims each one, and calls the owner's handler. Only contracts whose schedule the engine
+ * locks each one, and calls the owner's handler with the lock. Only contracts whose schedule the engine
  * keeps (`schedule_source` `subscriptions_engine`) are dispatched; a gateway or extension
  * that keeps its own schedule triggers its own renewals. The engine decides nothing about
  * what a due contract means: renewing, skipping or clearing the due moment is the owner's call.
  * A handler is expected to move or clear the due moment; one that does not is notified
- * again after the claim expires, behind contracts not yet notified.
+ * again by a later or overlapping tick, behind contracts locked less recently.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Dispatch
  */
@@ -21,6 +21,7 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Dispatch;
 use DateTimeImmutable;
 use DateTimeZone;
 use Throwable;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLock;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
@@ -43,13 +44,9 @@ final class DueDispatcher {
 	public const GROUP = 'woocommerce_subscriptions_engine';

 	/**
-	 * How long a dispatch claim hides a notified contract from later scans, in seconds,
-	 * measured on the database clock. Moving or clearing `next_payment_gmt` releases the
-	 * claim early; re-saving the same moment does not. A handler that leaves the due moment
-	 * in place is notified again once the claim expires, behind contracts not yet notified
-	 * for their current due moment.
+	 * Operation label of the lock the dispatcher takes for a due notification.
 	 */
-	public const CLAIM_TTL_SECONDS = 900;
+	public const LOCK_OPERATION = 'engine_due_dispatch';

 	/**
 	 * Scan cadence, in seconds (every 10 minutes).
@@ -78,7 +75,7 @@ final class DueDispatcher {
 	private const SCHEDULE_RECHECK_SECONDS = 3600;

 	/**
-	 * Repository used to scan, claim and read due contracts.
+	 * Repository used to scan, lock and read due contracts.
 	 *
 	 * @var ContractRepository
 	 */
@@ -128,8 +125,8 @@ final class DueDispatcher {
 		}

 		if ( ! self::is_scheduled() ) {
-			// `$unique = true` is a best-effort dedup; the per-contract claim keeps a
-			// duplicate recurring action from notifying a contract twice.
+			// `$unique = true` is a best-effort dedup; the per-contract lock keeps a duplicate
+			// recurring action from notifying a contract while another run holds it.
 			as_schedule_recurring_action( time() + self::INTERVAL_SECONDS, self::INTERVAL_SECONDS, self::HOOK, array(), self::GROUP, true );
 		}

@@ -161,16 +158,16 @@ final class DueDispatcher {
 	}

 	/**
-	 * Notify the owners of up to `$limit` due contracts.
-	 *
-	 * Each due contract is claimed first (a lost claim is skipped), then its owner's handler is
-	 * called with the contract view and `array( 'due_at' => DateTimeImmutable )`. A throwing
-	 * handler is logged and keeps its claim, so one contract never stalls the batch.
+	 * Notify the owners of up to `$limit` due contracts. Each is locked on the due moment the
+	 * scan read (a lost lock is skipped), read, and passed to its handler with `due_at` and the
+	 * `lock`, released when the handler returns. A throwing handler keeps its lock until expiry;
+	 * failures are logged per contract and never stall the batch.
 	 *
 	 * @param DateTimeImmutable|null $now   The scan moment; defaults to now (UTC).
 	 * @param int                    $limit Maximum due contracts this tick; non-positive is a no-op.
-	 * @return int The number of contracts whose handler returned without throwing; lost
-	 *             claims, missing contracts or handlers, and throwing handlers are not counted.
+	 * @return int The number of contracts whose handler returned without throwing (a failed
+	 *             release afterwards still counts); lost locks, missing contracts or handlers,
+	 *             failed locks or reads, and throwing handlers are not counted.
 	 */
 	public function run_batch( ?DateTimeImmutable $now = null, int $limit = self::BATCH_SIZE ): int {
 		if ( $limit < 1 ) {
@@ -197,14 +194,30 @@ final class DueDispatcher {
 		foreach ( $due_rows as $row ) {
 			$contract_id = $row['id'];

-			if ( ! $this->contracts->claim_contract( $contract_id, $row['next_payment_gmt'], self::CLAIM_TTL_SECONDS ) ) {
-				++$skipped;
+			try {
+				$lock = $this->contracts->acquire_lock( $contract_id, self::LOCK_OPERATION, array( 'next_payment_gmt' => $row['next_payment_gmt'] ) );
+				if ( null === $lock ) {
+					++$skipped;
+					continue;
+				}
+			} catch ( Throwable $e ) {
+				++$failed;
+				$this->log_failure( $contract_id, 'could not be locked', $e );
 				continue;
 			}

-			$contract = $this->contracts->find( $contract_id );
-			$handler  = ConsumerRegistry::get_contract_due_handler( $row['extension_slug'] );
+			try {
+				$contract = $this->contracts->find( $contract_id );
+			} catch ( Throwable $e ) {
+				++$failed;
+				$this->log_failure( $contract_id, 'could not be read', $e );
+				$this->release( $lock );
+				continue;
+			}
+
+			$handler = ConsumerRegistry::get_contract_due_handler( $row['extension_slug'] );
 			if ( null === $contract || null === $handler ) {
+				$this->release( $lock );
 				++$skipped;
 				continue;
 			}
@@ -212,19 +225,20 @@ final class DueDispatcher {
 			try {
 				$handler(
 					ContractView::from_contract( $contract, true ),
-					array( 'due_at' => new DateTimeImmutable( $row['next_payment_gmt'], $utc ) )
-				);
-				++$notified;
-			} catch ( Throwable $e ) {
-				++$failed;
-				wc_get_logger()->error(
-					sprintf( 'DueDispatcher::run_batch(): the due handler for contract %d threw: %s', $contract_id, $e->getMessage() ),
 					array(
-						'source'      => self::LOG_SOURCE,
-						'contract_id' => $contract_id,
+						'due_at' => new DateTimeImmutable( $row['next_payment_gmt'], $utc ),
+						'lock'   => $lock,
 					)
 				);
+			} catch ( Throwable $e ) {
+				// The lock is kept until it expires: the contract stays closed while its state is unknown.
+				++$failed;
+				$this->log_failure( $contract_id, 'threw in its due handler', $e );
+				continue;
 			}
+
+			++$notified;
+			$this->release( $lock );
 		}

 		if ( array() !== $due_rows ) {
@@ -236,4 +250,42 @@ final class DueDispatcher {

 		return $notified;
 	}
+
+	/**
+	 * Release a dispatch lock in its own try block: a failed release is logged and the lock
+	 * expires on its own, without failing the notification or the batch.
+	 *
+	 * @param ContractLock $lock The dispatch lock.
+	 */
+	private function release( ContractLock $lock ): void {
+		try {
+			$this->contracts->release_lock( $lock );
+		} catch ( Throwable $e ) {
+			wc_get_logger()->warning(
+				sprintf( 'DueDispatcher::run_batch(): failed to release the due lock on contract %d; it expires on its own. %s', $lock->get_contract_id(), $e->getMessage() ),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => $lock->get_contract_id(),
+					'stage'       => 'release',
+				)
+			);
+		}
+	}
+
+	/**
+	 * Log a contract that failed this tick.
+	 *
+	 * @param int       $contract_id Contract id.
+	 * @param string    $what        What went wrong, e.g. "could not be read".
+	 * @param Throwable $e           The failure.
+	 */
+	private function log_failure( int $contract_id, string $what, Throwable $e ): void {
+		wc_get_logger()->error(
+			sprintf( 'DueDispatcher::run_batch(): contract %d %s: %s', $contract_id, $what, $e->getMessage() ),
+			array(
+				'source'      => self::LOG_SOURCE,
+				'contract_id' => $contract_id,
+			)
+		);
+	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php
index 1c270a25d68..4099cf2da24 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php
@@ -42,17 +42,24 @@ final class ConsumerRegistry {
 	private static $consumers = array();

 	/**
-	 * Register a consumer extension by its slug, with an optional configuration.
-	 *
-	 * `contract_due_handler`: `callable( ContractView $contract, array $context ): void`, called
-	 * when one of the consumer's contracts is due (`$context['due_at']` is the due moment, UTC).
-	 * The slug is trimmed; an empty slug is ignored. A repeat registration, an unknown key or a
-	 * non-callable handler raises a `_doing_it_wrong()` notice (the first registration is kept;
-	 * the invalid key is dropped and the slug still registers). Register by `init`; a handler
-	 * registered after `wp_loaded` schedules the recurring due scan right away.
+	 * Register a consumer extension by its (trimmed, non-empty) slug. A repeat registration, an
+	 * unknown key or a non-callable handler gets `_doing_it_wrong()`: the first registration is
+	 * kept and an invalid key is dropped. Register by `init`; a handler registered after
+	 * `wp_loaded` schedules the recurring due scan right away.
 	 *
 	 * @param string               $slug   The consumer extension's registered slug.
-	 * @param array<string, mixed> $config Consumer configuration.
+	 * @param array<string, mixed> $config Consumer configuration. `contract_due_handler`:
+	 *                                     `callable( ContractView $contract, array $context ): void`,
+	 *                                     called when a contract is due, with `$context['due_at']`
+	 *                                     (UTC) and `$context['lock']` (the dispatcher's
+	 *                                     `ContractLock`, operation `engine_due_dispatch`, which
+	 *                                     other callers see while the handler runs). The
+	 *                                     dispatcher releases the lock when the
+	 *                                     handler returns, unless the handler already did, and keeps
+	 *                                     it until it expires when the handler throws. A handler
+	 *                                     that leaves the contract due (same due moment) is notified
+	 *                                     again by a later or overlapping scan; moving or clearing
+	 *                                     the due moment stops that.
 	 */
 	public static function register( string $slug, array $config = array() ): void {
 		$slug = trim( $slug );
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractLockState.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractLockState.php
new file mode 100644
index 00000000000..3d799b90220
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractLockState.php
@@ -0,0 +1,157 @@
+<?php
+/**
+ * ContractLockState - the private state behind a {@see ContractLock}.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLock;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Token, trust window and extend / release flags of one lock. Held by the lock and read by
+ * {@see ContractRepository} through {@see ContractLock::get_state()}; any other caller gets
+ * `_doing_it_wrong()`.
+ *
+ * Why: the contract row write checks the lock in its own statement, but fencing every item,
+ * address, meta and cycle statement the same way would join the contract row each time. This
+ * caches the last database confirmation (acquire, extend or verify) instead, and child writes
+ * trust it for {@see self::TRUST_WINDOW_SECONDS}, far shorter than the lock's lifetime and
+ * ending at least that long before expiry, so a takeover cannot land inside the trust. Past
+ * it, the next child write re-checks the lock with one read. Times are monotonic, in seconds.
+ *
+ * @internal Engine storage only.
+ */
+final class ContractLockState {
+
+	/**
+	 * Seconds child writes trust a lock after its last database stamp, and the minimum
+	 * remaining lifetime a verify requires.
+	 */
+	private const TRUST_WINDOW_SECONDS = 60;
+
+	/**
+	 * Engine-generated token.
+	 *
+	 * @var string
+	 */
+	private $token;
+
+	/**
+	 * Monotonic moment until which child writes trust the lock.
+	 *
+	 * @var float
+	 */
+	private $trusted_until = 0.0;
+
+	/**
+	 * Monotonic estimate of the stored expiry.
+	 *
+	 * @var float
+	 */
+	private $expires_estimate = 0.0;
+
+	/**
+	 * Whether the single extend allowance is used.
+	 *
+	 * @var bool
+	 */
+	private $extended = false;
+
+	/**
+	 * Whether the lock was released.
+	 *
+	 * @var bool
+	 */
+	private $released = false;
+
+	/**
+	 * State of a lock just stamped with a full TTL.
+	 *
+	 * @param string $token The stored token.
+	 */
+	public function __construct( string $token ) {
+		$this->token = $token;
+		$this->stamp( ContractLock::TTL_SECONDS );
+	}
+
+	/**
+	 * The token.
+	 */
+	public function get_token(): string {
+		return $this->token;
+	}
+
+	/**
+	 * Record a database stamp that left `$remaining_seconds` until expiry (acquire, extend or
+	 * verify): trust for the window, or less when the lock ends sooner, and not at all with a
+	 * window or less left ({@see self::is_trusted()}).
+	 *
+	 * @param int $remaining_seconds Seconds until the stored expiry.
+	 */
+	public function stamp( int $remaining_seconds ): void {
+		$now = self::now();
+
+		$this->expires_estimate = $now + $remaining_seconds;
+		$trust                  = min( self::TRUST_WINDOW_SECONDS, $remaining_seconds - self::TRUST_WINDOW_SECONDS );
+		$this->trusted_until    = $this->released || $trust <= 0 ? 0.0 : $now + $trust;
+	}
+
+	/**
+	 * Whether child writes may trust the lock without a database check.
+	 */
+	public function is_trusted(): bool {
+		return ! $this->released && self::now() < $this->trusted_until;
+	}
+
+	/**
+	 * Whether the stored expiry is, by the local estimate, too close for a verify to trust the
+	 * lock for a full window.
+	 */
+	public function is_near_expiry(): bool {
+		return $this->expires_estimate - self::now() <= 2 * self::TRUST_WINDOW_SECONDS;
+	}
+
+	/**
+	 * Whether the extend allowance is used.
+	 */
+	public function has_extended(): bool {
+		return $this->extended;
+	}
+
+	/**
+	 * Use the extend allowance.
+	 */
+	public function mark_extended(): void {
+		$this->extended = true;
+	}
+
+	/**
+	 * Whether the lock was released.
+	 */
+	public function is_released(): bool {
+		return $this->released;
+	}
+
+	/**
+	 * Mark the lock released and stop trusting it.
+	 */
+	public function mark_released(): void {
+		$this->released      = true;
+		$this->trusted_until = 0.0;
+	}
+
+	/**
+	 * Monotonic now in seconds. The `hrtime()` array form stays exact on 32-bit builds.
+	 */
+	private static function now(): float {
+		$time = hrtime();
+
+		return is_array( $time ) ? $time[0] + $time[1] / 1e9 : microtime( true );
+	}
+}
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 8068b20bbba..c6e8f66ba87 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
@@ -6,14 +6,15 @@
  * The contract is the live source of truth. A chain is NOT a stored entity: it is
  * the pair `(contract_id, kind)`, with its head and counters derived from the cycle
  * rows. The entity never carries a cycle graph in memory, so cycles are reached
- * through purpose-built reads ({@see self::find_chain_head()}, etc.), appended one at a
+ * through purpose-built reads ({@see self::find_cycle_history()}, etc.), appended one at a
  * time ({@see self::append_cycle()}) and changed only through compare-and-set writes
- * ({@see self::claim_cycle()}, {@see self::transition_cycle()}).
- * There is no whole-graph `save()`. Cycle snapshot ids are stored as given and their
- * value objects attached on read; nothing writes snapshot rows on append.
- * Meta is read and written only through the meta methods ({@see self::add_meta()} etc.),
- * so a whole-contract write never rewrites meta. It opens no transactions and keeps no
- * object cache; a caller may wrap several calls in its own transaction.
+ * ({@see self::transition_cycle()}). Cycle snapshot ids are stored as given and their
+ * value objects attached on read. Meta is read and written only through the meta methods.
+ *
+ * Every update, meta write, cycle write and child write runs under the per-contract lock
+ * ({@see ContractLock}): the caller's, or an internal one taken for an update or meta write made
+ * without one; each child-table write fences its lock itself. The exception is {@see self::delete()},
+ * the discard of a contract its creator still holds locked. No transactions, no object cache.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage
  */
@@ -24,6 +25,8 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage;

 use DateTimeImmutable;
 use DateTimeZone;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLock;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLockedException;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
@@ -45,134 +48,267 @@ final class ContractRepository {
 	private const LOG_SOURCE = 'woocommerce-subscriptions-engine';

 	/**
-	 * Insert a new contract and its items and addresses.
-	 *
-	 * Durable-intent-first (parent row, then children) and the seam a later
-	 * transaction-handling change wraps; it does not open a transaction now (a naive
-	 * one would commit the integration suite's outer test transaction, and bare
-	 * savepoints error in production without an ambient transaction).
-	 *
-	 * @param Contract $contract Contract to insert.
-	 * @return int The new contract id.
-	 * @throws \RuntimeException If the contract row or a child row insert fails.
+	 * Operation label of the lock the engine takes around an unlocked contract update.
 	 */
-	public function insert( Contract $contract ): int {
-		global $wpdb;
+	private const INTERNAL_LOCK_OPERATION = 'engine_contract_update';

-		$now  = gmdate( 'Y-m-d H:i:s' );
-		$data = $contract->to_storage();
+	/**
+	 * SQL condition: the contract row carries a live lock, on the database clock.
+	 */
+	private const LOCK_LIVE = '( lock_token IS NOT NULL AND locked_until_gmt > UTC_TIMESTAMP() )';

-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-		$inserted = $wpdb->insert(
-			SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ),
-			array_merge(
-				$data,
-				array(
-					'date_created_gmt' => $now,
-					'date_updated_gmt' => $now,
-				)
+	/**
+	 * SQL condition: the contract row carries no live lock (none, or an expired one).
+	 */
+	private const NO_LIVE_LOCK = '( lock_token IS NULL OR locked_until_gmt <= UTC_TIMESTAMP() )';
+
+	/**
+	 * The contract columns every contract read selects: the row plus whether its lock is live.
+	 * `lock_token` is stripped before hydration.
+	 */
+	private const CONTRACT_SELECT = '*, ' . self::LOCK_LIVE . ' AS lock_live';
+
+	/**
+	 * SQL condition: the contract row carries the live lock with the bound token.
+	 */
+	private const HOLDS_LOCK = 'lock_token = %s AND locked_until_gmt > UTC_TIMESTAMP()';
+
+	/**
+	 * Contract columns a lock may be conditioned on ({@see self::acquire_lock()}), mapped to the
+	 * column name the SQL uses.
+	 */
+	private const LOCK_CONDITION_COLUMNS = array(
+		'next_payment_gmt' => 'next_payment_gmt',
+		'status'           => 'status',
+	);
+
+	/**
+	 * Insert a new contract locked from birth: the row INSERT stamps the lock, then items and
+	 * addresses follow under it. When a child insert fails, the new contract (and its lock) is
+	 * deleted again, so a retry is not blocked by its own creation key.
+	 *
+	 * @param Contract $contract  Contract to insert.
+	 * @param string   $operation Operation label, 1-64 of `[a-z0-9_-]`.
+	 * @return ContractLock|null The lock on the new contract; null when the extension already holds its creation key.
+	 * @throws \InvalidArgumentException If `$operation` is not a valid label.
+	 * @throws \RuntimeException If the contract row or a child row insert fails.
+	 */
+	public function insert( Contract $contract, string $operation ): ?ContractLock {
+		self::assert_operation( $operation );
+
+		$token    = self::new_token();
+		$inserted = $this->insert_row(
+			$contract,
+			array(
+				'token'     => $token,
+				'operation' => $operation,
 			)
 		);
-
-		if ( false === $inserted ) {
-			throw new \RuntimeException( 'Failed to insert contract.' );
+		if ( ! $inserted ) {
+			return null;
 		}

-		$id = (int) $wpdb->insert_id;
-		$contract->set_id( $id );
+		$lock = new ContractLock( (int) $contract->get_id(), $operation, $token );

-		$this->insert_items( $id, $contract->get_items() );
-		$this->insert_addresses( $id, $contract->get_addresses() );
+		$this->insert_children_or_discard( $contract, $lock );

-		return $id;
+		return $lock;
 	}

 	/**
-	 * Persist changes to an existing contract and its child rows.
-	 *
-	 * Updates the contract row in place, then reconciles items / addresses only when
-	 * they differ (meta is never touched). A `next_payment_gmt` that differs from the stored
-	 * one (including null) releases the due-dispatch claim; re-saving the stored moment keeps
-	 * it. No transaction (see {@see self::insert()}).
+	 * Insert a new contract's items and addresses; on a failure, best-effort delete the new
+	 * contract with its children, then rethrow. A failed delete is logged, never thrown, so the
+	 * child insert failure is what the caller sees.
 	 *
-	 * @param Contract $contract Contract to update. Must have an id whose row still exists.
-	 * @return bool True when the contract row was updated (or already current).
-	 * @throws \RuntimeException If the contract has no id, its row no longer exists, or a write fails.
+	 * @param Contract     $contract The new contract, carrying its id and children.
+	 * @param ContractLock $lock     The contract's creation lock.
+	 * @throws \Throwable The child insert failure, rethrown (a `RuntimeException` for a failed insert).
 	 */
-	public function update( Contract $contract ): bool {
-		$id = $contract->get_id();
-		if ( null === $id ) {
-			throw new \RuntimeException( 'Cannot update a contract that has no id. Use ContractRepository::insert() for a new contract.' );
+	private function insert_children_or_discard( Contract $contract, ContractLock $lock ): void {
+		global $wpdb;
+
+		$contract_id = (int) $contract->get_id();
+
+		try {
+			$this->insert_items( $contract_id, $contract->get_items(), $lock );
+			$this->insert_addresses( $contract_id, $contract->get_addresses(), $lock );
+		} catch ( \Throwable $e ) {
+			try {
+				$discarded = $this->delete( $contract_id );
+				$reason    = $wpdb->last_error;
+			} catch ( \Throwable $cleanup ) {
+				$discarded = false;
+				$reason    = $cleanup->getMessage();
+			}
+			if ( ! $discarded ) {
+				wc_get_logger()->error(
+					sprintf( 'ContractRepository: could not discard contract %d after its items or addresses failed to insert; it stays without them. %s', $contract_id, $reason ),
+					array(
+						'source'      => self::LOG_SOURCE,
+						'contract_id' => $contract_id,
+					)
+				);
+			}
+			throw $e;
+		}
+	}
+
+	/**
+	 * Insert the contract row, stamping its lock on the database clock in the same statement,
+	 * and set the new id on the entity. Hand-built, so strings are checked against
+	 * their column lengths first, as `$wpdb->insert()` would.
+	 *
+	 * @param Contract                                $contract Contract to insert.
+	 * @param array{token: string, operation: string} $lock     Lock to stamp.
+	 * @return bool False when the `(extension_slug, creation_key)` pair is taken (nothing inserted).
+	 * @throws \RuntimeException If the insert fails for any other reason.
+	 */
+	private function insert_row( Contract $contract, array $lock ): bool {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$now   = gmdate( 'Y-m-d H:i:s' );
+
+		$columns                     = $contract->to_storage();
+		$columns['date_created_gmt'] = $now;
+		$columns['date_updated_gmt'] = $now;
+
+		$names        = array();
+		$placeholders = array();
+		$values       = array();
+		foreach ( $columns as $column => $value ) {
+			$names[] = "`{$column}`";
+			if ( null === $value ) {
+				$placeholders[] = 'NULL';
+				continue;
+			}
+			if ( is_string( $value ) ) {
+				self::assert_fits_column( $table, $column, $value );
+			}
+			$placeholders[] = '%s';
+			$values[]       = (string) $value;
+		}
+
+		array_push( $names, '`lock_token`', '`locked_until_gmt`', '`lock_operation`' );
+		array_push( $placeholders, '%s', 'DATE_ADD( UTC_TIMESTAMP(), INTERVAL %d SECOND )', '%s' );
+		array_push( $values, $lock['token'], ContractLock::TTL_SECONDS, $lock['operation'] );
+
+		$name_list        = implode( ', ', $names );
+		$placeholder_list = implode( ', ', $placeholders );
+
+		// A duplicate creation key is an expected outcome, so `$wpdb` must not print or log it;
+		// every failure is still read from `last_error` and thrown below.
+		$suppressed = $wpdb->suppress_errors( true );
+		try {
+			// Column names come from the entity's storage keys, never from input; every value is a placeholder.
+			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+			$inserted = $wpdb->query( $wpdb->prepare( "INSERT INTO `{$table}` ( {$name_list} ) VALUES ( {$placeholder_list} )", $values ) );
+			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+		} finally {
+			$wpdb->suppress_errors( $suppressed );
 		}

-		$this->assert_contract_exists( $id );
+		if ( false === $inserted ) {
+			$db_error  = $wpdb->last_error;
+			$duplicate = self::get_duplicate_key_result();
+			if ( null === $duplicate && null !== $columns['creation_key'] ) {
+				$duplicate = $this->has_creation_key( (string) $columns['extension_slug'], (string) $columns['creation_key'] );
+			}
+			// The only UNIQUE index a new contract row can hit is `creation`, and only with a key.
+			if ( true === $duplicate && null !== $columns['creation_key'] ) {
+				return false;
+			}
+			throw new \RuntimeException( esc_html( 'Failed to insert contract. ' . $db_error ) );
+		}

-		$this->write_contract_row( $id, $contract->to_storage() );
-		$this->sync_children( $contract );
+		$contract->set_id( (int) $wpdb->insert_id );

 		return true;
 	}

 	/**
-	 * Write only the named contract columns (plus `date_updated_gmt`) from the entity,
-	 * and replace items / addresses when named. Unnamed columns keep their stored
-	 * values, so a concurrent write to them is not reverted. The named columns land in one
-	 * statement, together or not at all. Writing a `next_payment_gmt` that differs from the
-	 * stored one (including null) also releases the due-dispatch claim; re-saving the stored
-	 * moment keeps it. Existence is checked only when the row write changes nothing.
+	 * Whether the extension already holds a contract with this creation key: the fallback when
+	 * the driver gives no error number. A failed read reads as false, so the caller reports its
+	 * own insert failure.
+	 *
+	 * @param string $extension_slug Owning extension.
+	 * @param string $creation_key   Creation key.
+	 */
+	private function has_creation_key( string $extension_slug, string $creation_key ): bool {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$found = $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM `{$table}` WHERE extension_slug = %s AND creation_key = %s LIMIT 1", $extension_slug, $creation_key ) );
+
+		return null !== $found;
+	}
+
+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
+	/**
+	 * Write the named contract columns from the entity in one statement under `$lock`, then
+	 * replace items / addresses when named.
 	 *
 	 * @param Contract           $contract Contract carrying the values. Must have an id.
 	 * @param array<int, string> $fields   Contract column names, plus `items` / `addresses`.
+	 * @param ContractLock       $lock     The held lock on this contract.
 	 * @return bool False when the contract row no longer exists (nothing is written).
-	 * @throws \InvalidArgumentException If a field is not a writable contract column.
+	 * @throws \InvalidArgumentException If a field is not a writable contract column, or the lock is for another contract.
+	 * @throws ContractLockedException If `$lock` was lost.
 	 * @throws \RuntimeException If the contract has no id or the write fails.
 	 */
-	public function update_fields( Contract $contract, array $fields ): bool {
-		$id = $contract->get_id();
-		if ( null === $id ) {
+	public function update_fields( Contract $contract, array $fields, ContractLock $lock ): bool {
+		$contract_id = $contract->get_id();
+		if ( null === $contract_id ) {
 			throw new \RuntimeException( 'Cannot update a contract that has no id. Use ContractRepository::insert() for a new contract.' );
 		}

+		self::assert_lock_for( $lock, $contract_id );
+
 		$storage = $contract->to_storage();
-		$columns = array();
 		foreach ( $fields as $field ) {
 			if ( 'items' === $field || 'addresses' === $field ) {
 				continue;
 			}
-			if ( 'extension_slug' === $field || ! array_key_exists( $field, $storage ) ) {
+			if ( 'extension_slug' === $field || 'creation_key' === $field || ! array_key_exists( $field, $storage ) ) {
 				throw new \InvalidArgumentException( esc_html( sprintf( 'Cannot update contract field "%s".', $field ) ) );
 			}
-			$columns[ $field ] = $storage[ $field ];
 		}

-		$updated = $this->write_contract_row( $id, $columns );
+		$columns = array_intersect_key( $storage, array_flip( $fields ) );
+		$updated = $this->write_contract_row( $contract_id, $columns, $lock );

-		// Zero changed rows: a missing row, or identical values written within the same second.
-		if ( 0 === $updated && ! $this->exists( $id ) ) {
-			return false;
+		// Every row write bumps `write_seq`, so zero rows means the fence refused the write or the row is gone.
+		if ( 0 === $updated ) {
+			$holder = $this->get_lock_holder( $contract_id );
+			if ( ! $holder['exists'] ) {
+				return false;
+			}
+			throw self::locked_exception( $contract_id, $holder ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the exception message is not output.
 		}

 		if ( in_array( 'items', $fields, true ) ) {
-			$this->replace_items( $id, $contract->get_items() );
+			$this->replace_items( $contract_id, $contract->get_items(), $lock );
 		}

 		if ( in_array( 'addresses', $fields, true ) ) {
-			$this->replace_addresses( $id, $contract->get_addresses() );
+			$this->replace_addresses( $contract_id, $contract->get_addresses(), $lock );
 		}

 		return true;
 	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

 	/**
-	 * Fetch a contract by id, hydrating the live entity with its items / addresses,
-	 * plus its frozen plan terms ({@see Contract::get_plan_snapshot()}) from
-	 * `plan_snapshot_id` - so every full read carries the billing cadence off the
-	 * snapshot, with no live {@see PlanRepository} join. Cycles are NOT hydrated - they
-	 * are reached on demand through the targeted cycle reads. For list / guard paths
-	 * that do not need the children, use {@see self::find_summary()}.
+	 * Fetch a contract by id with its items, addresses and frozen plan terms
+	 * ({@see Contract::get_plan_snapshot()}, no live {@see PlanRepository} join). Cycles are
+	 * not hydrated: use the targeted cycle reads. Paths that do not need the children use
+	 * {@see self::find_summary()}.
 	 *
 	 * @param int $id Contract id.
 	 * @return Contract|null Hydrated contract, or null if not found.
+	 * @throws \RuntimeException If the contract row read fails.
 	 */
 	public function find( int $id ): ?Contract {
 		$row = $this->find_contract_row( $id );
@@ -199,15 +335,16 @@ final class ContractRepository {
 	public function find_for_customer( int $contract_id, int $customer_id ): ?Contract {
 		global $wpdb;

-		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$table  = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$select = self::CONTRACT_SELECT;

 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d AND customer_id = %d", $contract_id, $customer_id ), ARRAY_A );
+		$row = $wpdb->get_row( $wpdb->prepare( "SELECT {$select} FROM {$table} WHERE id = %d AND customer_id = %d", $contract_id, $customer_id ), ARRAY_A );
 		if ( ! is_array( $row ) ) {
 			return null;
 		}

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

 	/**
@@ -233,6 +370,7 @@ final class ContractRepository {
 	 *
 	 * @param int $id Contract id.
 	 * @return Contract|null The contract (row only), or null if not found.
+	 * @throws \RuntimeException If the read fails.
 	 */
 	public function find_summary( int $id ): ?Contract {
 		$row = $this->find_contract_row( $id );
@@ -287,7 +425,8 @@ final class ContractRepository {
 		$limit  = isset( $args['limit'] ) && is_numeric( $args['limit'] ) ? max( 0, (int) $args['limit'] ) : 20;
 		$offset = isset( $args['offset'] ) && is_numeric( $args['offset'] ) ? max( 0, (int) $args['offset'] ) : 0;

-		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$table  = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$select = self::CONTRACT_SELECT;

 		list( $where_sql, $where_params ) = $this->build_list_criteria( $args );
 		$order_by                         = $this->build_order_by( $args );
@@ -301,7 +440,7 @@ final class ContractRepository {
 		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
 		$rows = $wpdb->get_results(
 			$wpdb->prepare(
-				"SELECT *
+				"SELECT {$select}
 				FROM {$table}
 				WHERE {$where_sql}
 				ORDER BY {$order_by}
@@ -547,7 +686,7 @@ final class ContractRepository {
 		$clean_rows = array();
 		foreach ( $rows as $row ) {
 			if ( is_array( $row ) ) {
-				$clean_rows[] = Coercion::coerce_string_keyed( $row );
+				$clean_rows[] = self::strip_lock_token( Coercion::coerce_string_keyed( $row ) );
 			}
 		}

@@ -570,33 +709,15 @@ final class ContractRepository {
 	}

 	/**
-	 * Contracts due at `$now`, never-notified first, then least recently notified - the due dispatcher's scan.
-	 *
-	 * A contract is due when its `next_payment_gmt` is at or before `$now`, its owner
-	 * (`extension_slug`) is one of `$owners`, the engine keeps its schedule (`schedule_source`
-	 * is {@see Contract::SCHEDULE_SOURCE_ENGINE}), and it carries no live due-dispatch claim.
-	 * A contract whose schedule a gateway or extension keeps is never selected: that party
-	 * triggers its own renewals. Status is not read: whether a due contract acts is its
-	 * owner's decision. Driven by the `due_owner (extension_slug, next_payment_gmt)` index;
-	 * the schedule source and the claim are residual filters.
-	 *
-	 * `$now` is the due cutoff only; claim liveness is read on the database clock
-	 * (`UTC_TIMESTAMP()`), the clock {@see self::claim_contract()} stamps claims on.
-	 *
-	 * Fairness: a handler is expected to move or clear the due moment it was notified for
-	 * (writing a different `next_payment_gmt` releases the claim). A contract whose handler
-	 * leaves it in place keeps its expired claim. Rows sort by that claim, oldest first, with
-	 * a null claim (never notified for the current due moment) ahead of all of them, then by
-	 * due moment: fresh contracts lead, and among repeats the least recently notified goes
-	 * next, so contracts stuck on an older due moment cannot starve a later-due retry. The
-	 * sort reads the owners' whole due backlog and filesorts it rather than walking
-	 * `due_owner` in order; measured cheap at realistic backlogs (about 13 ms warm for
-	 * 20,000 due rows) and acceptable before the schema freeze. `LIMIT` bounds the result.
+	 * The due dispatcher's scan: contracts of `$owners` with an engine-kept schedule whose
+	 * `next_payment_gmt` is at or before `$now` and that carry no live lock. Status is not read:
+	 * whether a due contract acts is its owner's decision. Least recently locked first, so a
+	 * contract left due by its handler cannot starve the others.
 	 *
 	 * @param DateTimeImmutable  $now    The cutoff moment; contracts due at or before it.
 	 * @param int                $limit  Maximum rows to return (the batch size).
 	 * @param array<int, string> $owners Owner slugs whose contracts may be selected.
-	 * @return array<int, array{id: int, extension_slug: string, next_payment_gmt: string}> Due rows, never-notified first, then least recently notified, then oldest-due.
+	 * @return array<int, array{id: int, extension_slug: string, next_payment_gmt: string}> Due rows, least recently locked first, then oldest-due.
 	 */
 	public function find_due( DateTimeImmutable $now, int $limit, array $owners ): array {
 		if ( $limit < 1 ) {
@@ -613,11 +734,15 @@ final class ContractRepository {
 		$contracts          = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
 		$cutoff             = self::format_utc( $now );
 		$owner_placeholders = implode( ', ', array_fill( 0, count( $owners ), '%s' ) );
+		$no_live_lock       = self::NO_LIVE_LOCK;

 		$args = array_merge( $owners, array( $cutoff, Contract::SCHEDULE_SOURCE_ENGINE, $limit ) );

 		// Table names cannot be bound, so they are interpolated; every value is a placeholder.
 		// The owner placeholder list is generated, one `%s` per owner in `$args`.
+		// Driven by the `due_owner` index; schedule source and lock are residual filters, and lock
+		// liveness is read on the database clock, not `$now`. Every contract is stamped by its
+		// creation lock's release; the sort filesorts the owners' due backlog and `LIMIT` bounds it.
 		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
 		$rows = $wpdb->get_results(
 			$wpdb->prepare(
@@ -625,8 +750,8 @@ final class ContractRepository {
 				FROM {$contracts}
 				WHERE extension_slug IN ( {$owner_placeholders} ) AND next_payment_gmt IS NOT NULL AND next_payment_gmt <= %s
 				  AND schedule_source = %s
-				  AND ( claimed_until IS NULL OR claimed_until <= UTC_TIMESTAMP() )
-				ORDER BY claimed_until ASC, next_payment_gmt ASC, id ASC
+				  AND {$no_live_lock}
+				ORDER BY locked_until_gmt ASC, next_payment_gmt ASC, id ASC
 				LIMIT %d",
 				$args
 			),
@@ -658,40 +783,373 @@ final class ContractRepository {
 		return $result;
 	}

+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
+	/**
+	 * Take the contract's lock for `$operation`: one conditional UPDATE that lands only while
+	 * no live lock is held (an expired one is taken over) and every condition holds, stamped on
+	 * the database clock.
+	 *
+	 * @param int                  $contract_id Contract id.
+	 * @param string               $operation   Operation label, 1-64 of `[a-z0-9_-]`.
+	 * @param array<string, mixed> $conditions  Contract column => the value the caller read: a
+	 *                                          scalar must equal it, null requires NULL. Columns:
+	 *                                          {@see self::LOCK_CONDITION_COLUMNS}.
+	 * @return ContractLock|null The lock, or null when the contract is locked, missing or a condition fails.
+	 * @throws \InvalidArgumentException If `$operation` is not a valid label, or a condition column or value is not accepted.
+	 * @throws \RuntimeException If the write fails.
+	 */
+	public function acquire_lock( int $contract_id, string $operation, array $conditions = array() ): ?ContractLock {
+		global $wpdb;
+
+		self::assert_operation( $operation );
+		list( $condition_sql, $condition_values ) = self::get_condition_clause( $conditions );
+
+		$table        = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$no_live_lock = self::NO_LIVE_LOCK;
+		$token        = self::new_token();
+		$values       = array_merge( array( $token, ContractLock::TTL_SECONDS, $operation, $contract_id ), $condition_values );
+
+		$sql = "UPDATE {$table} SET lock_token = %s, locked_until_gmt = DATE_ADD( UTC_TIMESTAMP(), INTERVAL %d SECOND ), lock_operation = %s
+			WHERE id = %d{$condition_sql} AND {$no_live_lock}";
+
+		// Fixed SQL fragments only; every value is a placeholder.
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
+		$updated = $wpdb->query( $wpdb->prepare( $sql, $values ) );
+
+		if ( false === $updated ) {
+			throw new \RuntimeException( esc_html( sprintf( 'Failed to lock contract %d: %s', $contract_id, $wpdb->last_error ) ) );
+		}
+
+		return 1 === $updated ? new ContractLock( $contract_id, $operation, $token ) : null;
+	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
+
+	/**
+	 * Whether the contract exists and who holds its lock, read on the database clock. The
+	 * operation is set only while the lock is live. Writes use it to shape errors only (it may
+	 * lag on a replica), never to decide that a write landed.
+	 *
+	 * @param int $contract_id Contract id.
+	 * @return array{exists: bool, operation: ?string}
+	 * @throws \RuntimeException If the read fails.
+	 */
+	private function get_lock_holder( int $contract_id ): array {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+		$lock_live = self::LOCK_LIVE;
+		$sql       = "SELECT {$lock_live} AS live, lock_operation FROM {$table} WHERE id = %d";
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
+		$row = $wpdb->get_row( $wpdb->prepare( $sql, $contract_id ), ARRAY_A );
+		if ( '' !== $wpdb->last_error ) {
+			throw new \RuntimeException( esc_html( sprintf( 'Failed to read the lock on contract %d: %s', $contract_id, $wpdb->last_error ) ) );
+		}
+
+		if ( ! is_array( $row ) ) {
+			return array(
+				'exists'    => false,
+				'operation' => null,
+			);
+		}
+
+		$live = ! empty( $row['live'] );
+
+		return array(
+			'exists'    => true,
+			'operation' => $live ? Coercion::coerce_nullable_string( $row['lock_operation'] ?? null ) : null,
+		);
+	}
+
+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
 	/**
-	 * Take the due-dispatch claim on a contract for `$ttl_seconds`.
+	 * Extend a held lock to a full TTL from the database's now, using its single extend
+	 * allowance. Called by {@see self::fence_children()} only, after checking the allowance.
 	 *
-	 * One conditional write: it lands only while the contract still carries the
-	 * `next_payment_gmt` the caller read and no live claim, so of two overlapping
-	 * dispatchers exactly one wins. The claim is stamped and its liveness compared on the
-	 * database clock (`UTC_TIMESTAMP()`) at the moment of the write, the same clock cycle
-	 * claims use, so a claim taken late in a slow batch still lives its full TTL. Writing a
-	 * different `next_payment_gmt` (or null) releases the claim; re-saving the same moment
-	 * keeps it (see {@see self::update_fields()} and {@see self::update()}).
+	 * @param ContractLock $lock The held lock.
+	 * @throws ContractLockedException If the lock was lost.
+	 * @throws \RuntimeException If the write fails.
+	 */
+	private function extend_lock( ContractLock $lock ): void {
+		global $wpdb;
+
+		$state = $lock->get_state();
+
+		$table      = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$holds_lock = self::HOLDS_LOCK;
+
+		$sql    = "UPDATE {$table} SET locked_until_gmt = DATE_ADD( UTC_TIMESTAMP(), INTERVAL %d SECOND ), write_seq = write_seq + 1 WHERE id = %d AND {$holds_lock}";
+		$values = array( ContractLock::TTL_SECONDS, $lock->get_contract_id(), $state->get_token() );
+
+		// Fixed SQL fragments only; every value is a placeholder.
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
+		$updated = $wpdb->query( $wpdb->prepare( $sql, $values ) );
+
+		if ( false === $updated ) {
+			throw new \RuntimeException( esc_html( sprintf( 'Failed to extend the lock on contract %d: %s', $lock->get_contract_id(), $wpdb->last_error ) ) );
+		}
+
+		// The `write_seq` bump makes zero rows mean the lock is no longer held.
+		if ( 0 === $updated ) {
+			$this->throw_locked( $lock->get_contract_id() );
+		}
+
+		$state->stamp( ContractLock::TTL_SECONDS );
+		$state->mark_extended();
+	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
+
+	/**
+	 * Release a lock. Idempotent, and guarded by the token, so it never clears a lock another
+	 * operation took over. `locked_until_gmt` keeps the release moment (the dispatch fairness key).
 	 *
-	 * @param int    $contract_id           Contract id.
-	 * @param string $seen_next_payment_gmt The `next_payment_gmt` the caller read (UTC, `Y-m-d H:i:s`).
-	 * @param int    $ttl_seconds           Claim lifetime in seconds.
-	 * @return bool True when this call took the claim.
+	 * @param ContractLock $lock The lock.
+	 * @throws \RuntimeException If the write fails (the lock is still marked released and expires on its own).
 	 */
-	public function claim_contract( int $contract_id, string $seen_next_payment_gmt, int $ttl_seconds ): bool {
+	public function release_lock( ContractLock $lock ): void {
 		global $wpdb;

+		$state = $lock->get_state();
+		if ( $state->is_released() ) {
+			return;
+		}
+
+		$token = $state->get_token();
+		$state->mark_released();
+
 		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );

 		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
 		$updated = $wpdb->query(
 			$wpdb->prepare(
-				"UPDATE {$table} SET claimed_until = DATE_ADD( UTC_TIMESTAMP(), INTERVAL %d SECOND )
-				WHERE id = %d AND next_payment_gmt = %s AND ( claimed_until IS NULL OR claimed_until <= UTC_TIMESTAMP() )",
-				$ttl_seconds,
-				$contract_id,
-				$seen_next_payment_gmt
+				"UPDATE {$table} SET lock_token = NULL, lock_operation = NULL, locked_until_gmt = UTC_TIMESTAMP() WHERE id = %d AND lock_token = %s",
+				$lock->get_contract_id(),
+				$token
 			)
 		);
 		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

-		return 1 === $updated;
+		if ( false === $updated ) {
+			throw new \RuntimeException( esc_html( sprintf( 'Failed to release the lock on contract %d: %s', $lock->get_contract_id(), $wpdb->last_error ) ) );
+		}
+	}
+
+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
+	/**
+	 * Fence a child-table write (cycles, meta, items, addresses) made with `$lock`; within 60 s
+	 * of the lock's last database stamp it costs no query. Past that, the lock is extended once when
+	 * the local estimate gives 120 s or less left; otherwise it is re-verified on the database clock
+	 * and trusted for up to 60 s more, extended once with 60 s or less left, and refused once the
+	 * extend is used.
+	 *
+	 * @param ContractLock $lock The held lock.
+	 * @throws ContractLockedException If the lock was released, lost or is about to expire.
+	 * @throws \RuntimeException If the verify read or the extend write fails.
+	 */
+	private function fence_children( ContractLock $lock ): void {
+		global $wpdb;
+
+		$state = $lock->get_state();
+		if ( $state->is_released() ) {
+			$this->throw_locked( $lock->get_contract_id() );
+		}
+		if ( $state->is_trusted() ) {
+			return;
+		}
+		if ( ! $state->has_extended() && $state->is_near_expiry() ) {
+			$this->extend_lock( $lock );
+			return;
+		}
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+		$sql = "SELECT TIMESTAMPDIFF( SECOND, UTC_TIMESTAMP(), locked_until_gmt ) FROM {$table} WHERE id = %d AND lock_token = %s";
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
+		$remaining = $wpdb->get_var( $wpdb->prepare( $sql, $lock->get_contract_id(), $state->get_token() ) );
+		if ( '' !== $wpdb->last_error ) {
+			throw new \RuntimeException( esc_html( sprintf( 'Failed to verify the lock on contract %d: %s', $lock->get_contract_id(), $wpdb->last_error ) ) );
+		}
+
+		// No row: the lock was lost.
+		if ( null === $remaining ) {
+			$this->throw_locked( $lock->get_contract_id() );
+		}
+
+		$state->stamp( Coercion::coerce_int( $remaining ) );
+		if ( $state->is_trusted() ) {
+			return;
+		}
+		if ( ! $state->has_extended() ) {
+			$this->extend_lock( $lock );
+			return;
+		}
+
+		$this->throw_locked( $lock->get_contract_id() );
+	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
+
+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
+	/**
+	 * Take the internal lock for a write made without one: null when the contract does not exist.
+	 *
+	 * @internal Public for {@see \Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts::update()}, which holds it across its read and write.
+	 *
+	 * @param int $contract_id Contract id.
+	 * @throws ContractLockedException If another operation holds the lock.
+	 * @throws \RuntimeException If the lock write or the holder read fails.
+	 */
+	public function acquire_internal_lock( int $contract_id ): ?ContractLock {
+		$lock = $this->acquire_lock( $contract_id, self::INTERNAL_LOCK_OPERATION );
+		if ( null !== $lock ) {
+			return $lock;
+		}
+
+		$holder = $this->get_lock_holder( $contract_id );
+		if ( ! $holder['exists'] ) {
+			return null;
+		}
+		throw self::locked_exception( $contract_id, $holder ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the exception message is not output.
+	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
+
+	/**
+	 * Release the internal lock. A failed release is logged, not thrown, so it cannot replace the
+	 * write's result or exception; the lock expires on its own.
+	 *
+	 * @internal Public for {@see \Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts::update()}.
+	 *
+	 * @param ContractLock $lock The internal lock.
+	 */
+	public function release_internal_lock( ContractLock $lock ): void {
+		try {
+			$this->release_lock( $lock );
+		} catch ( \RuntimeException $e ) {
+			wc_get_logger()->warning(
+				sprintf( 'ContractRepository: failed to release the internal lock on contract %d; it expires on its own. %s', $lock->get_contract_id(), $e->getMessage() ),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => $lock->get_contract_id(),
+				)
+			);
+		}
+	}
+
+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
+	/**
+	 * Fence a child write on its first lines: the lock must be for this contract and still trusted
+	 * or verifiable. May verify the lock on the database clock and extend it once.
+	 *
+	 * @param ContractLock $lock        The lock.
+	 * @param int          $contract_id The contract being written.
+	 * @throws \InvalidArgumentException If the lock is for another contract.
+	 * @throws ContractLockedException If the lock was lost.
+	 */
+	private function fence_child_write( ContractLock $lock, int $contract_id ): void {
+		self::assert_lock_for( $lock, $contract_id );
+		$this->fence_children( $lock );
+	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
+
+	/**
+	 * Refuse a lock that belongs to another contract.
+	 *
+	 * @param ContractLock $lock        The lock.
+	 * @param int          $contract_id The contract being written.
+	 * @throws \InvalidArgumentException If the lock is for another contract.
+	 */
+	public static function assert_lock_for( ContractLock $lock, int $contract_id ): void {
+		if ( $lock->get_contract_id() !== $contract_id ) {
+			throw new \InvalidArgumentException( esc_html( sprintf( 'The lock belongs to contract %d, not contract %d.', $lock->get_contract_id(), $contract_id ) ) );
+		}
+	}
+
+	/**
+	 * Throw a conflict carrying the contract's current lock holder, if any.
+	 *
+	 * @param int $contract_id Contract id.
+	 * @return never
+	 * @throws ContractLockedException Always.
+	 */
+	private function throw_locked( int $contract_id ): void {
+		throw self::locked_exception( $contract_id, $this->get_lock_holder( $contract_id ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the exception message is not output.
+	}
+
+	/**
+	 * A conflict carrying the holder already read.
+	 *
+	 * @param int                                     $contract_id Contract id.
+	 * @param array{exists: bool, operation: ?string} $holder      The lock holder read.
+	 */
+	private static function locked_exception( int $contract_id, array $holder ): ContractLockedException {
+		return new ContractLockedException( $contract_id, $holder['operation'] );
+	}
+
+	/**
+	 * Refuse an operation label that is not a slug of 1-64 lowercase letters, digits,
+	 * underscores or hyphens (the schedule source rule).
+	 *
+	 * @param string $operation Operation label.
+	 * @throws \InvalidArgumentException If the label is not a valid slug.
+	 */
+	private static function assert_operation( string $operation ): void {
+		if ( 1 !== preg_match( '/^[a-z0-9_-]{1,64}$/D', $operation ) ) {
+			throw new \InvalidArgumentException( 'A lock operation label must be 1-64 lowercase letters, digits, underscores or hyphens.' );
+		}
+	}
+
+	/**
+	 * Whether the last statement failed on a UNIQUE index (MySQL error 1062), read from the
+	 * driver; null when the connection is not mysqli, so the caller reads the row back instead.
+	 * The error number does not depend on the server's message language or on a read snapshot.
+	 */
+	private static function get_duplicate_key_result(): ?bool {
+		global $wpdb;
+
+		$dbh = $wpdb->dbh;
+		if ( ! $dbh instanceof \mysqli ) {
+			return null;
+		}
+
+		return 1062 === $dbh->errno;
+	}
+
+	/**
+	 * A new lock token: 32 hex characters, the `lock_token` column width.
+	 */
+	private static function new_token(): string {
+		return bin2hex( random_bytes( 16 ) );
+	}
+
+	/**
+	 * The SQL for lock conditions: ` AND <column> = %s` or ` AND <column> IS NULL` per condition,
+	 * with the column name taken from the allowlist and every value a placeholder.
+	 *
+	 * @param array<string, mixed> $conditions Contract column => value.
+	 * @return array{0: string, 1: array<int, string>} The SQL fragment and its values.
+	 * @throws \InvalidArgumentException If a column is not in the allowlist or a value is neither scalar nor null.
+	 */
+	private static function get_condition_clause( array $conditions ): array {
+		$sql    = '';
+		$values = array();
+		foreach ( $conditions as $column => $value ) {
+			if ( ! isset( self::LOCK_CONDITION_COLUMNS[ $column ] ) ) {
+				throw new \InvalidArgumentException( esc_html( sprintf( 'A lock cannot be conditioned on "%s".', $column ) ) );
+			}
+			$name = self::LOCK_CONDITION_COLUMNS[ $column ];
+
+			if ( null === $value ) {
+				$sql .= " AND {$name} IS NULL";
+			} elseif ( is_scalar( $value ) ) {
+				$sql     .= " AND {$name} = %s";
+				$values[] = (string) $value;
+			} else {
+				throw new \InvalidArgumentException( esc_html( sprintf( 'The lock condition on "%s" must be a scalar or null.', $column ) ) );
+			}
+		}
+
+		return array( $sql, $values );
 	}

 	/**
@@ -750,7 +1208,8 @@ final class ContractRepository {
 			)
 		);

-		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$table  = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$select = self::CONTRACT_SELECT;

 		// One query for both shapes: the optional status filter joins the WHERE, and
 		// every value is a placeholder (table names cannot be bound, so the table is
@@ -768,7 +1227,7 @@ final class ContractRepository {
 		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
 		$rows = $wpdb->get_results(
 			$wpdb->prepare(
-				"SELECT *
+				"SELECT {$select}
 				FROM {$table}
 				WHERE {$where}
 				ORDER BY id DESC
@@ -792,39 +1251,23 @@ final class ContractRepository {
 	public function find_by_origin_order( int $order_id ): array {
 		global $wpdb;

-		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$table  = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$select = self::CONTRACT_SELECT;

 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-		$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE origin_order_id = %d ORDER BY id ASC", $order_id ), ARRAY_A );
+		$rows = $wpdb->get_results( $wpdb->prepare( "SELECT {$select} FROM {$table} WHERE origin_order_id = %d ORDER BY id ASC", $order_id ), ARRAY_A );

 		return $this->contracts_from_rows( is_array( $rows ) ? $rows : array() );
 	}

 	/**
-	 * Whether a contract row exists for `$id`.
+	 * Delete a contract and its child, cycle and snapshot rows. Takes no lock: it is the
+	 * engine-internal discard of a contract its creator still holds locked.
 	 *
 	 * @param int $id Contract id.
+	 * @return bool True when the contract row was removed.
 	 */
-	public function exists( int $id ): bool {
-		global $wpdb;
-
-		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
-
-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-		$found = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE id = %d", $id ) );
-
-		return null !== $found;
-	}
-
-	/**
-	 * Delete a contract and its child + cycle rows.
-	 *
-	 * Snapshot rows are per contract, so they are deleted with the contract too.
-	 *
-	 * @param int $id Contract id.
-	 * @return bool True when the contract row was removed.
-	 */
-	public function delete( int $id ): bool {
+	public function delete( int $id ): bool {
 		global $wpdb;

 		foreach ( array(
@@ -844,129 +1287,304 @@ final class ContractRepository {
 		return (bool) $deleted;
 	}

+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
 	/**
-	 * Add a meta row for a contract, like `add_post_meta()`.
-	 *
-	 * @param int    $contract_id Contract id.
-	 * @param string $key         Meta key.
-	 * @param mixed  $value       Meta value; serialized when not scalar.
-	 * @param bool   $unique      When true, add nothing if the key already exists. Advisory:
-	 *                            checked before the insert with no unique index.
+	 * Add a meta row for a contract, like `add_post_meta()`, under `$lock` or, without one, under
+	 * an internal lock taken for the call. With `$unique` the insert checks the key is absent.
+	 *
+	 * @param int               $contract_id Contract id.
+	 * @param string            $key         Meta key.
+	 * @param mixed             $value       Meta value; serialized when not scalar.
+	 * @param bool              $unique      When true, add nothing if the key already exists.
+	 * @param ContractLock|null $lock        The caller's lock on this contract, or null.
 	 * @return int|null The new meta row id, or null when `$unique` and the key exists.
-	 * @throws \InvalidArgumentException If `$key` is empty.
+	 * @throws \InvalidArgumentException If `$key` is empty, the contract does not exist, or the lock is for another contract.
+	 * @throws ContractLockedException If another operation holds the lock, or `$lock` was lost.
 	 * @throws \RuntimeException If the insert fails.
 	 */
-	public function add_meta( int $contract_id, string $key, $value, bool $unique = false ): ?int {
-		global $wpdb;
-
+	public function add_meta( int $contract_id, string $key, $value, bool $unique = false, ?ContractLock $lock = null ): ?int {
 		if ( '' === $key ) {
 			throw new \InvalidArgumentException( 'Contract meta key must not be empty.' );
 		}

-		if ( $unique && array() !== $this->find_meta_values( $contract_id, $key ) ) {
+		if ( null !== $lock ) {
+			return $this->add_meta_row( $contract_id, $key, $value, $unique, $lock );
+		}
+
+		$internal_lock = $this->acquire_internal_lock( $contract_id );
+		if ( null === $internal_lock ) {
+			throw new \InvalidArgumentException( esc_html( sprintf( 'Contract %d does not exist.', $contract_id ) ) );
+		}
+		try {
+			return $this->add_meta_row( $contract_id, $key, $value, $unique, $internal_lock );
+		} finally {
+			$this->release_internal_lock( $internal_lock );
+		}
+	}
+
+	/**
+	 * The add behind {@see self::add_meta()}, under a held lock.
+	 *
+	 * @param int          $contract_id Contract id.
+	 * @param string       $key         Meta key.
+	 * @param mixed        $value       Meta value; serialized when not scalar.
+	 * @param bool         $unique      When true, add nothing if the key already exists.
+	 * @param ContractLock $lock        The held lock.
+	 * @return int|null The new meta row id, or null when `$unique` and the key exists.
+	 */
+	private function add_meta_row( int $contract_id, string $key, $value, bool $unique, ContractLock $lock ): ?int {
+		// A plain read under the lock, as `add_metadata()` does: every meta writer holds the lock.
+		if ( $unique && $this->has_meta_key( $contract_id, $key ) ) {
 			return null;
 		}

-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
-		$inserted = $wpdb->insert(
-			SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ),
-			array(
-				'contract_id' => $contract_id,
-				'meta_key'    => $key,
-				'meta_value'  => maybe_serialize( $value ),
-			)
-		);
+		$meta_id = $this->insert_meta_row( $contract_id, $key, $value, $lock );
+		if ( null === $meta_id ) {
+			$this->throw_for_empty_meta_insert( $contract_id );
+		}
+
+		return $meta_id;
+	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
+
+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
+	/**
+	 * Insert one meta row as `INSERT ... SELECT` from the contract row, so a missing contract
+	 * inserts nothing. Callers that add only an absent key read it first, under the lock.
+	 *
+	 * @param int          $contract_id Contract id.
+	 * @param string       $key         Meta key.
+	 * @param mixed        $value       Meta value; serialized when not scalar.
+	 * @param ContractLock $lock        The held lock.
+	 * @return int|null The new meta row id, or null when nothing was inserted.
+	 * @throws \InvalidArgumentException If the lock is for another contract.
+	 * @throws ContractLockedException If the lock was lost.
+	 * @throws \RuntimeException If the insert fails.
+	 */
+	private function insert_meta_row( int $contract_id, string $key, $value, ContractLock $lock ): ?int {
+		global $wpdb;
+
+		$this->fence_child_write( $lock, $contract_id );
+
+		$meta      = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META );
+		$contracts = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$inserted = $wpdb->query( $wpdb->prepare( "INSERT INTO {$meta} ( contract_id, meta_key, meta_value ) SELECT c.id, %s, %s FROM {$contracts} c WHERE c.id = %d", $key, maybe_serialize( $value ), $contract_id ) );

 		if ( false === $inserted ) {
-			throw new \RuntimeException( sprintf( 'Failed to add contract meta "%s" for contract %d: %s', esc_html( $key ), (int) $contract_id, esc_html( $wpdb->last_error ) ) );
+			$failure = new \RuntimeException( sprintf( 'Failed to add contract meta "%s" for contract %d: %s', esc_html( $key ), (int) $contract_id, esc_html( $wpdb->last_error ) ) );
+			$this->throw_if_lock_lost( $lock );
+			throw $failure;
+		}
+		if ( 1 !== (int) $inserted ) {
+			return null;
 		}

 		return (int) $wpdb->insert_id;
 	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

 	/**
-	 * Update a contract's meta rows for `$key`, like `update_post_meta()`: adds a row when
-	 * the key is absent, else rewrites every row for the key, or only the rows holding
-	 * `$prev_value`. The absent-key check runs before the write with no unique index.
+	 * Whether the contract has a meta row for `$key`.
 	 *
 	 * @param int    $contract_id Contract id.
 	 * @param string $key         Meta key.
-	 * @param mixed  $value       New value; serialized when not scalar.
-	 * @param mixed  $prev_value  Only update rows holding this value; null updates all rows for the key.
-	 *                            Any other value ('' and false included) matches literally.
-	 * @return bool True when a row was added or at least one row changed.
-	 * @throws \InvalidArgumentException If `$key` is empty.
-	 * @throws \RuntimeException If a write fails.
+	 * @throws \RuntimeException If the read fails.
 	 */
-	public function update_meta( int $contract_id, string $key, $value, $prev_value = null ): bool {
+	private function has_meta_key( int $contract_id, string $key ): bool {
 		global $wpdb;

+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+		$found = $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$table} WHERE contract_id = %d AND meta_key = %s LIMIT 1", $contract_id, $key ) );
+		if ( '' !== $wpdb->last_error ) {
+			throw new \RuntimeException( sprintf( 'Failed to read contract meta "%s" for contract %d: %s', esc_html( $key ), (int) $contract_id, esc_html( $wpdb->last_error ) ) );
+		}
+
+		return null !== $found;
+	}
+
+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
+	/**
+	 * Update a contract's meta rows for `$key`, like `update_post_meta()`, under `$lock` or, without
+	 * one, under an internal lock taken for the call: rewrites every row for the key, or only the
+	 * rows holding `$prev_value`, and adds the key when it has no row.
+	 *
+	 * @param int               $contract_id Contract id.
+	 * @param string            $key         Meta key.
+	 * @param mixed             $value       New value; serialized when not scalar.
+	 * @param mixed             $prev_value  Only update rows holding this value; null updates all rows for the key.
+	 *                                       Any other value ('' and false included) matches literally.
+	 * @param ContractLock|null $lock        The caller's lock on this contract, or null.
+	 * @return bool True when a row was added or at least one row changed.
+	 * @throws \InvalidArgumentException If `$key` is empty, the contract does not exist, or the lock is for another contract.
+	 * @throws ContractLockedException If another operation holds the lock, or `$lock` was lost.
+	 * @throws \RuntimeException If a write fails.
+	 */
+	public function update_meta( int $contract_id, string $key, $value, $prev_value = null, ?ContractLock $lock = null ): bool {
 		if ( '' === $key ) {
 			throw new \InvalidArgumentException( 'Contract meta key must not be empty.' );
 		}

-		if ( array() === $this->find_meta_values( $contract_id, $key ) ) {
-			$this->add_meta( $contract_id, $key, $value );
+		if ( null !== $lock ) {
+			return $this->update_meta_value( $contract_id, $key, $value, $prev_value, $lock );
+		}
+
+		$internal_lock = $this->acquire_internal_lock( $contract_id );
+		if ( null === $internal_lock ) {
+			throw new \InvalidArgumentException( esc_html( sprintf( 'Contract %d does not exist.', $contract_id ) ) );
+		}
+		try {
+			return $this->update_meta_value( $contract_id, $key, $value, $prev_value, $internal_lock );
+		} finally {
+			$this->release_internal_lock( $internal_lock );
+		}
+	}
+
+	/**
+	 * The update behind {@see self::update_meta()}, under a held lock: rewrite the matching rows,
+	 * or add the key when it has no row.
+	 *
+	 * @param int          $contract_id Contract id.
+	 * @param string       $key         Meta key.
+	 * @param mixed        $value       New value; serialized when not scalar.
+	 * @param mixed        $prev_value  Only update rows holding this value; null updates all rows for the key.
+	 * @param ContractLock $lock        The held lock.
+	 * @return bool True when a row was added or at least one row changed.
+	 */
+	private function update_meta_value( int $contract_id, string $key, $value, $prev_value, ContractLock $lock ): bool {
+		$updated = $this->update_meta_rows( $contract_id, $key, $value, $prev_value, $lock );
+		if ( $updated > 0 ) {
 			return true;
 		}
+		if ( $this->has_meta_key( $contract_id, $key ) ) {
+			return false;
+		}

-		$where = array(
-			'contract_id' => $contract_id,
-			'meta_key'    => $key,
-		);
+		$meta_id = $this->insert_meta_row( $contract_id, $key, $value, $lock );
+		if ( null === $meta_id ) {
+			$this->throw_for_empty_meta_insert( $contract_id );
+		}
+
+		return true;
+	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
+
+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
+	/**
+	 * Rewrite the meta rows for `$key` (only those holding `$prev_value`, when given).
+	 *
+	 * @param int          $contract_id Contract id.
+	 * @param string       $key         Meta key.
+	 * @param mixed        $value       New value; serialized when not scalar.
+	 * @param mixed        $prev_value  Only update rows holding this value; null updates all rows for the key.
+	 * @param ContractLock $lock        The held lock.
+	 * @return int The number of rows changed.
+	 * @throws \InvalidArgumentException If the lock is for another contract.
+	 * @throws ContractLockedException If the lock was lost.
+	 * @throws \RuntimeException If the write fails.
+	 */
+	private function update_meta_rows( int $contract_id, string $key, $value, $prev_value, ContractLock $lock ): int {
+		global $wpdb;
+
+		$this->fence_child_write( $lock, $contract_id );
+
+		$meta   = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META );
+		$values = array( maybe_serialize( $value ), $contract_id, $key );
+		$where  = 'contract_id = %d AND meta_key = %s';
 		if ( null !== $prev_value ) {
-			$where['meta_value'] = maybe_serialize( $prev_value );
+			$where   .= ' AND meta_value = %s';
+			$values[] = maybe_serialize( $prev_value );
 		}

-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
-		$updated = $wpdb->update(
-			SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ),
-			array( 'meta_value' => maybe_serialize( $value ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
-			$where
-		);
+		// The WHERE clause is built from fixed fragments above; every value is a placeholder.
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+		$updated = $wpdb->query( $wpdb->prepare( "UPDATE {$meta} SET meta_value = %s WHERE {$where}", $values ) );

 		if ( false === $updated ) {
 			throw new \RuntimeException( sprintf( 'Failed to update contract meta "%s" for contract %d: %s', esc_html( $key ), (int) $contract_id, esc_html( $wpdb->last_error ) ) );
 		}

-		return $updated > 0;
+		return (int) $updated;
 	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
 	/**
-	 * Delete a contract's meta rows for `$key`, like `delete_post_meta()`.
-	 *
-	 * @param int    $contract_id Contract id.
-	 * @param string $key         Meta key.
-	 * @param mixed  $value       Only delete rows holding this value; null deletes every row for the key.
-	 *                            Any other value ('' and false included) matches literally.
-	 * @return bool True when at least one row was deleted.
-	 * @throws \InvalidArgumentException If `$key` is empty.
+	 * Delete a contract's meta rows for `$key`, like `delete_post_meta()`, under `$lock` or, without
+	 * one, under an internal lock taken for the call.
+	 *
+	 * @param int               $contract_id Contract id.
+	 * @param string            $key         Meta key.
+	 * @param mixed             $value       Only delete rows holding this value; null deletes every row for the key.
+	 *                                       Any other value ('' and false included) matches literally.
+	 * @param ContractLock|null $lock        The caller's lock on this contract, or null.
+	 * @return bool True when at least one row was deleted (false for a missing contract).
+	 * @throws \InvalidArgumentException If `$key` is empty, or the lock is for another contract.
+	 * @throws ContractLockedException If another operation holds the lock, or `$lock` was lost.
 	 * @throws \RuntimeException If the delete fails.
 	 */
-	public function delete_meta( int $contract_id, string $key, $value = null ): bool {
-		global $wpdb;
-
+	public function delete_meta( int $contract_id, string $key, $value = null, ?ContractLock $lock = null ): bool {
 		if ( '' === $key ) {
 			throw new \InvalidArgumentException( 'Contract meta key must not be empty.' );
 		}

-		$where = array(
-			'contract_id' => $contract_id,
-			'meta_key'    => $key,
-		);
+		if ( null !== $lock ) {
+			$deleted = $this->delete_meta_rows( $contract_id, $key, $value, $lock );
+			return $deleted > 0;
+		}
+
+		$internal_lock = $this->acquire_internal_lock( $contract_id );
+		if ( null === $internal_lock ) {
+			return false;
+		}
+		try {
+			$deleted = $this->delete_meta_rows( $contract_id, $key, $value, $internal_lock );
+		} finally {
+			$this->release_internal_lock( $internal_lock );
+		}
+
+		return $deleted > 0;
+	}
+
+	/**
+	 * Delete the meta rows for `$key` (only those holding `$value`, when given).
+	 *
+	 * @param int          $contract_id Contract id.
+	 * @param string       $key         Meta key.
+	 * @param mixed        $value       Only delete rows holding this value; null deletes every row for the key.
+	 * @param ContractLock $lock        The held lock.
+	 * @return int The number of rows deleted.
+	 * @throws \InvalidArgumentException If the lock is for another contract.
+	 * @throws ContractLockedException If the lock was lost.
+	 * @throws \RuntimeException If the delete fails.
+	 */
+	private function delete_meta_rows( int $contract_id, string $key, $value, ContractLock $lock ): int {
+		global $wpdb;
+
+		$this->fence_child_write( $lock, $contract_id );
+
+		$meta   = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META );
+		$values = array( $contract_id, $key );
+		$where  = 'contract_id = %d AND meta_key = %s';
 		if ( null !== $value ) {
-			$where['meta_value'] = maybe_serialize( $value );
+			$where   .= ' AND meta_value = %s';
+			$values[] = maybe_serialize( $value );
 		}

-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-		$deleted = $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ), $where );
+		// The WHERE clause is built from fixed fragments above; every value is a placeholder.
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+		$deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM {$meta} WHERE {$where}", $values ) );

 		if ( false === $deleted ) {
 			throw new \RuntimeException( sprintf( 'Failed to delete contract meta "%s" for contract %d: %s', esc_html( $key ), (int) $contract_id, esc_html( $wpdb->last_error ) ) );
 		}

-		return $deleted > 0;
+		return (int) $deleted;
 	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

 	/**
 	 * Read contract meta (WordPress `get_post_meta()` semantics), values unserialized,
@@ -977,6 +1595,7 @@ final class ContractRepository {
 	 * @param bool   $single      With a key: return the first value only.
 	 * @return mixed Empty key: `array<string, array<int, mixed>>` of all keys. Key + `$single`:
 	 *               the first value, or '' when absent. Key only: the list of values (`[]` when absent).
+	 * @throws \RuntimeException If the read fails.
 	 */
 	public function get_meta( int $contract_id, string $key = '', bool $single = false ) {
 		if ( '' === $key ) {
@@ -997,98 +1616,137 @@ final class ContractRepository {
 		return $values;
 	}

+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
 	/**
-	 * Append a cycle to its chain `(contract_id, kind)`.
-	 *
-	 * Assigns an unassigned `sequence_no` (the chain head's plus one, or 1), then inserts
-	 * the cycle row and stamps the generated id back onto the entity. The chain's
-	 * UNIQUE indexes guard concurrent appends and duplicate counts. The seam a later
-	 * transaction-handling change wraps.
-	 *
-	 * With `$claim_seconds`, the cycle is appended claimed: the same INSERT stamps
-	 * `claimed_until` from the database clock, like {@see self::claim_cycle()} and
-	 * {@see self::transition_cycle()}, so the claim and the row land together. The entity's
-	 * own claim value is never written.
-	 *
-	 * @param Cycle    $cycle         Cycle to append. Carries its contract id and kind.
-	 * @param int|null $claim_seconds Claim length in seconds from the database's now, or null to append unclaimed.
-	 * @throws DuplicateCycleException If a chain UNIQUE index rejects the row (the
-	 *                                 position is already claimed - the create-as-claim race).
-	 * @throws \RuntimeException If the cycle insert fails for any other reason (the
-	 *                          database error is in the message).
+	 * Append a cycle to its chain `(contract_id, kind)` under `$lock`. An unassigned `sequence_no`
+	 * becomes the chain's highest plus one, read under the lock before the insert; the chain's
+	 * UNIQUE indexes still refuse a taken position or count. The new id is set on the entity.
+	 *
+	 * @param Cycle        $cycle Cycle to append. Carries its contract id and kind.
+	 * @param ContractLock $lock  The caller's lock on the cycle's contract.
+	 * @throws DuplicateCycleException If a chain UNIQUE index rejects the row (the position or count is taken).
+	 * @throws ContractLockedException If `$lock` was lost.
+	 * @throws \InvalidArgumentException If the lock is for another contract.
+	 * @throws \RuntimeException If a value is longer than its column, or the position read or the insert fails.
 	 */
-	public function append_cycle( Cycle $cycle, ?int $claim_seconds = null ): void {
-		$this->assign_next_sequence_no( $cycle );
-		$this->insert_cycle( $cycle, $claim_seconds );
+	public function append_cycle( Cycle $cycle, ContractLock $lock ): void {
+		global $wpdb;
+
+		$this->fence_child_write( $lock, $cycle->get_contract_id() );
+
+		if ( $cycle->awaits_sequence_no() ) {
+			$sequence_no = $this->get_next_sequence_no( $cycle->get_contract_id(), $cycle->get_kind() );
+			$cycle->assign_sequence_no( $sequence_no );
+		}
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+		$now   = gmdate( 'Y-m-d H:i:s' );
+
+		$columns                     = $cycle->to_storage();
+		$columns['date_created_gmt'] = $now;
+		$columns['date_updated_gmt'] = $now;
+
+		// Checked first so an over-long value is refused with its column named.
+		foreach ( $columns as $column => $value ) {
+			if ( is_string( $value ) ) {
+				self::assert_fits_column( $table, $column, $value );
+			}
+		}
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+		$inserted = $wpdb->insert( $table, $columns );
+
+		if ( false === $inserted ) {
+			$db_error  = $wpdb->last_error;
+			$duplicate = self::get_duplicate_key_result();
+			if ( null === $duplicate ) {
+				$duplicate = $this->is_chain_slot_taken( $cycle );
+			}
+			if ( $duplicate ) {
+				throw new DuplicateCycleException( esc_html( 'Cycle position already exists: ' . $db_error ) );
+			}
+			$this->throw_if_lock_lost( $lock );
+			throw new \RuntimeException( esc_html( 'Failed to insert cycle. ' . $db_error ) );
+		}
+
+		$cycle->set_id( (int) $wpdb->insert_id );
 	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

 	/**
-	 * Assign the chain's next `sequence_no` to a cycle that awaits one.
+	 * Whether the cycle's chain already holds its position or its count: the fallback when the
+	 * driver gives no error number. A failed read reads as false, so the caller reports its own
+	 * insert failure.
 	 *
-	 * @param Cycle $cycle Cycle about to be appended.
+	 * @param Cycle $cycle Cycle that failed to insert, with its position assigned.
 	 */
-	private function assign_next_sequence_no( Cycle $cycle ): void {
-		if ( ! $cycle->awaits_sequence_no() ) {
-			return;
+	private function is_chain_slot_taken( Cycle $cycle ): bool {
+		global $wpdb;
+
+		$table  = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+		$where  = 'contract_id = %d AND kind = %s AND ( sequence_no = %d';
+		$values = array( $cycle->get_contract_id(), $cycle->get_kind(), $cycle->get_sequence_no() );
+		if ( null !== $cycle->get_count() ) {
+			$where   .= ' OR count = %d';
+			$values[] = $cycle->get_count();
 		}

-		$head = $this->find_chain_head( $cycle->get_contract_id(), $cycle->get_kind() );
-		$cycle->assign_sequence_no( null === $head ? 1 : $head->get_sequence_no() + 1 );
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+		$found = $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM `{$table}` WHERE {$where} ) LIMIT 1", $values ) );
+
+		return null !== $found;
 	}

 	/**
-	 * Take the claim on a cycle in `$status` whose claim has expired or was never set, for
-	 * `$claim_seconds`. A compare-and-set on the status and the claim: among concurrent
-	 * callers exactly one matches the row. The expiry check and the new claim use the
-	 * database clock (`UTC_TIMESTAMP()`), the one clock every worker shares.
+	 * The next position in a chain: its highest `sequence_no` plus one. A plain read under the
+	 * caller's lock; inside a caller's REPEATABLE READ transaction a locking read here would
+	 * gap-lock the chain index and could deadlock concurrent first appends.
 	 *
-	 * @param int    $cycle_id      Cycle id.
-	 * @param string $status        The status the caller read; the compare-and-set predicate (not validated).
-	 * @param int    $claim_seconds Claim length in seconds, from the database's now.
-	 * @return bool True when this caller took the claim.
+	 * @param int    $contract_id Contract id.
+	 * @param string $kind        Chain kind.
+	 * @throws \RuntimeException If the read fails.
 	 */
-	public function claim_cycle( int $cycle_id, string $status, int $claim_seconds ): bool {
+	private function get_next_sequence_no( int $contract_id, string $kind ): int {
 		global $wpdb;

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

-		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-		$result = $wpdb->query(
-			$wpdb->prepare(
-				"UPDATE {$table} SET claimed_until = DATE_ADD( UTC_TIMESTAMP(), INTERVAL %d SECOND ), date_updated_gmt = UTC_TIMESTAMP()
-				WHERE id = %d AND status = %s AND ( claimed_until IS NULL OR claimed_until <= UTC_TIMESTAMP() )",
-				$claim_seconds,
-				$cycle_id,
-				$status
-			)
-		);
-		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$next = $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE( MAX( sequence_no ), 0 ) + 1 FROM `{$table}` WHERE contract_id = %d AND kind = %s", $contract_id, $kind ) );
+		if ( '' !== $wpdb->last_error ) {
+			throw new \RuntimeException( esc_html( sprintf( 'Failed to read the next cycle position for contract %d: %s', $contract_id, $wpdb->last_error ) ) );
+		}

-		return false !== $result && 1 === $wpdb->rows_affected;
+		return Coercion::coerce_int( $next );
 	}

+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
 	/**
-	 * Move a cycle from `$from_status` to `$to_status` as a compare-and-set on its status:
-	 * one UPDATE that lands only while the row still carries `$from_status`, so among
-	 * concurrent callers exactly one wins. `$changes` keys present are written: `order_id`
-	 * and `reason` (null clears); `claim_seconds` sets a claim from the database's now, and
-	 * without it the claim is released. A same-status call only writes `$changes`, so it
-	 * returns true whenever the row still carries the status, even when nothing changed.
+	 * Move a cycle from `$from_status` to `$to_status` under `$lock` in one compare-and-set UPDATE
+	 * that lands only while the row still carries `$from_status` and belongs to the lock's
+	 * contract. A same-status call returns true once the cycle holds that status and the values.
 	 *
 	 * @param int                  $cycle_id    Cycle id.
 	 * @param string               $from_status The status the caller read; the compare-and-set predicate (not validated).
 	 * @param string               $to_status   The status to write; must be a registered cycle status.
-	 * @param array<string, mixed> $changes     Optional `order_id` (?int), `reason` (?string), `claim_seconds` (int).
-	 * @return bool True when this caller won the transition (for a same-status call: the row carries the status).
+	 * @param array<string, mixed> $changes     Optional `order_id` (?int), `reason` (?string); present keys are written, null clears.
+	 * @param ContractLock         $lock        The caller's lock on the cycle's contract.
+	 * @return bool True when this caller won the transition (for a same-status call: the cycle holds the status and the values).
 	 * @throws \DomainException If `$to_status` is not a registered cycle status.
+	 * @throws \InvalidArgumentException If the cycle does not exist, or belongs to another contract than `$lock`.
+	 * @throws ContractLockedException If `$lock` was lost.
+	 * @throws \RuntimeException If the write fails.
 	 */
-	public function transition_cycle( int $cycle_id, string $from_status, string $to_status, array $changes ): bool {
+	public function transition_cycle( int $cycle_id, string $from_status, string $to_status, array $changes, ContractLock $lock ): bool {
 		global $wpdb;

 		if ( ! CycleStatus::is_registered( $to_status ) ) {
 			throw new \DomainException( esc_html( sprintf( 'ContractRepository::transition_cycle(): cycle status "%s" is not registered.', $to_status ) ) );
 		}

+		// The contract match is in the UPDATE's WHERE clause: this method knows only the cycle id.
+		$this->fence_children( $lock );
+
 		$set    = array( 'status = %s' );
 		$values = array( $to_status );

@@ -1110,64 +1768,94 @@ final class ContractRepository {
 			}
 		}

-		if ( array_key_exists( 'claim_seconds', $changes ) ) {
-			$set[]    = 'claimed_until = DATE_ADD( UTC_TIMESTAMP(), INTERVAL %d SECOND )';
-			$values[] = Coercion::coerce_int( $changes['claim_seconds'] );
-		} else {
-			$set[] = 'claimed_until = NULL';
-		}
-
 		$set[]    = 'date_updated_gmt = %s';
 		$values[] = gmdate( 'Y-m-d H:i:s' );
 		$values[] = $cycle_id;
 		$values[] = $from_status;
+		$values[] = $lock->get_contract_id();

-		$table      = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+		$cycles     = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
 		$set_clause = implode( ', ', $set );
+		$sql        = "UPDATE {$cycles} SET {$set_clause} WHERE id = %d AND status = %s AND contract_id = %d";

 		// The SET clause is built from fixed fragments above; every value is a placeholder.
-		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
-		$result = $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET {$set_clause} WHERE id = %d AND status = %s", $values ) );
-		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
+		$result = $wpdb->query( $wpdb->prepare( $sql, $values ) );

 		if ( false === $result ) {
-			return false;
+			throw new \RuntimeException( esc_html( sprintf( 'Failed to transition cycle %d: %s', $cycle_id, $wpdb->last_error ) ) );
 		}

 		if ( 1 === $wpdb->rows_affected ) {
 			return true;
 		}

-		// MySQL counts changed rows, not matched ones: a same-status call whose values are
-		// already stored matches the row but changes nothing. It still succeeded when the row
-		// carries the status.
-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-		return $from_status === $to_status && null !== $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE id = %d AND status = %s", $cycle_id, $to_status ) );
+		return $this->classify_lost_transition( $cycle_id, $from_status, $to_status, $changes, $lock );
 	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
 	/**
-	 * The chain's head - its most-recent cycle (highest `sequence_no` in `(contract_id,
-	 * kind)`) - or null when the chain is empty. The head is the chain's growth point, not
-	 * necessarily the cycle current by date (an extension may append a head whose
-	 * period lies ahead).
+	 * Tell apart why a cycle transition changed no row, from the stored cycle: a same-status call
+	 * whose status and values are already stored is a no-op win; anything else is a lost
+	 * compare-and-set.
 	 *
-	 * @param int    $contract_id Contract id.
-	 * @param string $kind        Chain kind. Defaults to billing.
-	 * @return Cycle|null The head cycle, or null if the chain has none.
+	 * @param int                  $cycle_id    Cycle id.
+	 * @param string               $from_status The compare-and-set predicate.
+	 * @param string               $to_status   The status written.
+	 * @param array<string, mixed> $changes     The `order_id` / `reason` changes written.
+	 * @param ContractLock         $lock        The caller's lock.
+	 * @return bool True for a same-status no-op, false when the status moved.
+	 * @throws \InvalidArgumentException If the cycle does not exist, or belongs to another contract than `$lock`.
+	 * @throws \RuntimeException If the read fails.
 	 */
-	public function find_chain_head( int $contract_id, string $kind = Cycle::KIND_BILLING ): ?Cycle {
+	private function classify_lost_transition( int $cycle_id, string $from_status, string $to_status, array $changes, ContractLock $lock ): bool {
 		global $wpdb;

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

 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE contract_id = %d AND kind = %s ORDER BY sequence_no DESC LIMIT 1", $contract_id, $kind ), ARRAY_A );
+		$row = $wpdb->get_row( $wpdb->prepare( "SELECT contract_id, status, order_id, reason FROM {$table} WHERE id = %d", $cycle_id ), ARRAY_A );
+		if ( '' !== $wpdb->last_error ) {
+			throw new \RuntimeException( esc_html( sprintf( 'Failed to read cycle %d: %s', $cycle_id, $wpdb->last_error ) ) );
+		}
+		if ( ! is_array( $row ) ) {
+			throw new \InvalidArgumentException( esc_html( sprintf( 'Cycle %d does not exist.', $cycle_id ) ) );
+		}

-		if ( null === $row ) {
-			return null;
+		$contract_id = Coercion::coerce_int( $row['contract_id'] ?? 0 );
+		if ( $lock->get_contract_id() !== $contract_id ) {
+			throw new \InvalidArgumentException( esc_html( sprintf( 'Cycle %d does not belong to contract %d.', $cycle_id, $lock->get_contract_id() ) ) );
 		}

-		return $this->hydrate_cycle( $row );
+		$status = Coercion::coerce_string( $row['status'] ?? '' );
+
+		return $from_status === $to_status && $status === $to_status && self::has_values( $row, $changes );
+	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
+
+	/**
+	 * Whether a stored cycle row already has the `order_id` / `reason` values of `$changes`.
+	 *
+	 * @param array<string, mixed> $row     Cycle row (`order_id`, `reason`).
+	 * @param array<string, mixed> $changes Changes: optional `order_id`, `reason`.
+	 */
+	private static function has_values( array $row, array $changes ): bool {
+		if ( array_key_exists( 'order_id', $changes ) ) {
+			$intended = null === $changes['order_id'] ? null : Coercion::coerce_int( $changes['order_id'] );
+			if ( Coercion::coerce_nullable_int( $row['order_id'] ?? null ) !== $intended ) {
+				return false;
+			}
+		}
+
+		if ( array_key_exists( 'reason', $changes ) ) {
+			$intended = null === $changes['reason'] ? null : Coercion::coerce_string( $changes['reason'] );
+			if ( Coercion::coerce_nullable_string( $row['reason'] ?? null ) !== $intended ) {
+				return false;
+			}
+		}
+
+		return true;
 	}

 	/**
@@ -1253,67 +1941,45 @@ final class ContractRepository {
 		return $cycle;
 	}

+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
 	/**
-	 * Insert a cycle row and stamp the generated id back onto the entity. Hand-built so
-	 * a claim can be stamped from `UTC_TIMESTAMP()` in the same statement; string values
-	 * are checked against their column lengths first, as `$wpdb->insert()` would.
+	 * A meta insert that selected no contract row: the contract does not exist.
 	 *
-	 * @param Cycle    $cycle         Cycle to insert. Carries its contract id and kind.
-	 * @param int|null $claim_seconds Claim length in seconds from the database's now, or null for none.
-	 * @throws DuplicateCycleException If a chain UNIQUE index rejects the row.
-	 * @throws \RuntimeException If a value is longer than its column, or the insert fails for any other reason.
+	 * @param int $contract_id Contract id.
+	 * @return never
+	 * @throws \InvalidArgumentException Always.
 	 */
-	private function insert_cycle( Cycle $cycle, ?int $claim_seconds ): void {
-		global $wpdb;
-
-		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
-		$now   = gmdate( 'Y-m-d H:i:s' );
-
-		$columns = $cycle->to_storage();
-		unset( $columns['claimed_until'] );
-		$columns['date_created_gmt'] = $now;
-		$columns['date_updated_gmt'] = $now;
+	private function throw_for_empty_meta_insert( int $contract_id ): void {
+		throw new \InvalidArgumentException( esc_html( sprintf( 'Contract %d does not exist.', $contract_id ) ) );
+	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

-		$names        = array();
-		$placeholders = array();
-		$values       = array();
-		foreach ( $columns as $column => $value ) {
-			$names[] = "`{$column}`";
-			if ( null === $value ) {
-				$placeholders[] = 'NULL';
-				continue;
-			}
-			if ( is_string( $value ) ) {
-				self::assert_fits_column( $table, $column, $value );
-			}
-			$placeholders[] = '%s';
-			$values[]       = (string) $value;
-		}
+	/**
+	 * After a failed child insert, report a lost lock instead of the database error: throws when
+	 * `$lock` is no longer held. Returns when it is held, or the check fails, so the caller
+	 * throws its own error.
+	 *
+	 * @param ContractLock $lock The caller's lock.
+	 * @throws ContractLockedException If the lock was lost.
+	 */
+	private function throw_if_lock_lost( ContractLock $lock ): void {
+		global $wpdb;

-		if ( null !== $claim_seconds ) {
-			$names[]        = '`claimed_until`';
-			$placeholders[] = 'DATE_ADD( UTC_TIMESTAMP(), INTERVAL %d SECOND )';
-			$values[]       = $claim_seconds;
+		$contract_id = $lock->get_contract_id();
+		$contracts   = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$holds_lock  = self::HOLDS_LOCK;
+		try {
+			$holder = $this->get_lock_holder( $contract_id );
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+			$held = $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$contracts} WHERE id = %d AND {$holds_lock}", $contract_id, $lock->get_state()->get_token() ) );
+		} catch ( \RuntimeException $e ) {
+			return;
 		}

-		$name_list        = implode( ', ', $names );
-		$placeholder_list = implode( ', ', $placeholders );
-
-		// Column names come from the entity's storage keys, never from input; every value is a placeholder.
-		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
-		$inserted = $wpdb->query( $wpdb->prepare( "INSERT INTO `{$table}` ( {$name_list} ) VALUES ( {$placeholder_list} )", $values ) );
-		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
-
-		if ( false === $inserted ) {
-			$db_error = $wpdb->last_error;
-			if ( false !== stripos( $db_error, 'Duplicate entry' ) ) {
-				// A chain UNIQUE index rejected the row: the position is already claimed.
-				throw new DuplicateCycleException( esc_html( 'Cycle position already exists: ' . $db_error ) );
-			}
-			throw new \RuntimeException( esc_html( 'Failed to insert cycle. ' . $db_error ) );
+		if ( '' !== $wpdb->last_error || ! $holder['exists'] || null !== $held ) {
+			return;
 		}
-
-		$cycle->set_id( (int) $wpdb->insert_id );
+		throw self::locked_exception( $contract_id, $holder ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the exception message is not output.
 	}

 	/**
@@ -1429,42 +2095,50 @@ final class ContractRepository {
 	 *
 	 * @param int $id Contract id.
 	 * @return array<string, mixed>|null
+	 * @throws \RuntimeException If the read fails.
 	 */
 	private function find_contract_row( int $id ): ?array {
 		global $wpdb;

-		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$table  = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$select = self::CONTRACT_SELECT;

 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), ARRAY_A );
+		$row = $wpdb->get_row( $wpdb->prepare( "SELECT {$select} FROM {$table} WHERE id = %d", $id ), ARRAY_A );
+
+		// A failed read must not look like a missing contract.
+		if ( '' !== $wpdb->last_error ) {
+			throw new \RuntimeException( esc_html( sprintf( 'Failed to read contract %d: %s', $id, $wpdb->last_error ) ) );
+		}

-		return null === $row ? null : $row;
+		return is_array( $row ) ? self::strip_lock_token( Coercion::coerce_string_keyed( $row ) ) : null;
 	}

 	/**
-	 * Write contract columns (plus `date_updated_gmt`) in one UPDATE statement, so the
-	 * row never lands half-written: a status and a due moment written together change
-	 * together or not at all.
+	 * Drop the lock token from a fetched contract row: it never leaves storage.
 	 *
-	 * When `next_payment_gmt` is among the columns, the same statement releases the
-	 * due-dispatch claim only if the due moment really changes: a claim belongs to the
-	 * moment it was taken on, so moving or clearing that moment frees the contract for the
-	 * next scan, while re-saving it keeps the claim and a handler cannot jump the dispatch
-	 * queue by writing the same moment back. MySQL evaluates SET assignments left to
-	 * right, so the claim is assigned first, while `next_payment_gmt` still holds the
-	 * stored value; `<=>` keeps the comparison NULL-safe.
-	 *
-	 * A hand-built statement skips the per-column length check `$wpdb->update()` makes, and
-	 * WordPress runs without strict SQL modes, so MySQL would silently shorten an over-long
-	 * string. Each string is checked against its column length first and refused like
-	 * {@see self::insert()} refuses it.
-	 *
-	 * @param int                            $id      Contract id.
-	 * @param array<string, int|string|null> $columns Contract column values keyed by column name (from {@see Contract::to_storage()}).
+	 * @param array<string, mixed> $row Contract row.
+	 * @return array<string, mixed>
+	 */
+	private static function strip_lock_token( array $row ): array {
+		unset( $row['lock_token'] );
+
+		return $row;
+	}
+
+	/**
+	 * Write contract columns (plus `date_updated_gmt`, and a `write_seq` bump so a matched row
+	 * always counts as changed) in one UPDATE that matches only while `$lock` lives. Strings are
+	 * checked against their column lengths first, since WordPress runs without strict SQL modes
+	 * and MySQL would shorten them.
+	 *
+	 * @param int                            $contract_id Contract id.
+	 * @param array<string, int|string|null> $columns     Contract column values keyed by column name (from {@see Contract::to_storage()}).
+	 * @param ContractLock                   $lock        The held lock.
 	 * @return int The number of rows the statement changed.
 	 * @throws \RuntimeException If a value is longer than its column, or the write fails.
 	 */
-	private function write_contract_row( int $id, array $columns ): int {
+	private function write_contract_row( int $contract_id, array $columns, ContractLock $lock ): int {
 		global $wpdb;

 		$table                       = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
@@ -1476,19 +2150,9 @@ final class ContractRepository {
 			}
 		}

-		$assignments = array();
+		$assignments = array( '`write_seq` = `write_seq` + 1' );
 		$values      = array();

-		if ( array_key_exists( 'next_payment_gmt', $columns ) ) {
-			$next_payment = $columns['next_payment_gmt'];
-			if ( null === $next_payment ) {
-				$assignments[] = '`claimed_until` = IF( `next_payment_gmt` <=> NULL, `claimed_until`, NULL )';
-			} else {
-				$assignments[] = '`claimed_until` = IF( `next_payment_gmt` <=> %s, `claimed_until`, NULL )';
-				$values[]      = (string) $next_payment;
-			}
-		}
-
 		foreach ( $columns as $column => $value ) {
 			if ( null === $value ) {
 				$assignments[] = "`{$column}` = NULL";
@@ -1498,12 +2162,14 @@ final class ContractRepository {
 			$values[]      = (string) $value;
 		}

-		$values[] = $id;
+		$values[] = $contract_id;
+		$values[] = $lock->get_state()->get_token();
 		$set      = implode( ', ', $assignments );
+		$fence    = self::HOLDS_LOCK;

 		// Column names come from the entity's storage keys, never from input; every value is a placeholder.
 		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
-		$updated = $wpdb->query( $wpdb->prepare( "UPDATE `{$table}` SET {$set} WHERE `id` = %d", $values ) );
+		$updated = $wpdb->query( $wpdb->prepare( "UPDATE `{$table}` SET {$set} WHERE `id` = %d AND {$fence}", $values ) );
 		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare

 		if ( false === $updated ) {
@@ -1540,101 +2206,133 @@ final class ContractRepository {
 		}
 	}

+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
 	/**
-	 * Guard against a stale id / concurrent delete before touching any table.
-	 *
-	 * The contract row UPDATE reports 0 changed rows for both "matched, nothing changed" and
-	 * "no row matched", so on its own it cannot tell the parent is gone - and a child reconcile
-	 * would then write orphan rows (no FK enforces the relation). This narrows but does
-	 * not fully close the race; the complete fix (a transaction with SELECT ... FOR UPDATE)
-	 * is the seam a later transaction-handling change wraps.
-	 *
-	 * @param int $id Contract id.
-	 * @throws \RuntimeException If the contract row no longer exists.
-	 */
-	private function assert_contract_exists( int $id ): void {
-		if ( ! $this->exists( $id ) ) {
-			throw new \RuntimeException(
-				esc_html( sprintf( 'Cannot update contract %d: the contract row no longer exists (stale id or concurrent delete).', $id ) )
-			);
-		}
-	}
-
-	/**
-	 * Reconcile a contract's items and address rows only when they differ.
-	 *
-	 * Each child set is compared via a normalized signature both the loaded rows and
-	 * the entity's arrays are projected through, so MySQL's column coercion (DECIMAL
-	 * padding, INT-as-string) applies to both sides and a no-op round-trip compares
-	 * equal. Only a changed set is rewritten (delete-then-reinsert for that one table).
-	 *
-	 * @param Contract $contract Contract whose children to reconcile. Must have an id.
-	 * @throws \RuntimeException If a child row write fails.
-	 */
-	private function sync_children( Contract $contract ): void {
-		$id = (int) $contract->get_id();
-
-		if ( $this->items_signature( $this->find_items( $id ) ) !== $this->items_signature( $contract->get_items() ) ) {
-			$this->replace_items( $id, $contract->get_items() );
-		}
-
-		if ( $this->addresses_signature( $this->find_addresses( $id ) ) !== $this->addresses_signature( $contract->get_addresses() ) ) {
-			$this->replace_addresses( $id, $contract->get_addresses() );
-		}
-	}
-
-	/**
-	 * Delete-then-reinsert a contract's item rows.
+	 * Replace a contract's item rows: insert the new rows, then delete every other row of the
+	 * contract. A failed insert removes the rows it added and keeps the old set; a failed delete
+	 * leaves old and new rows side by side, never an empty set, and the next replacement repairs it.
 	 *
 	 * @param int                              $contract_id Contract id.
 	 * @param array<int, array<string, mixed>> $items       Item rows.
-	 * @throws \RuntimeException If the delete or an insert fails.
+	 * @param ContractLock                     $lock        The held lock.
+	 * @throws \InvalidArgumentException If the lock is for another contract.
+	 * @throws ContractLockedException If the lock was lost.
+	 * @throws \RuntimeException If an insert or the delete fails.
 	 */
-	private function replace_items( int $contract_id, array $items ): void {
+	private function replace_items( int $contract_id, array $items, ContractLock $lock ): void {
 		global $wpdb;

-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-		$deleted = $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ITEMS ), array( 'contract_id' => $contract_id ) );
+		$this->fence_child_write( $lock, $contract_id );
+
+		$table   = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ITEMS );
+		$new_ids = $this->insert_items( $contract_id, $items, $lock );
+		$values  = array_merge( array( $contract_id ), $new_ids );
+		$keep    = array() === $new_ids ? '' : ' AND id NOT IN ( ' . implode( ', ', array_fill( 0, count( $new_ids ), '%d' ) ) . ' )';
+
+		// The NOT IN placeholders join dynamically, so the sniff cannot count them.
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+		$deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE contract_id = %d{$keep}", $values ) );
 		if ( false === $deleted ) {
-			throw new \RuntimeException( sprintf( 'Failed to delete item rows for contract %d: %s', (int) $contract_id, esc_html( $wpdb->last_error ) ) );
+			throw new \RuntimeException( sprintf( 'Failed to delete old item rows for contract %d: %s', (int) $contract_id, esc_html( $wpdb->last_error ) ) );
 		}
-
-		$this->insert_items( $contract_id, $items );
 	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
 	/**
-	 * Delete-then-reinsert a contract's address rows.
+	 * Replace a contract's address rows: upsert each given type in place, then delete the
+	 * types no longer given. A failure in between leaves a stale type, never a missing one.
 	 *
 	 * @param int                                 $contract_id Contract id.
 	 * @param array<string, array<string, mixed>> $addresses   Address rows keyed by type.
-	 * @throws \RuntimeException If the delete or an insert fails.
+	 * @param ContractLock                        $lock        The held lock.
+	 * @throws \InvalidArgumentException If the lock is for another contract.
+	 * @throws ContractLockedException If the lock was lost.
+	 * @throws \RuntimeException If an upsert or the delete fails.
 	 */
-	private function replace_addresses( int $contract_id, array $addresses ): void {
+	private function replace_addresses( int $contract_id, array $addresses, ContractLock $lock ): void {
 		global $wpdb;

-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-		$deleted = $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ADDRESSES ), array( 'contract_id' => $contract_id ) );
+		$this->fence_child_write( $lock, $contract_id );
+
+		$table   = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ADDRESSES );
+		$columns = array_merge( array( 'contract_id', 'address_type' ), Contract::ADDRESS_FIELDS );
+
+		$column_list = implode( ', ', $columns );
+		$updates     = implode(
+			', ',
+			array_map(
+				static function ( string $column ): string {
+					return "{$column} = VALUES({$column})";
+				},
+				Contract::ADDRESS_FIELDS
+			)
+		);
+
+		foreach ( $addresses as $type => $address ) {
+			$record       = $this->address_record( $contract_id, (string) $type, $address );
+			$placeholders = array();
+			$values       = array();
+			foreach ( $columns as $column ) {
+				$value = $record[ $column ];
+				if ( null === $value ) {
+					$placeholders[] = 'NULL';
+					continue;
+				}
+				if ( is_string( $value ) ) {
+					self::assert_fits_column( $table, $column, $value );
+				}
+				$placeholders[] = '%s';
+				$values[]       = (string) $value;
+			}
+			$placeholder_list = implode( ', ', $placeholders );
+
+			// Column names come from Contract::ADDRESS_FIELDS, never from input; every value is a placeholder.
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+			$upserted = $wpdb->query( $wpdb->prepare( "INSERT INTO {$table} ( {$column_list} ) VALUES ( {$placeholder_list} ) ON DUPLICATE KEY UPDATE {$updates}", $values ) );
+			if ( false === $upserted ) {
+				throw new \RuntimeException( sprintf( 'Failed to write %s address row for contract %d: %s', esc_html( (string) $type ), (int) $contract_id, esc_html( $wpdb->last_error ) ) );
+			}
+		}
+
+		$types  = array_map( 'strval', array_keys( $addresses ) );
+		$values = array_merge( array( $contract_id ), $types );
+		$keep   = array() === $types ? '' : ' AND address_type NOT IN ( ' . implode( ', ', array_fill( 0, count( $types ), '%s' ) ) . ' )';
+
+		// The NOT IN placeholders join dynamically, so the sniff cannot count them.
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+		$deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE contract_id = %d{$keep}", $values ) );
 		if ( false === $deleted ) {
 			throw new \RuntimeException( sprintf( 'Failed to delete address rows for contract %d: %s', (int) $contract_id, esc_html( $wpdb->last_error ) ) );
 		}
-
-		$this->insert_addresses( $contract_id, $addresses );
 	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
 	/**
-	 * Insert line items for a contract.
+	 * Insert line items for a contract under `$lock`. A failed insert best-effort deletes the rows
+	 * this call added, then throws.
 	 *
 	 * @param int                              $contract_id Contract id.
 	 * @param array<int, array<string, mixed>> $items       Item rows.
+	 * @param ContractLock                     $lock        The held lock.
+	 * @return array<int, int> The new row ids, in order.
+	 * @throws \InvalidArgumentException If the lock is for another contract.
+	 * @throws ContractLockedException If the lock was lost.
 	 * @throws \RuntimeException If an insert fails.
 	 */
-	private function insert_items( int $contract_id, array $items ): void {
+	private function insert_items( int $contract_id, array $items, ContractLock $lock ): array {
 		global $wpdb;

+		$this->fence_child_write( $lock, $contract_id );
+
+		$table   = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ITEMS );
+		$new_ids = array();
+
 		foreach ( $items as $item ) {
 			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
 			$inserted = $wpdb->insert(
-				SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ITEMS ),
+				$table,
 				array(
 					'contract_id'  => $contract_id,
 					'item_name'    => Coercion::coerce_string( $item['item_name'] ?? null ),
@@ -1649,38 +2347,67 @@ final class ContractRepository {
 			);

 			if ( false === $inserted ) {
-				throw new \RuntimeException( sprintf( 'Failed to insert item row for contract %d: %s', (int) $contract_id, esc_html( $wpdb->last_error ) ) );
+				$error = $wpdb->last_error;
+				if ( array() !== $new_ids ) {
+					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+					$wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE id IN ( " . implode( ', ', array_fill( 0, count( $new_ids ), '%d' ) ) . ' )', $new_ids ) );
+				}
+				throw new \RuntimeException( sprintf( 'Failed to insert item row for contract %d: %s', (int) $contract_id, esc_html( $error ) ) );
 			}
+
+			$new_ids[] = (int) $wpdb->insert_id;
 		}
+
+		return $new_ids;
 	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
 	/**
-	 * Insert addresses for a contract.
+	 * Insert addresses for a contract under `$lock`.
 	 *
 	 * @param int                                 $contract_id Contract id.
 	 * @param array<string, array<string, mixed>> $addresses   Address rows keyed by type.
+	 * @param ContractLock                        $lock        The held lock.
+	 * @throws \InvalidArgumentException If the lock is for another contract.
+	 * @throws ContractLockedException If the lock was lost.
 	 * @throws \RuntimeException If an insert fails.
 	 */
-	private function insert_addresses( int $contract_id, array $addresses ): void {
+	private function insert_addresses( int $contract_id, array $addresses, ContractLock $lock ): void {
 		global $wpdb;

-		foreach ( $addresses as $type => $address ) {
-			$record = array(
-				'contract_id'  => $contract_id,
-				'address_type' => (string) $type,
-			);
-
-			foreach ( Contract::ADDRESS_FIELDS as $column ) {
-				$record[ $column ] = isset( $address[ $column ] ) ? Coercion::coerce_string( $address[ $column ] ) : null;
-			}
+		$this->fence_child_write( $lock, $contract_id );

+		foreach ( $addresses as $type => $address ) {
 			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-			$inserted = $wpdb->insert( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ADDRESSES ), $record );
+			$inserted = $wpdb->insert( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ADDRESSES ), $this->address_record( $contract_id, (string) $type, $address ) );
 			if ( false === $inserted ) {
 				throw new \RuntimeException( sprintf( 'Failed to insert %s address row for contract %d: %s', esc_html( (string) $type ), (int) $contract_id, esc_html( $wpdb->last_error ) ) );
 			}
 		}
 	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
+
+	/**
+	 * An address row as stored.
+	 *
+	 * @param int                  $contract_id Contract id.
+	 * @param string               $type        Address type.
+	 * @param array<string, mixed> $address     Address fields.
+	 * @return array<string, int|string|null>
+	 */
+	private function address_record( int $contract_id, string $type, array $address ): array {
+		$record = array(
+			'contract_id'  => $contract_id,
+			'address_type' => $type,
+		);
+
+		foreach ( Contract::ADDRESS_FIELDS as $column ) {
+			$record[ $column ] = isset( $address[ $column ] ) ? Coercion::coerce_string( $address[ $column ] ) : null;
+		}
+
+		return $record;
+	}

 	/**
 	 * Load line items for a contract.
@@ -1729,6 +2456,7 @@ final class ContractRepository {
 	 * @param int    $contract_id Contract id.
 	 * @param string $key         Meta key.
 	 * @return array<int, mixed>
+	 * @throws \RuntimeException If the read fails.
 	 */
 	private function find_meta_values( int $contract_id, string $key ): array {
 		$values = array();
@@ -1745,6 +2473,7 @@ final class ContractRepository {
 	 * @param int         $contract_id Contract id.
 	 * @param string|null $key         Meta key, or null for every key.
 	 * @return array<int, array{meta_key: string, meta_value: string}>
+	 * @throws \RuntimeException If the read fails.
 	 */
 	private function find_meta_rows( int $contract_id, ?string $key ): array {
 		global $wpdb;
@@ -1761,6 +2490,11 @@ final class ContractRepository {
 			$rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, meta_value FROM {$table} WHERE contract_id = %d AND meta_key = %s ORDER BY id ASC", $contract_id, $key ), ARRAY_A );
 		}

+		// Callers decide writes from meta; a failed read must not look like an absent key.
+		if ( '' !== $wpdb->last_error ) {
+			throw new \RuntimeException( sprintf( 'Failed to read contract meta for contract %d: %s', (int) $contract_id, esc_html( $wpdb->last_error ) ) );
+		}
+
 		$result = array();
 		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
 			if ( is_array( $row ) ) {
@@ -1773,82 +2507,4 @@ final class ContractRepository {

 		return $result;
 	}
-
-	/**
-	 * A change-detection signature for an item set: each item projected to the
-	 * comparable columns, with money / count fields coerced to the fixed shape MySQL
-	 * stores (DECIMAL scale, INT-as-string) so a no-op round-trip compares equal in
-	 * {@see self::sync_children()}. The id, `contract_id`, and column order are ignored.
-	 *
-	 * @param array<int, array<string, mixed>> $items Item arrays (loaded rows or entity items).
-	 * @return array<int, array<string, string|null>> Per-item comparable projection.
-	 */
-	private function items_signature( array $items ): array {
-		$signature = array();
-
-		foreach ( $items as $item ) {
-			$signature[] = array(
-				'item_name'    => Coercion::coerce_string( $item['item_name'] ?? null ),
-				'item_type'    => Coercion::coerce_string( $item['item_type'] ?? null, 'line_item' ),
-				'product_id'   => isset( $item['product_id'] ) ? (string) Coercion::coerce_int( $item['product_id'] ) : null,
-				'variation_id' => isset( $item['variation_id'] ) ? (string) Coercion::coerce_int( $item['variation_id'] ) : null,
-				'quantity'     => number_format( Coercion::coerce_float( $item['quantity'] ?? 1 ), 4, '.', '' ),
-				'subtotal'     => number_format( Coercion::coerce_float( $item['subtotal'] ?? 0 ), 8, '.', '' ),
-				'total'        => number_format( Coercion::coerce_float( $item['total'] ?? 0 ), 8, '.', '' ),
-				'taxes'        => $this->taxes_signature( $item['taxes'] ?? null ),
-			);
-		}
-
-		return $signature;
-	}
-
-	/**
-	 * A change-detection signature for an address set, keyed by type.
-	 *
-	 * Projects each address to the persisted columns with the same string coercion
-	 * the load path returns, so an unchanged set compares equal in
-	 * {@see self::sync_children()}.
-	 *
-	 * @param array<string, array<string, mixed>> $addresses Address arrays keyed by type.
-	 * @return array<string, array<string, string|null>> Per-type comparable projection.
-	 */
-	private function addresses_signature( array $addresses ): array {
-		$signature = array();
-
-		foreach ( $addresses as $type => $address ) {
-			$record = array();
-			foreach ( Contract::ADDRESS_FIELDS as $column ) {
-				$value             = isset( $address[ $column ] ) ? Coercion::coerce_string( $address[ $column ] ) : '';
-				$record[ $column ] = '' !== $value ? $value : null;
-			}
-
-			$signature[ (string) $type ] = $record;
-		}
-
-		ksort( $signature );
-
-		return $signature;
-	}
-
-	/**
-	 * Normalize a taxes value to canonical JSON (or null), so a loaded JSON string and
-	 * the entity's decoded array compare equal regardless of which side it came from.
-	 *
-	 * @param mixed $taxes A JSON string (loaded) or an array (entity), or null.
-	 * @return string|null Canonical JSON, or null when there is nothing to compare.
-	 */
-	private function taxes_signature( $taxes ): ?string {
-		if ( null === $taxes ) {
-			return null;
-		}
-
-		$decoded = is_string( $taxes ) ? json_decode( $taxes, true ) : $taxes;
-		if ( ! is_array( $decoded ) ) {
-			return null;
-		}
-
-		$encoded = wp_json_encode( $decoded );
-
-		return false === $encoded ? null : $encoded;
-	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/DuplicateCycleException.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/DuplicateCycleException.php
index 544c6219c4d..dfea60ff431 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/DuplicateCycleException.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/DuplicateCycleException.php
@@ -2,7 +2,7 @@
 /**
  * DuplicateCycleException - a cycle insert rejected by the chain's UNIQUE indexes
  * (`(contract_id, kind, count)` / `(contract_id, kind, sequence_no)`): the position is
- * already taken. This is the expected create-as-claim race signal, distinguished from
+ * already taken. This is the expected append race signal, distinguished from
  * any other write failure so callers can treat the collision as benign without masking
  * real database errors.
  *
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
index 7b0f5d15909..06e2bf23a4b 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
@@ -58,13 +58,17 @@ final class SchemaInstaller {
 	 *         dbDelta adds the column and widens `schedule_source` in place, but does not
 	 *         drop the index or rename stored 'primitive' rows (which the due scan skips):
 	 *         pre-freeze, recreate the tables.
+	 * 2.8.0 - contract lock: contracts gain `lock_token`, `locked_until_gmt`, `lock_operation`,
+	 *         a `write_seq` counter bumped by every row write, and a binary-collated
+	 *         `creation_key` with a `creation (extension_slug, creation_key)` UNIQUE index;
+	 *         contracts and cycles drop `claimed_until`. Pre-freeze, recreate the tables.
 	 *
 	 * Pre-freeze, tables are recreated rather than migrated. dbDelta adds columns but
 	 * does not change an existing column's nullability or drop unused ones, so a dev box
 	 * on an earlier schema must drop and recreate the tables (and clear VERSION_OPTION)
 	 * to pick up such changes - in-place ALTERs and backfills arrive with the freeze.
 	 */
-	private const VERSION = '2.7.0';
+	private const VERSION = '2.8.0';

 	/**
 	 * Option key tracking the installed schema version.
@@ -128,7 +132,7 @@ final class SchemaInstaller {
 		$collate = $wpdb->get_charset_collate();
 		$names   = self::get_table_names( $wpdb->prefix );

-		foreach ( self::get_table_definitions( $names, $collate ) as $sql ) {
+		foreach ( self::get_table_definitions( $names, $collate, $wpdb->charset ) as $sql ) {
 			dbDelta( $sql );
 		}

@@ -203,9 +207,10 @@ final class SchemaInstaller {
 	 *
 	 * @param array<string, string> $names   Map of logical => prefixed table names.
 	 * @param string                $collate Charset/collate clause from $wpdb.
+	 * @param string                $charset Table charset from $wpdb, or '' for the server default.
 	 * @return array<int, string>
 	 */
-	private static function get_table_definitions( array $names, string $collate ): array {
+	private static function get_table_definitions( array $names, string $collate, string $charset ): array {
 		$plans              = $names[ self::TABLE_PLANS ];
 		$plan_meta          = $names[ self::TABLE_PLAN_META ];
 		$contracts          = $names[ self::TABLE_CONTRACTS ];
@@ -257,7 +262,15 @@ final class SchemaInstaller {
 		// recurring; today the store base currency). `schedule_source` records
 		// who keeps the payment schedule: the engine ('subscriptions_engine', the only
 		// source the due scan dispatches) or a gateway or extension that keeps its own.
-		$contracts_sql = "CREATE TABLE {$contracts} (
+		// `lock_token` / `locked_until_gmt` / `lock_operation` hold the per-contract lock (live
+		// while `locked_until_gmt` is ahead of the DB clock). `write_seq` is bumped by every row
+		// write, so a write that matches the row always changes it (zero rows = not matched).
+		// `creation` makes an extension's optional `creation_key` unique (NULL keys never
+		// collide); 64 + 100 characters stay within the 767-byte key limit of older InnoDB
+		// row formats. The key is opaque, so it compares binary: keys differing only in case
+		// or accents are distinct. The collation ignores trailing spaces, so the entity trims keys.
+		$creation_key_collate = '' === $charset ? '' : " COLLATE {$charset}_bin";
+		$contracts_sql        = "CREATE TABLE {$contracts} (
   id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
   status VARCHAR(20) NOT NULL,
   customer_id BIGINT UNSIGNED NULL,
@@ -270,7 +283,11 @@ final class SchemaInstaller {
   payment_token_id BIGINT UNSIGNED NULL,
   start_gmt DATETIME NULL,
   next_payment_gmt DATETIME NULL,
-  claimed_until DATETIME NULL,
+  lock_token CHAR(32) NULL,
+  locked_until_gmt DATETIME NULL,
+  lock_operation VARCHAR(64) NULL,
+  write_seq BIGINT UNSIGNED NOT NULL DEFAULT 0,
+  creation_key VARCHAR(100){$creation_key_collate} NULL,
   plan_snapshot_id BIGINT UNSIGNED NULL,
   items_snapshot_id BIGINT UNSIGNED NULL,
   billing_total DECIMAL(26,8) NOT NULL DEFAULT 0,
@@ -288,7 +305,8 @@ final class SchemaInstaller {
   KEY customer_status (customer_id, status),
   KEY due (next_payment_gmt, status),
   KEY due_owner (extension_slug, next_payment_gmt),
-  KEY origin_order (origin_order_id)
+  KEY origin_order (origin_order_id),
+  UNIQUE KEY creation (extension_slug, creation_key)
 ) {$collate};";

 		$contract_items_sql = "CREATE TABLE {$contract_items} (
@@ -341,10 +359,7 @@ final class SchemaInstaller {
 		// per-charge idempotency anchor, with `count` nullable so non-counting cycles
 		// (e.g. future trial periods) coexist freely under MySQL's NULL-distinct rule.
 		// `order_id` is non-1:1 (an aggregate order may serve many cycles); `contract_kind`
-		// serves targeted per-chain reads (MAX(count), head). `claimed_until` is the cycle
-		// claim: stamped when a caller appends, claims or transitions a cycle with a claim,
-		// claimable again once it passes; the chain's UNIQUE positions remain the primary
-		// guard against a duplicate cycle.
+		// serves targeted per-chain reads (MAX(count), head).
 		// `retry_at` is reserved (additive) for a later retry/dunning pass - not wired yet.
 		$cycles_sql = "CREATE TABLE {$cycles} (
   id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -362,7 +377,6 @@ final class SchemaInstaller {
   items_snapshot_id BIGINT UNSIGNED NULL,
   order_id BIGINT UNSIGNED NULL,
   extension_slug VARCHAR(64) NULL,
-  claimed_until DATETIME NULL,
   retry_at DATETIME NULL,
   date_created_gmt DATETIME NOT NULL,
   date_updated_gmt DATETIME NOT NULL,
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php
index 615d0adfd0e..22b17374ad5 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php
@@ -132,22 +132,6 @@ final class ArgumentValidator {
 		return $value;
 	}

-	/**
-	 * Validate and return a positive integer (a digit string is cast).
-	 *
-	 * @param string $key   Field name.
-	 * @param mixed  $value Caller value.
-	 * @throws InvalidArgumentException If the value is not a positive integer.
-	 */
-	public static function validate_positive_int( string $key, $value ): int {
-		$value = self::cast_digit_string( $value );
-		if ( ! is_int( $value ) || $value <= 0 ) {
-			throw new InvalidArgumentException( sprintf( '"%s" must be a positive integer.', esc_html( $key ) ) );
-		}
-
-		return $value;
-	}
-
 	/**
 	 * Validate and return a non-negative integer (a digit string is cast).
 	 *
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractActionsTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractActionsTest.php
index 7087fede5c7..6dd3c5a4aa5 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractActionsTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractActionsTest.php
@@ -214,7 +214,7 @@ class ContractActionsTest extends EngineIntegrationTestCase {
 	 * An active contract owned by the test extension.
 	 */
 	private function create_contract(): ContractView {
-		return Contracts::create(
+		return $this->create_released(
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
 				'status'         => 'active',
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
index b8e80a3098f..835bf86db46 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
@@ -15,6 +15,7 @@ use DomainException;
 use EngineIntegrationTestCase;
 use InvalidArgumentException;
 use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLockedException;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\View\CycleView;
@@ -33,9 +34,25 @@ class ContractsTest extends EngineIntegrationTestCase {

 	private const EXTENSION_SLUG = 'acme-subs';

+	/**
+	 * A second connection whose committed rows outlive the test transaction, closed after its
+	 * rollback (a duplicate-key insert left shared locks on those rows until then).
+	 *
+	 * @var array{wpdb: \wpdb, statements: array<int, string>}|null
+	 */
+	private $committed_cleanup = null;
+
 	public function tear_down(): void {
 		StatusRegistry::reset();
 		parent::tear_down();
+
+		if ( null !== $this->committed_cleanup ) {
+			foreach ( $this->committed_cleanup['statements'] as $statement ) {
+				$this->committed_cleanup['wpdb']->query( $statement ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepared when queued.
+			}
+			$this->committed_cleanup['wpdb']->close();
+			$this->committed_cleanup = null;
+		}
 	}

 	/**
@@ -63,7 +80,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	}

 	public function test_an_extension_slug_only_create_is_an_empty_draft(): void {
-		$created = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) );
+		$created = $this->create_released( array( 'extension_slug' => self::EXTENSION_SLUG ) );
 		$id      = $created->get_id();

 		$view = $this->view( $id );
@@ -81,7 +98,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	}

 	public function test_create_stores_and_returns_every_field(): void {
-		$created = Contracts::create(
+		$created = $this->create_released(
 			array(
 				'extension_slug'       => self::EXTENSION_SLUG,
 				'status'               => ContractStatus::ACTIVE,
@@ -183,7 +200,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 			'shipping' => array_merge( $billing, array( 'city' => 'Porto' ) ),
 		);

-		$created = Contracts::create(
+		$created = $this->create_released(
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
 				'items'          => $items,
@@ -204,7 +221,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	}

 	public function test_datetime_objects_are_stored_as_utc_strings(): void {
-		$id = Contracts::create(
+		$id = $this->create_released(
 			array(
 				'extension_slug'   => self::EXTENSION_SLUG,
 				'start_gmt'        => new DateTimeImmutable( '2026-01-01 02:00:00', new DateTimeZone( 'Europe/Lisbon' ) ),
@@ -229,7 +246,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 			2
 		);

-		$id = Contracts::create(
+		$id = $this->create_released(
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
 				'custmer_id'     => 1,
@@ -244,7 +261,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	public function test_unknown_item_and_address_keys_are_ignored_with_a_notice(): void {
 		$this->setExpectedIncorrectUsage( Contracts::class );

-		$id = Contracts::create(
+		$id = $this->create_released(
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
 				'items'          => array(
@@ -280,7 +297,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	public function test_a_missing_or_empty_extension_slug_is_rejected( array $args ): void {
 		$this->expectException( InvalidArgumentException::class );

-		Contracts::create( $args );
+		$this->create_released( $args );
 	}

 	/**
@@ -296,7 +313,7 @@ class ContractsTest extends EngineIntegrationTestCase {

 	public function test_an_entity_invariant_failure_is_reported_as_invalid_input(): void {
 		try {
-			Contracts::create(
+			$this->create_released(
 				array(
 					'extension_slug' => self::EXTENSION_SLUG,
 					'status'         => 'nonsense',
@@ -318,7 +335,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 		$before = Contracts::count();

 		try {
-			Contracts::create( array_merge( array( 'extension_slug' => self::EXTENSION_SLUG ), $fields ) );
+			$this->create_released( array_merge( array( 'extension_slug' => self::EXTENSION_SLUG ), $fields ) );
 			$this->fail( 'Expected an InvalidArgumentException.' );
 		} catch ( InvalidArgumentException $e ) {
 			$this->assertSame( $before, Contracts::count(), 'Nothing is written.' );
@@ -360,7 +377,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	 * @param array<string, mixed> $fields Invalid fields.
 	 */
 	public function test_a_rejected_update_writes_none_of_its_keys( array $fields ): void {
-		$id = Contracts::create(
+		$id = $this->create_released(
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
 				'currency'       => 'USD',
@@ -399,7 +416,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	}

 	public function test_money_without_currency_is_rejected_on_update(): void {
-		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		$id = $this->create_released( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();

 		$this->expectException( InvalidArgumentException::class );

@@ -407,14 +424,14 @@ class ContractsTest extends EngineIntegrationTestCase {
 	}

 	public function test_a_null_money_value_resets_to_zero_without_a_currency(): void {
-		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		$id = $this->create_released( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();

 		$this->assertInstanceOf( ContractView::class, Contracts::update( $id, array( 'billing_total' => null ) ) );
 		$this->assertSame( '0.00000000', $this->view( $id )->get_billing_total() );
 	}

 	public function test_the_currency_cannot_be_cleared_while_a_total_is_non_zero(): void {
-		$id = Contracts::create(
+		$id = $this->create_released(
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
 				'currency'       => 'USD',
@@ -422,6 +439,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 			)
 		)->get_id();

+		Contracts::update( $id, array( 'billing_total' => '10' ) );
 		try {
 			Contracts::update( $id, array( 'currency' => null ) );
 			$this->fail( 'Expected an InvalidArgumentException.' );
@@ -447,7 +465,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 		$customer = self::factory()->user->create();
 		$this->assertIsInt( $customer );

-		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		$id = $this->create_released( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();

 		$this->assertInstanceOf( ContractView::class, Contracts::update( $id, array( 'customer_id' => $customer ) ) );
 		$this->assertInstanceOf(
@@ -473,7 +491,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	}

 	public function test_update_keeps_unmentioned_payment_fields(): void {
-		$id = Contracts::create(
+		$id = $this->create_released(
 			array(
 				'extension_slug'       => self::EXTENSION_SLUG,
 				'payment_method'       => 'dummy',
@@ -491,7 +509,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	}

 	public function test_update_with_no_fields_returns_the_view(): void {
-		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		$id = $this->create_released( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();

 		$updated = Contracts::update( $id, array() );

@@ -502,7 +520,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	public function test_update_returns_null_when_the_contract_is_deleted_before_the_write(): void {
 		global $wpdb;

-		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		$id = $this->create_released( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();

 		// A concurrent delete lands after the facade read the contract and before its write.
 		$table    = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
@@ -531,7 +549,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	public function test_update_does_not_revert_a_concurrent_write_to_other_fields(): void {
 		global $wpdb;

-		$id = Contracts::create(
+		$id = $this->create_released(
 			array(
 				'extension_slug'   => self::EXTENSION_SLUG,
 				'status'           => ContractStatus::ACTIVE,
@@ -578,7 +596,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	}

 	public function test_items_and_addresses_are_replaced_as_a_whole(): void {
-		$id = Contracts::create(
+		$id = $this->create_released(
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
 				'items'          => array( array( 'item_name' => 'Coffee' ), array( 'item_name' => 'Tea' ) ),
@@ -634,7 +652,7 @@ class ContractsTest extends EngineIntegrationTestCase {

 		$table = SchemaInstaller::get_table_name( $child_table );
 		$break = static function ( string $query ) use ( $table ): string {
-			return 0 === strpos( $query, "INSERT INTO `{$table}`" ) ? 'SELECT broken syntax (' : $query;
+			return 1 === preg_match( '/^INSERT INTO `?' . preg_quote( $table, '/' ) . '`?\s/', $query ) ? 'SELECT broken syntax (' : $query;
 		};
 		add_filter( 'query', $break );
 		$suppressed = $wpdb->suppress_errors( true );
@@ -643,7 +661,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 			$write();
 			$this->fail( 'Expected the write to throw instead of returning a view.' );
 		} catch ( \RuntimeException $e ) {
-			$this->assertStringContainsString( 'Failed to insert', $e->getMessage() );
+			$this->assertStringContainsString( 'Failed to', $e->getMessage() );
 		} finally {
 			$wpdb->suppress_errors( $suppressed );
 			remove_filter( 'query', $break );
@@ -664,8 +682,17 @@ class ContractsTest extends EngineIntegrationTestCase {
 		);
 	}

+	/**
+	 * @testdox A failed item insert on update throws and keeps the previous items.
+	 */
 	public function test_update_throws_when_an_item_insert_fails(): void {
-		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		$view = $this->create_released(
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'items'          => array( array( 'item_name' => 'Coffee' ) ),
+			)
+		);
+		$id   = $view->get_id();

 		$this->assert_write_throws_when_child_inserts_fail(
 			SchemaInstaller::TABLE_CONTRACT_ITEMS,
@@ -673,10 +700,22 @@ class ContractsTest extends EngineIntegrationTestCase {
 				return Contracts::update( $id, array( 'items' => array( array( 'item_name' => 'Cocoa' ) ) ) );
 			}
 		);
+
+		$this->assertSame( array( 'Coffee' ), array_column( (array) $this->view( $id )->get_items(), 'item_name' ) );
+		$this->assertNull( $this->get_lock_holder( $id )['operation'], 'The internal lock is released.' );
 	}

+	/**
+	 * @testdox A failed address write on update throws and keeps the previous address.
+	 */
 	public function test_update_throws_when_an_address_insert_fails(): void {
-		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		$view = $this->create_released(
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'addresses'      => array( 'billing' => array( 'city' => 'Lisbon' ) ),
+			)
+		);
+		$id   = $view->get_id();

 		$this->assert_write_throws_when_child_inserts_fail(
 			SchemaInstaller::TABLE_CONTRACT_ADDRESSES,
@@ -684,10 +723,13 @@ class ContractsTest extends EngineIntegrationTestCase {
 				return Contracts::update( $id, array( 'addresses' => array( 'billing' => array( 'city' => 'Faro' ) ) ) );
 			}
 		);
+
+		$addresses = (array) $this->view( $id )->get_addresses();
+		$this->assertSame( 'Lisbon', $addresses['billing']['city'] ?? null );
 	}

 	public function test_null_clears_nullable_fields(): void {
-		$id = Contracts::create(
+		$id = $this->create_released(
 			array(
 				'extension_slug'   => self::EXTENSION_SLUG,
 				'selling_plan_id'  => 3,
@@ -713,7 +755,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	}

 	public function test_an_unknown_update_key_is_ignored_with_a_notice(): void {
-		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		$id = $this->create_released( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
 		$this->setExpectedIncorrectUsage( Contracts::class . '::update' );
 		$this->setExpectedIncorrectUsage( Contracts::class );

@@ -741,7 +783,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	}

 	public function test_extension_slug_is_not_an_update_key(): void {
-		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		$id = $this->create_released( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
 		$this->setExpectedIncorrectUsage( Contracts::class . '::update' );

 		Contracts::update(
@@ -769,7 +811,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 			2
 		);

-		$id = Contracts::create(
+		$id = $this->create_released(
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
 				'plan_snapshot'  => array( 'selling_plan_id' => 3 ),
@@ -788,7 +830,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	public function test_a_registered_extension_status_is_accepted(): void {
 		StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );

-		$id = Contracts::create(
+		$id = $this->create_released(
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
 				'status'         => 'paused-by-merchant',
@@ -802,7 +844,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	 * Create a contract carrying a currency, ready for cycles.
 	 */
 	private function contract_with_currency(): int {
-		return Contracts::create(
+		return $this->create_released(
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
 				'currency'       => 'USD',
@@ -895,7 +937,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 		$id = $this->contract_with_currency();
 		$this->add_cycle( $id, $this->cycle_args() );

-		$this->assertNull( Contracts::add_cycle( $id, $this->cycle_args( array( 'sequence_no' => 1 ) ) ), 'A lost append returns null.' );
+		$this->assertNull( $this->add_cycle_locked( $id, $this->cycle_args( array( 'sequence_no' => 1 ) ) ), 'A lost append returns null.' );
 		$this->assertCount( 1, Contracts::get_cycles( $id ), 'Nothing is written.' );
 	}

@@ -904,7 +946,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 		$kind = str_repeat( 'k', 21 );

 		try {
-			Contracts::add_cycle( $id, $this->cycle_args( array( 'kind' => $kind ) ) );
+			$this->add_cycle_locked( $id, $this->cycle_args( array( 'kind' => $kind ) ) );
 			$this->fail( 'Expected add_cycle() to refuse a kind longer than its column.' );
 		} catch ( \RuntimeException $e ) {
 			$this->assertStringContainsString( 'longer than the column allows', $e->getMessage() );
@@ -921,41 +963,96 @@ class ContractsTest extends EngineIntegrationTestCase {
 		$this->assertSame( 2, $view->get_sequence_no() );
 	}

-	public function test_a_taken_count_returns_null(): void {
+	/**
+	 * @testdox A failed read of the next position throws and appends nothing.
+	 */
+	public function test_a_failed_position_read_throws_and_appends_nothing(): void {
 		$id = $this->contract_with_currency();
-		$this->add_cycle( $id, $this->cycle_args( array( 'count' => 1 ) ) );
+		$this->add_cycle( $id, $this->cycle_args() );

-		$this->assertNull( Contracts::add_cycle( $id, $this->cycle_args( array( 'count' => 1 ) ) ), 'A lost append returns null.' );
-		$this->assertCount( 1, Contracts::get_cycles( $id ), 'Nothing is written.' );
+		$this->fail_next_query( '/^\s*SELECT COALESCE\( MAX\( sequence_no \)/i' );
+		try {
+			$this->add_cycle_locked( $id, $this->cycle_args() );
+			$this->fail( 'Expected the failed read to throw.' );
+		} catch ( \RuntimeException $e ) {
+			$this->assertNotInstanceOf( ContractLockedException::class, $e, 'A failed read, not a lock conflict.' );
+		}
+		$this->assertCount( 1, Contracts::get_cycles( $id ), 'Nothing is appended.' );
 	}

-	public function test_an_explicit_position_is_kept(): void {
-		$id = $this->contract_with_currency();
+	/**
+	 * @testdox A taken position, count or creation key is a lost race by its error number, even when the caller's transaction snapshot cannot see the row that took it.
+	 */
+	public function test_a_taken_slot_is_a_lost_race_inside_a_caller_transaction(): void {
+		$cycles    = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+		$contracts = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$id        = $this->contract_with_currency();
+		$this->add_cycle( $id, $this->cycle_args( array( 'count' => 1 ) ) );

-		$cycle_id = $this->add_cycle(
-			$id,
-			$this->cycle_args(
+		// The test runs in a transaction whose snapshot is already open; a second connection
+		// commits rows that snapshot cannot see, as a concurrent writer would.
+		$credentials = array();
+		foreach ( array( 'DB_USER', 'DB_PASSWORD', 'DB_NAME', 'DB_HOST' ) as $name ) {
+			$value = constant( $name );
+			$this->assertIsString( $value );
+			$credentials[] = $value;
+		}
+		$other = new \wpdb( ...$credentials );
+		$slug  = self::EXTENSION_SLUG;
+
+		// Fixed test values only: the contract id is an int and the rest are literals.
+		$this->committed_cleanup = array(
+			'wpdb'       => $other,
+			'statements' => array(
+				sprintf( 'DELETE FROM `%s` WHERE contract_id = %d AND sequence_no = 2', $cycles, $id ),
+				sprintf( "DELETE FROM `%s` WHERE extension_slug = '%s' AND creation_key = 'order:12'", $contracts, $slug ),
+			),
+		);
+
+		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
+		// Rows an aborted earlier run committed would make these inserts fail; tear_down() removes them otherwise.
+		foreach ( $this->committed_cleanup['statements'] as $statement ) {
+			$other->query( $statement );
+		}
+		$other->query( sprintf( "INSERT INTO `%s` ( contract_id, kind, sequence_no, count, status, starts_at_gmt, ends_at_gmt, currency, date_created_gmt, date_updated_gmt ) VALUES ( %d, 'billing', 2, 2, 'billed', '2026-02-01 00:00:00', '2026-03-01 00:00:00', 'USD', UTC_TIMESTAMP(), UTC_TIMESTAMP() )", $cycles, $id ) );
+		$other->query( sprintf( "INSERT INTO `%s` ( status, extension_slug, creation_key, date_created_gmt, date_updated_gmt ) VALUES ( 'draft', '%s', 'order:12', UTC_TIMESTAMP(), UTC_TIMESTAMP() )", $contracts, $slug ) );
+		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
+		$this->assertSame( '', $other->last_error );
+
+		$this->assertNull( $this->add_cycle_locked( $id, $this->cycle_args() ), 'The next position, read from the old snapshot, is taken.' );
+		$this->assertNull(
+			$this->add_cycle_locked(
+				$id,
+				$this->cycle_args(
+					array(
+						'sequence_no' => 3,
+						'count'       => 2,
+					)
+				)
+			),
+			'The count is taken.'
+		);
+		$this->assertNull(
+			Contracts::create(
 				array(
-					'sequence_no' => 5,
-					'count'       => 3,
+					'extension_slug' => self::EXTENSION_SLUG,
+					'creation_key'   => 'order:12',
 				)
-			)
-		)->get_id();
-		$cycle    = $this->cycle( $id, $cycle_id );
-
-		$this->assertSame( 5, $cycle->get_sequence_no() );
-		$this->assertSame( 3, $cycle->get_count() );
+			),
+			'The creation key is taken.'
+		);
 	}

-	public function test_another_kind_starts_its_own_chain(): void {
+	/**
+	 * @testdox A failed insert that is not a duplicate key surfaces as a database error, even when its slot is taken.
+	 */
+	public function test_a_failure_other_than_a_duplicate_key_is_a_database_error(): void {
 		$id = $this->contract_with_currency();
 		$this->add_cycle( $id, $this->cycle_args() );
-		$this->add_cycle( $id, $this->cycle_args() );
-
-		$view = $this->add_cycle( $id, $this->cycle_args( array( 'kind' => 'shipping' ) ) );

-		$this->assertSame( 'shipping', $view->get_kind() );
-		$this->assertSame( 1, $view->get_sequence_no() );
+		$this->fail_next_query( '/^\s*INSERT INTO `\S*subscription_cycles`/' );
+		$this->expectException( \RuntimeException::class );
+		$this->add_cycle_locked( $id, $this->cycle_args( array( 'sequence_no' => 1 ) ) );
 	}

 	/**
@@ -1114,194 +1211,526 @@ class ContractsTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox A claimed append holds a claim: claim_cycle fails until the claim expires.
+	 * @testdox transition_cycle settles a pending cycle billed with its order, and a lost compare-and-set returns false.
 	 */
-	public function test_a_claimed_append_holds_a_claim(): void {
+	public function test_transition_cycle_settles_and_loses_a_stale_read(): void {
 		$id    = $this->contract_with_currency();
-		$cycle = $this->add_cycle(
-			$id,
-			$this->cycle_args(
-				array(
-					'status'        => CycleStatus::PENDING,
-					'claim_seconds' => 900,
-				)
-			)
-		);
+		$cycle = $this->add_cycle( $id, $this->cycle_args( array( 'status' => CycleStatus::PENDING ) ) );

-		$claimed_until = $this->cycle( $id, $cycle->get_id() )->get_claimed_until_gmt();
-		$this->assertNotNull( $claimed_until );
-		$this->assertGreaterThan( time() + 800, strtotime( $claimed_until . ' UTC' ) );
-		$this->assertFalse( Contracts::claim_cycle( $cycle->get_id(), CycleStatus::PENDING, 900 ), 'The appender holds the cycle.' );
+		$this->assertTrue( $this->transition_cycle_locked( $cycle->get_id(), $this->transition( CycleStatus::PENDING, CycleStatus::BILLED, array( 'order_id' => 77 ) ) ) );
+		$this->assertFalse( $this->transition_cycle_locked( $cycle->get_id(), $this->transition( CycleStatus::PENDING, CycleStatus::FAILED ) ), 'A caller that read pending loses.' );

-		$this->expire_claim( $cycle->get_id() );
-		$this->assertTrue( Contracts::claim_cycle( $cycle->get_id(), CycleStatus::PENDING, 900 ), 'An expired claim can be taken.' );
-		$this->assertFalse( Contracts::claim_cycle( $cycle->get_id(), CycleStatus::PENDING, 900 ), 'Only once.' );
+		$stored = $this->cycle( $id, $cycle->get_id() );
+		$this->assertSame( CycleStatus::BILLED, $stored->get_status()->get_value() );
+		$this->assertSame( 77, $stored->get_order_id() );
 	}

 	/**
-	 * @testdox A claimed append stamps its claim on the database clock, like claim_cycle.
+	 * @testdox transition_cycle attaches an order, fails with a reason, keeps an omitted order and clears a null reason.
 	 */
-	public function test_a_claimed_append_stamps_the_claim_on_the_database_clock(): void {
-		$id = $this->contract_with_currency();
+	public function test_transition_cycle_order_and_reason(): void {
+		$id       = $this->contract_with_currency();
+		$cycle_id = $this->add_cycle( $id, $this->cycle_args( array( 'status' => CycleStatus::PENDING ) ) )->get_id();

-		// Pin the session's database clock far from PHP's; UTC_TIMESTAMP() follows it.
-		$this->pin_db_clock( '2035-01-01 00:00:00' );
-		$cycle = $this->add_cycle(
-			$id,
-			$this->cycle_args(
-				array(
-					'status'        => CycleStatus::PENDING,
-					'claim_seconds' => 900,
-				)
-			)
-		);
+		$this->assertTrue( $this->transition_cycle_locked( $cycle_id, $this->transition( CycleStatus::PENDING, CycleStatus::PENDING, array( 'order_id' => 77 ) ) ) );
+		$this->assertSame( 77, $this->cycle( $id, $cycle_id )->get_order_id() );
+
+		$this->assertTrue( $this->transition_cycle_locked( $cycle_id, $this->transition( CycleStatus::PENDING, CycleStatus::FAILED, array( 'reason' => 'payment-failed' ) ) ) );
+		$failed = Contracts::get_cycles( $id, 1 )[0];
+		$this->assertSame( CycleStatus::FAILED, $failed->get_status() );
+		$this->assertSame( 'payment-failed', $failed->get_reason() );
+		$this->assertSame( 77, $failed->get_order_id(), 'An omitted order_id keeps the stored one.' );

-		$this->assertSame( '2035-01-01 00:15:00', $this->cycle( $id, $cycle->get_id() )->get_claimed_until_gmt() );
+		$this->assertTrue( $this->transition_cycle_locked( $cycle_id, $this->transition( CycleStatus::FAILED, CycleStatus::BILLED, array( 'reason' => null ) ) ) );
+		$this->assertNull( Contracts::get_cycles( $id, 1 )[0]->get_reason(), 'A null reason clears it.' );
 	}

 	/**
-	 * @testdox An invalid claim_seconds is rejected without a write.
+	 * @testdox transition_cycle rejects an unregistered target status and ignores unknown keys with a notice.
 	 */
-	public function test_an_invalid_claim_is_rejected(): void {
-		$id = $this->contract_with_currency();
+	public function test_transition_cycle_rejects_bad_input(): void {
+		$id    = $this->contract_with_currency();
+		$cycle = $this->add_cycle( $id, $this->cycle_args( array( 'status' => CycleStatus::PENDING ) ) );

 		try {
-			$this->add_cycle( $id, $this->cycle_args( array( 'claim_seconds' => 0 ) ) );
+			$this->transition_cycle_locked( $cycle->get_id(), $this->transition( CycleStatus::PENDING, 'nonsense' ) );
 			$this->fail( 'Expected an InvalidArgumentException.' );
 		} catch ( InvalidArgumentException $e ) {
-			$this->assertSame( array(), Contracts::get_cycles( $id ) );
+			$this->assertSame( CycleStatus::PENDING, Contracts::get_cycles( $id, 1 )[0]->get_status() );
 		}

-		$this->expectException( InvalidArgumentException::class );
-		Contracts::claim_cycle( 1, CycleStatus::PENDING, 0 );
+		$this->setExpectedIncorrectUsage( Contracts::class . '::transition_cycle' );
+		$this->assertTrue( $this->transition_cycle_locked( $cycle->get_id(), $this->transition( CycleStatus::PENDING, CycleStatus::BILLED, array( 'status' => 'x' ) ) ) );
 	}

 	/**
-	 * @testdox claim_cycle wins once on an unclaimed cycle in the status the caller read.
+	 * @testdox transition_cycle rejects an invalid argument value and leaves the cycle unchanged.
+	 * @dataProvider provide_invalid_transition_args
+	 *
+	 * @param array<string, mixed> $args Transition args.
 	 */
-	public function test_claim_cycle_on_unclaimed_pending_and_billed_cycles(): void {
-		$id      = $this->contract_with_currency();
-		$billed  = $this->add_cycle( $id, $this->cycle_args() );
-		$pending = $this->add_cycle( $id, $this->cycle_args( array( 'status' => CycleStatus::PENDING ) ) );
+	public function test_transition_cycle_rejects_invalid_argument_values( array $args ): void {
+		$id    = $this->contract_with_currency();
+		$cycle = $this->add_cycle( $id, $this->cycle_args( array( 'status' => CycleStatus::PENDING ) ) );

-		$this->assertTrue( Contracts::claim_cycle( $pending->get_id(), CycleStatus::PENDING, 900 ) );
-		$this->assertFalse( Contracts::claim_cycle( $pending->get_id(), CycleStatus::PENDING, 900 ) );
-		$this->assertFalse( Contracts::claim_cycle( $billed->get_id(), CycleStatus::PENDING, 900 ), 'A stale read loses.' );
-		$this->assertTrue( Contracts::claim_cycle( $billed->get_id(), CycleStatus::BILLED, 900 ), 'The caller names the status it read.' );
+		try {
+			$this->transition_cycle_locked( $cycle->get_id(), $args );
+			$this->fail( 'Expected an InvalidArgumentException.' );
+		} catch ( InvalidArgumentException $e ) {
+			$stored = $this->cycle( $id, $cycle->get_id() );
+			$this->assertSame( CycleStatus::PENDING, $stored->get_status()->get_value() );
+			$this->assertNull( $stored->get_order_id() );
+			$this->assertNull( $stored->get_reason() );
+		}
 	}

 	/**
-	 * @testdox transition_cycle settles a pending cycle billed with its order, and a lost compare-and-set returns false.
+	 * @return array<string, array{0: array<string, mixed>}>
 	 */
-	public function test_transition_cycle_settles_and_loses_a_stale_read(): void {
-		$id    = $this->contract_with_currency();
-		$cycle = $this->add_cycle( $id, $this->cycle_args( array( 'status' => CycleStatus::PENDING ) ) );
+	public function provide_invalid_transition_args(): array {
+		$valid = array(
+			'from_status' => CycleStatus::PENDING,
+			'to_status'   => CycleStatus::BILLED,
+		);

-		$this->assertTrue( Contracts::transition_cycle( $cycle->get_id(), CycleStatus::PENDING, CycleStatus::BILLED, array( 'order_id' => 77 ) ) );
-		$this->assertFalse( Contracts::transition_cycle( $cycle->get_id(), CycleStatus::PENDING, CycleStatus::FAILED ), 'A caller that read pending loses.' );
+		return array(
+			'missing from_status'    => array( array( 'to_status' => CycleStatus::BILLED ) ),
+			'missing to_status'      => array( array( 'from_status' => CycleStatus::PENDING ) ),
+			'empty from_status'      => array( array_merge( $valid, array( 'from_status' => '' ) ) ),
+			'non-string from_status' => array( array_merge( $valid, array( 'from_status' => 1 ) ) ),
+			'non-string to_status'   => array( array_merge( $valid, array( 'to_status' => array( 'billed' ) ) ) ),
+			'zero order id'          => array( array_merge( $valid, array( 'order_id' => 0 ) ) ),
+			'negative order id'      => array( array_merge( $valid, array( 'order_id' => -5 ) ) ),
+			'non-string reason'      => array( array_merge( $valid, array( 'reason' => array( 'payment-failed' ) ) ) ),
+			'numeric reason'         => array( array_merge( $valid, array( 'reason' => 42 ) ) ),
+		);
+	}

-		$stored = $this->cycle( $id, $cycle->get_id() );
-		$this->assertSame( CycleStatus::BILLED, $stored->get_status()->get_value() );
-		$this->assertSame( 77, $stored->get_order_id() );
+	/**
+	 * Transition args.
+	 *
+	 * @param string               $from  The status read.
+	 * @param string               $to    The status to write.
+	 * @param array<string, mixed> $extra Further args.
+	 * @return array<string, mixed>
+	 */
+	private function transition( string $from, string $to, array $extra = array() ): array {
+		return array_merge(
+			array(
+				'from_status' => $from,
+				'to_status'   => $to,
+			),
+			$extra
+		);
 	}

 	/**
-	 * @testdox transition_cycle attaches an order under a claim, fails with a reason, and retries with a new claim.
+	 * @testdox lock() returns null while another operation holds the lock and for an unknown contract; bad labels are invalid.
 	 */
-	public function test_transition_cycle_order_reason_and_claim(): void {
-		$id       = $this->contract_with_currency();
-		$cycle    = $this->add_cycle(
-			$id,
-			$this->cycle_args(
+	public function test_lock_lost_race_and_validation(): void {
+		$id = $this->contract_with_currency();
+		$this->pin_db_clock( '2035-01-01 00:00:00' );
+		$lock = $this->take_lock( $id, 'hold' );
+		$this->assertSame( $id, $lock->get_contract_id() );
+
+		$this->assertNull( Contracts::lock( $id, 'cancel' ), 'A held lock is a lost race.' );
+		$this->assertNull( Contracts::lock( 999999, 'hold' ), 'An unknown contract has no lock to take.' );
+
+		$lock_state = $this->view( $id )->get_lock_state();
+		$this->assertNotNull( $lock_state );
+		$this->assertSame( 'hold', $lock_state['operation'] );
+		$this->assertSame( '2035-01-01 00:10:00', $lock_state['locked_until']->format( 'Y-m-d H:i:s' ) );
+
+		foreach ( array( '', str_repeat( 'x', 65 ) ) as $label ) {
+			try {
+				Contracts::lock( $id, $label );
+				$this->fail( 'Expected an InvalidArgumentException.' );
+			} catch ( InvalidArgumentException $e ) {
+				$this->assertStringNotContainsString( 'lock_token', $e->getMessage() );
+			}
+		}
+	}
+
+	/**
+	 * @testdox create() returns the new contract and its lock, taken for "create" by default; writes with it land, others are refused until release.
+	 */
+	public function test_create_is_locked_from_birth(): void {
+		$default = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) );
+		$this->assertNotNull( $default );
+		$this->assertSame( 'create', $default['lock']->get_operation() );
+		$lock_state = $this->view( $default['contract']->get_id() )->get_lock_state();
+		$this->assertNotNull( $lock_state );
+		$this->assertSame( 'create', $lock_state['operation'] );
+		try {
+			Contracts::update( $default['contract']->get_id(), array( 'currency' => 'USD' ) );
+			$this->fail( 'Expected an unlocked write to be refused.' );
+		} catch ( ContractLockedException $e ) {
+			$this->assertSame( 'create', $e->get_operation() );
+		}
+		$default['lock']->release();
+
+		$created = Contracts::create(
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'currency'       => 'USD',
+				'creation_key'   => 'order:1',
+			),
+			array( 'operation' => 'checkout' )
+		);
+		$this->assertNotNull( $created );
+		$lock = $created['lock'];
+		$id   = $created['contract']->get_id();
+
+		$this->assertSame( $id, $lock->get_contract_id() );
+		$this->assertSame( 'USD', $created['contract']->get_currency() );
+		$this->assertSame( ContractStatus::DRAFT, $this->view( $id )->get_status() );
+		$this->assertInstanceOf( CycleView::class, Contracts::add_cycle( $id, $this->cycle_args(), $lock ) );
+		$this->assertInstanceOf( ContractView::class, Contracts::update( $id, array( 'status' => ContractStatus::ACTIVE ), $lock ) );
+
+		try {
+			Contracts::update( $id, array( 'status' => ContractStatus::ON_HOLD ) );
+			$this->fail( 'Expected an unlocked write to be refused.' );
+		} catch ( ContractLockedException $e ) {
+			$this->assertSame( 'checkout', $e->get_operation() );
+		}
+
+		$lock->release();
+		$this->assertInstanceOf( ContractView::class, Contracts::update( $id, array( 'status' => ContractStatus::ON_HOLD ) ) );
+	}
+
+	/**
+	 * @testdox A duplicate creation key is a lost race: create() returns null and writes nothing; an invalid key throws.
+	 */
+	public function test_a_duplicate_creation_key_returns_null(): void {
+		global $wpdb;
+
+		$args    = array(
+			'extension_slug' => self::EXTENSION_SLUG,
+			'creation_key'   => 'order:9',
+		);
+		$created = Contracts::create( $args );
+		$this->assertNotNull( $created );
+
+		$this->assertNull( Contracts::create( $args ) );
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$this->assertSame( '1', $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE creation_key = %s", 'order:9' ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+		$this->expectException( InvalidArgumentException::class );
+		Contracts::create(
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'creation_key'   => str_repeat( 'k', 101 ),
+			)
+		);
+	}
+
+	/**
+	 * @testdox A creation key differing only in surrounding whitespace is the same key.
+	 */
+	public function test_a_whitespace_padded_creation_key_is_the_same_key(): void {
+		$this->assertNotNull(
+			Contracts::create(
 				array(
-					'status'        => CycleStatus::PENDING,
-					'claim_seconds' => 900,
+					'extension_slug' => self::EXTENSION_SLUG,
+					'creation_key'   => 'order:1',
 				)
 			)
 		);
-		$cycle_id = $cycle->get_id();

-		$this->assertTrue(
-			Contracts::transition_cycle(
-				$cycle_id,
-				CycleStatus::PENDING,
-				CycleStatus::PENDING,
+		$this->assertNull(
+			Contracts::create(
+				array(
+					'extension_slug' => self::EXTENSION_SLUG,
+					'creation_key'   => 'order:1 ',
+				)
+			)
+		);
+		$this->assertNull(
+			Contracts::create(
 				array(
-					'order_id'      => 77,
-					'claim_seconds' => 900,
+					'extension_slug' => self::EXTENSION_SLUG,
+					'creation_key'   => ' order:1',
 				)
 			)
 		);
-		$attached = $this->cycle( $id, $cycle_id );
-		$this->assertSame( 77, $attached->get_order_id() );
-		$this->assertNotNull( $attached->get_claimed_until_gmt(), 'The claim is kept while attaching the order.' );
+	}

-		$this->assertTrue( Contracts::transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::FAILED, array( 'reason' => 'payment-failed' ) ) );
-		$failed = Contracts::get_cycles( $id, 1 )[0];
-		$this->assertSame( CycleStatus::FAILED, $failed->get_status() );
-		$this->assertSame( 'payment-failed', $failed->get_reason() );
-		$this->assertSame( 77, $failed->get_order_id(), 'An omitted order_id keeps the stored one.' );
-		$this->assertNull( $this->cycle( $id, $cycle_id )->get_claimed_until_gmt(), 'No claim_seconds clears the claim.' );
+	/**
+	 * @testdox Without a creation key every create makes a new contract.
+	 */
+	public function test_keyless_creates_never_collide(): void {
+		$args   = array( 'extension_slug' => self::EXTENSION_SLUG );
+		$first  = Contracts::create( $args );
+		$second = Contracts::create( $args );

-		$this->assertTrue( Contracts::transition_cycle( $cycle_id, CycleStatus::FAILED, CycleStatus::PENDING, array( 'claim_seconds' => 900 ) ) );
-		$this->assertFalse( Contracts::claim_cycle( $cycle_id, CycleStatus::PENDING, 900 ), 'The retrier holds the claim.' );
+		$this->assertNotNull( $first );
+		$this->assertNotNull( $second );
+		$this->assertNotSame( $first['contract']->get_id(), $second['contract']->get_id() );
+	}

-		$this->assertTrue( Contracts::transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::BILLED, array( 'reason' => null ) ) );
-		$this->assertNull( Contracts::get_cycles( $id, 1 )[0]->get_reason(), 'A null reason clears it.' );
+	/**
+	 * @testdox An unknown create option is noticed and ignored; an invalid operation label throws.
+	 */
+	public function test_create_options_are_validated(): void {
+		$this->setExpectedIncorrectUsage( Contracts::class . '::create' );
+		$created = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ), array( 'skip_lock' => true ) );
+		$this->assertNotNull( $created );
+		$this->assertSame( 'create', $created['lock']->get_operation() );
+
+		foreach ( array( 'Checkout', 5 ) as $operation ) {
+			try {
+				Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ), array( 'operation' => $operation ) );
+				$this->fail( 'Expected an invalid operation label to throw.' );
+			} catch ( InvalidArgumentException $e ) {
+				$this->assertStringContainsString( 'operation', $e->getMessage() );
+			}
+		}
 	}

 	/**
-	 * @testdox transition_cycle rejects an unregistered target status and ignores unknown keys with a notice.
+	 * @testdox A duplicate creation key prints no database error and restores the error suppression setting.
 	 */
-	public function test_transition_cycle_rejects_bad_input(): void {
-		$id    = $this->contract_with_currency();
-		$cycle = $this->add_cycle( $id, $this->cycle_args( array( 'status' => CycleStatus::PENDING ) ) );
+	public function test_a_duplicate_creation_key_is_quiet(): void {
+		global $wpdb;

+		$args = array(
+			'extension_slug' => self::EXTENSION_SLUG,
+			'creation_key'   => 'order:10',
+		);
+		$this->assertNotNull( Contracts::create( $args ) );
+
+		$show_errors = $wpdb->show_errors( true );
+		$suppressed  = $wpdb->suppress_errors( false );
+		ob_start();
 		try {
-			Contracts::transition_cycle( $cycle->get_id(), CycleStatus::PENDING, 'nonsense' );
-			$this->fail( 'Expected an InvalidArgumentException.' );
-		} catch ( InvalidArgumentException $e ) {
-			$this->assertSame( CycleStatus::PENDING, Contracts::get_cycles( $id, 1 )[0]->get_status() );
+			$this->assertNull( Contracts::create( $args ) );
+		} finally {
+			$output            = (string) ob_get_clean();
+			$still_suppressing = $wpdb->suppress_errors( $suppressed );
+			$wpdb->show_errors( $show_errors );
 		}

-		$this->setExpectedIncorrectUsage( Contracts::class . '::transition_cycle' );
-		$this->assertTrue( Contracts::transition_cycle( $cycle->get_id(), CycleStatus::PENDING, CycleStatus::BILLED, array( 'status' => 'x' ) ) );
+		$this->assertSame( '', $output, 'A duplicate creation key must not print a database error.' );
+		$this->assertFalse( $still_suppressing, 'The previous error suppression setting must be restored.' );
 	}

 	/**
-	 * @testdox transition_cycle rejects an invalid argument value and leaves the cycle unchanged.
-	 * @dataProvider provide_invalid_transition_args
-	 *
-	 * @param array<string, mixed> $args Transition args.
+	 * @testdox An unlocked update with items while another lock lives throws, writes nothing and leaves no internal lock.
 	 */
-	public function test_transition_cycle_rejects_invalid_argument_values( array $args ): void {
-		$id    = $this->contract_with_currency();
-		$cycle = $this->add_cycle( $id, $this->cycle_args( array( 'status' => CycleStatus::PENDING ) ) );
+	public function test_an_unlocked_items_update_conflicts_cleanly(): void {
+		$view = $this->create_released(
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'items'          => array( array( 'item_name' => 'Coffee' ) ),
+			)
+		);
+		$id   = $view->get_id();
+		$lock = $this->take_lock( $id, 'renewal' );

 		try {
-			Contracts::transition_cycle( $cycle->get_id(), CycleStatus::PENDING, CycleStatus::BILLED, $args );
-			$this->fail( 'Expected an InvalidArgumentException.' );
+			Contracts::update( $id, array( 'items' => array( array( 'item_name' => 'Cocoa' ) ) ) );
+			$this->fail( 'Expected a conflict.' );
+		} catch ( ContractLockedException $e ) {
+			$this->assertSame( 'renewal', $e->get_operation() );
+		}
+		$this->assertSame( array( 'Coffee' ), array_column( (array) $this->view( $id )->get_items(), 'item_name' ) );
+
+		$lock->release();
+		Contracts::update( $id, array( 'items' => array( array( 'item_name' => 'Cocoa' ) ) ) );
+		$this->assertSame( array( 'Cocoa' ), array_column( (array) $this->view( $id )->get_items(), 'item_name' ) );
+		$this->assertSame( $id, $this->take_lock( $id, 'next' )->get_contract_id(), 'The internal lock was released.' );
+	}
+
+	/**
+	 * @testdox An unlocked update refused by validation releases the internal lock.
+	 */
+	public function test_a_refused_unlocked_update_releases_the_internal_lock(): void {
+		$id = $this->contract_with_currency();
+		Contracts::update( $id, array( 'billing_total' => '10' ) );
+
+		try {
+			Contracts::update( $id, array( 'currency' => null ) );
+			$this->fail( 'Expected totals without a currency to be refused.' );
 		} catch ( InvalidArgumentException $e ) {
-			$stored = $this->cycle( $id, $cycle->get_id() );
-			$this->assertSame( CycleStatus::PENDING, $stored->get_status()->get_value() );
-			$this->assertNull( $stored->get_order_id() );
-			$this->assertNull( $stored->get_reason() );
+			$this->assertNull( $this->view( $id )->get_lock_state(), 'The internal lock is released.' );
 		}
+		$this->assertSame( 'USD', $this->view( $id )->get_currency() );
 	}

 	/**
-	 * @return array<string, array{0: array<string, mixed>}>
+	 * @testdox An unlocked update takes the internal lock before it reads the contract and releases it after the write; it is refused while another operation holds the lock and reads as missing for an unknown contract.
 	 */
-	public function provide_invalid_transition_args(): array {
-		return array(
-			'zero claim'        => array( array( 'claim_seconds' => 0 ) ),
-			'negative claim'    => array( array( 'claim_seconds' => -1 ) ),
-			'non-numeric claim' => array( array( 'claim_seconds' => 'x' ) ),
-			'zero order id'     => array( array( 'order_id' => 0 ) ),
-			'negative order id' => array( array( 'order_id' => -5 ) ),
-			'non-string reason' => array( array( 'reason' => array( 'payment-failed' ) ) ),
-			'numeric reason'    => array( array( 'reason' => 42 ) ),
+	public function test_an_unlocked_row_update_uses_the_internal_lock(): void {
+		$id = $this->contract_with_currency();
+
+		$statements = array();
+		$record     = static function ( $query ) use ( &$statements ) {
+			$statements[] = (string) $query;
+			return $query;
+		};
+		add_filter( 'query', $record );
+		try {
+			$this->assertInstanceOf( ContractView::class, Contracts::update( $id, array( 'status' => ContractStatus::ON_HOLD ) ) );
+		} finally {
+			remove_filter( 'query', $record );
+		}
+		$acquires = array_keys( (array) preg_grep( "/SET lock_token = '[0-9a-f]{32}'/", $statements ) );
+		$reads    = array_keys( (array) preg_grep( '/^\s*SELECT .* FROM \S*subscription_contracts\b/', $statements ) );
+		$writes   = array_keys( (array) preg_grep( '/SET `write_seq` = `write_seq` \+ 1/', $statements ) );
+		$releases = array_keys( (array) preg_grep( '/SET lock_token = NULL/', $statements ) );
+		$this->assertCount( 1, $acquires, 'The internal lock is taken once.' );
+		$this->assertCount( 1, $releases, 'It is released once.' );
+		$this->assertNotSame( array(), $reads );
+		$this->assertCount( 1, $writes );
+		$this->assertLessThan( $reads[0], $acquires[0], 'The lock is taken before the contract is read.' );
+		$this->assertGreaterThan( $writes[0], $releases[0], 'It is released after the write.' );
+		$this->assertNull( $this->view( $id )->get_lock_state(), 'The internal lock is released.' );
+
+		$this->take_lock( $id, 'renewal' );
+		try {
+			Contracts::update( $id, array( 'status' => ContractStatus::ACTIVE ) );
+			$this->fail( 'Expected a conflict.' );
+		} catch ( ContractLockedException $e ) {
+			$this->assertSame( 'renewal', $e->get_operation() );
+		}
+		$this->assertSame( ContractStatus::ON_HOLD, $this->view( $id )->get_status() );
+
+		$this->assertNull( Contracts::update( 999999, array( 'status' => ContractStatus::ACTIVE ) ) );
+	}
+
+	/**
+	 * @testdox An unlocked items update refused by a live lock reads the holder once to report it.
+	 */
+	public function test_a_refused_internal_lock_reads_the_holder_once(): void {
+		$id = $this->contract_with_currency();
+		$this->take_lock( $id, 'renewal' );
+
+		$statements = array();
+		$record     = static function ( $query ) use ( &$statements ) {
+			$statements[] = $query;
+			return $query;
+		};
+		add_filter( 'query', $record );
+		try {
+			Contracts::update( $id, array( 'items' => array( array( 'item_name' => 'Cocoa' ) ) ) );
+			$this->fail( 'Expected a conflict.' );
+		} catch ( ContractLockedException $e ) {
+			$this->assertSame( 'renewal', $e->get_operation() );
+		} finally {
+			remove_filter( 'query', $record );
+		}
+
+		$holder_reads = preg_grep( '/AS live, lock_operation/', $statements );
+		$this->assertIsArray( $holder_reads );
+		$this->assertCount( 1, $holder_reads, 'The holder is read once.' );
+		$this->assertSame( array(), preg_grep( '/^\s*(INSERT|DELETE)\b|SET lock_token = NULL/i', $statements ), 'Nothing is written or released.' );
+	}
+
+	/**
+	 * @testdox Every write method refuses a lock for another contract.
+	 */
+	public function test_every_write_refuses_another_contracts_lock(): void {
+		$id       = $this->contract_with_currency();
+		$cycle_id = $this->add_cycle( $id, $this->cycle_args( array( 'status' => CycleStatus::PENDING ) ) )->get_id();
+		$lock     = $this->take_lock( $this->contract_with_currency(), 'hold' );
+
+		$writes = array(
+			'update'           => static function () use ( $id, $lock ): void {
+				Contracts::update( $id, array( 'status' => ContractStatus::ON_HOLD ), $lock );
+			},
+			'add_cycle'        => function () use ( $id, $lock ): void {
+				Contracts::add_cycle( $id, $this->cycle_args(), $lock );
+			},
+			'transition_cycle' => function () use ( $cycle_id, $lock ): void {
+				Contracts::transition_cycle( $cycle_id, $this->transition( CycleStatus::PENDING, CycleStatus::BILLED ), $lock );
+			},
+			'add_meta'         => static function () use ( $id, $lock ): void {
+				Contracts::add_meta( $id, 'k', 'v', false, $lock );
+			},
+			'update_meta'      => static function () use ( $id, $lock ): void {
+				Contracts::update_meta( $id, 'k', 'v', null, $lock );
+			},
+			'delete_meta'      => static function () use ( $id, $lock ): void {
+				Contracts::delete_meta( $id, 'k', null, $lock );
+			},
+		);
+
+		foreach ( $writes as $method => $write ) {
+			// A cycle is addressed by its id, so its refusal names the cycle and the lock's contract.
+			$ids = 'transition_cycle' === $method ? array( $cycle_id, $lock->get_contract_id() ) : array( $lock->get_contract_id(), $id );
+			try {
+				$write();
+				$this->fail( "Expected {$method}() to refuse the lock." );
+			} catch ( InvalidArgumentException $e ) {
+				foreach ( $ids as $named ) {
+					$this->assertStringContainsString( (string) $named, $e->getMessage(), "{$method}() names both ids." );
+				}
+			}
+		}
+		$this->assertSame( CycleStatus::PENDING, $this->cycle( $id, $cycle_id )->get_status()->get_value() );
+		$this->assertSame( array(), Contracts::get_meta( $id ) );
+	}
+
+	/**
+	 * @testdox A failed child insert is reported as a lock conflict when the caller's lock was lost.
+	 */
+	public function test_a_failed_child_insert_under_a_lock_conflict_is_a_lock_conflict(): void {
+		$meta   = '/^\s*INSERT INTO \S*contract_meta .* FROM \S*subscription_contracts c WHERE c\.id = \d+/s';
+		$cycles = '/^\s*INSERT INTO `?\S*subscription_cycles`? .* VALUES /s';
+
+		$id = $this->contract_with_currency();
+		$this->pin_db_clock( '2035-01-01 00:00:00' );
+		$lock = $this->take_lock( $id, 'hold' );
+		$this->pin_db_clock( '2035-01-01 00:10:00' );
+		$this->take_lock( $id, 'cancel' );
+
+		$writes = array(
+			'add_meta'        => static function () use ( $id, $lock ): void {
+				Contracts::add_meta( $id, 'k', 'v', false, $lock );
+			},
+			'add_meta unique' => static function () use ( $id, $lock ): void {
+				Contracts::add_meta( $id, 'k', 'v', true, $lock );
+			},
+			'add_cycle'       => function () use ( $id, $lock ): void {
+				Contracts::add_cycle( $id, $this->cycle_args(), $lock );
+			},
 		);
+		foreach ( $writes as $name => $write ) {
+			$this->fail_next_query( 'add_cycle' === $name ? $cycles : $meta );
+			try {
+				$write();
+				$this->fail( "Expected {$name} to report the lost lock." );
+			} catch ( ContractLockedException $e ) {
+				$this->assertSame( 'cancel', $e->get_operation(), "{$name} names the new holder." );
+			}
+		}
+	}
+
+	/**
+	 * @testdox A failed child insert with no lock conflict still surfaces the database error.
+	 */
+	public function test_a_failed_child_insert_without_a_lock_conflict_is_a_database_error(): void {
+		$id = $this->contract_with_currency();
+
+		$this->fail_next_query( '/^\s*INSERT INTO \S*contract_meta .* FROM \S*subscription_contracts c WHERE c\.id = \d+/s' );
+		try {
+			Contracts::add_meta( $id, 'k', 'v', true );
+			$this->fail( 'Expected the failed insert to throw.' );
+		} catch ( ContractLockedException $e ) {
+			$this->fail( 'The internal lock is still held, so the failure is not a lock conflict.' );
+		} catch ( \RuntimeException $e ) {
+			$this->assertStringContainsString( 'Failed to add contract meta', $e->getMessage() );
+		}
+
+		$this->fail_next_query( '/^\s*INSERT INTO `?\S*subscription_cycles`? .* VALUES /s' );
+		try {
+			$this->add_cycle_locked( $id, $this->cycle_args() );
+			$this->fail( 'Expected the failed insert to throw.' );
+		} catch ( ContractLockedException $e ) {
+			$this->fail( 'The caller still holds its lock, so the failure is not a lock conflict.' );
+		} catch ( \RuntimeException $e ) {
+			$this->assertStringContainsString( 'Failed to insert cycle', $e->getMessage() );
+		}
 	}

 	/**
@@ -1357,26 +1786,14 @@ class ContractsTest extends EngineIntegrationTestCase {
 	 * @param array<string, mixed> $args        Cycle fields.
 	 */
 	private function add_cycle( int $contract_id, array $args ): CycleView {
-		$view = Contracts::add_cycle( $contract_id, $args );
+		$view = $this->add_cycle_locked( $contract_id, $args );
 		$this->assertInstanceOf( CycleView::class, $view );

 		return $view;
 	}

-	/**
-	 * Move a cycle's claim into the past.
-	 *
-	 * @param int $cycle_id Cycle id.
-	 */
-	private function expire_claim( int $cycle_id ): void {
-		global $wpdb;
-
-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-		$wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES ), array( 'claimed_until' => gmdate( 'Y-m-d H:i:s', time() - 60 ) ), array( 'id' => $cycle_id ) );
-	}
-
 	public function test_meta_keeps_several_values_under_one_key(): void {
-		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		$id = $this->create_released( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();

 		$this->assertIsInt( Contracts::add_meta( $id, 'note', 'one' ) );
 		$this->assertIsInt( Contracts::add_meta( $id, 'note', array( 'two' ) ) );
@@ -1387,7 +1804,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	}

 	public function test_a_unique_meta_add_refuses_an_existing_key(): void {
-		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		$id = $this->create_released( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
 		Contracts::add_meta( $id, 'note', 'one' );

 		$this->assertNull( Contracts::add_meta( $id, 'note', 'two', true ) );
@@ -1395,7 +1812,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	}

 	public function test_update_meta_with_a_previous_value_and_delete_one_value(): void {
-		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		$id = $this->create_released( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
 		Contracts::add_meta( $id, 'note', 'one' );
 		Contracts::add_meta( $id, 'note', 'two' );

@@ -1407,7 +1824,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	}

 	public function test_only_null_matches_any_meta_value(): void {
-		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		$id = $this->create_released( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
 		Contracts::add_meta( $id, 'note', 'one' );
 		Contracts::add_meta( $id, 'note', '' );

@@ -1428,7 +1845,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	}

 	public function test_deleting_a_contract_removes_its_meta(): void {
-		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		$id = $this->create_released( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
 		Contracts::add_meta( $id, 'note', 'one' );

 		$this->assertTrue( ( new ContractRepository() )->delete( $id ) );
@@ -1437,7 +1854,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	}

 	public function test_an_empty_meta_key_is_rejected(): void {
-		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		$id = $this->create_released( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();

 		$this->expectException( InvalidArgumentException::class );

@@ -1445,7 +1862,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 	}

 	public function test_meta_survives_a_contract_update(): void {
-		$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+		$id = $this->create_released( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
 		Contracts::add_meta( $id, 'note', 'kept' );

 		Contracts::update(
@@ -1506,7 +1923,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 			)
 		);

-		return ( new ContractRepository() )->insert( $contract );
+		return $this->insert_released( $contract );
 	}

 	/**
@@ -1529,7 +1946,7 @@ class ContractsTest extends EngineIntegrationTestCase {
 			)
 		);

-		return ( new ContractRepository() )->insert( $contract );
+		return $this->insert_released( $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 42b1aec01cd..9f12cc4f407 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
@@ -58,7 +58,7 @@ class ContractsControllerTest extends EngineIntegrationTestCase {
 	 * A store manager reads the stored contract facts, children included.
 	 */
 	public function test_get_returns_the_contract_to_a_store_manager(): void {
-		$contract = Contracts::create(
+		$contract = $this->create_released(
 			array(
 				'extension_slug'   => 'test-extension',
 				'status'           => 'active',
@@ -108,7 +108,7 @@ class ContractsControllerTest extends EngineIntegrationTestCase {
 	 * A contract without addresses encodes them as an empty JSON object.
 	 */
 	public function test_get_encodes_no_addresses_as_an_object(): void {
-		$contract = Contracts::create(
+		$contract = $this->create_released(
 			array(
 				'extension_slug' => 'test-extension',
 				'status'         => 'active',
@@ -645,7 +645,7 @@ class ContractsControllerTest extends EngineIntegrationTestCase {
 	 * @param string   $extension_slug Owning extension.
 	 */
 	private function create_contract( ?int $customer_id, string $extension_slug = self::EXTENSION_SLUG ): ContractView {
-		return Contracts::create(
+		return $this->create_released(
 			array(
 				'extension_slug' => $extension_slug,
 				'status'         => 'active',
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
index 342321263bf..d15707aa4ea 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
@@ -10,12 +10,20 @@

 declare( strict_types=1 );

+use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLock;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\Plans;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\CycleView;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractLockState;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;

 /**
  * Engine integration test case.
@@ -27,12 +35,23 @@ abstract class EngineIntegrationTestCase extends WP_UnitTestCase {
 	 */
 	protected const PLAN_OWNER = 'engine-tests';

+	/**
+	 * The `$wpdb` error suppression to restore after {@see self::fail_next_query()}.
+	 *
+	 * @var bool|null
+	 */
+	private $suppressed_errors = null;
+
 	/**
 	 * Return the session's database clock to the server clock after each test.
 	 */
 	public function tear_down(): void {
 		global $wpdb;

+		if ( null !== $this->suppressed_errors ) {
+			$wpdb->suppress_errors( $this->suppressed_errors );
+			$this->suppressed_errors = null;
+		}
 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
 		$wpdb->query( 'SET timestamp = DEFAULT' );
 		parent::tear_down();
@@ -40,7 +59,7 @@ abstract class EngineIntegrationTestCase extends WP_UnitTestCase {

 	/**
 	 * Pin the session's database clock: `UTC_TIMESTAMP()` returns `$utc` until the next pin
-	 * or the end of the test. Dispatch claims and cycle claims are measured on this clock.
+	 * or the end of the test. Contract lock expiry is measured on this clock.
 	 *
 	 * @param string $utc Moment (UTC, `Y-m-d H:i:s`).
 	 */
@@ -53,6 +72,272 @@ abstract class EngineIntegrationTestCase extends WP_UnitTestCase {
 		$wpdb->query( $wpdb->prepare( 'SET timestamp = %d', $timestamp ) );
 	}

+	/**
+	 * Take a contract's lock through the facade, failing the test when the race is lost.
+	 *
+	 * @param int    $contract_id Contract id.
+	 * @param string $operation   Operation label.
+	 */
+	protected function take_lock( int $contract_id, string $operation ): ContractLock {
+		$lock = Contracts::lock( $contract_id, $operation );
+		$this->assertNotNull( $lock, 'Expected to take the lock.' );
+
+		return $lock;
+	}
+
+	/**
+	 * A lock's private state, read through reflection (tests only).
+	 *
+	 * @param ContractLock $lock The lock.
+	 */
+	protected function lock_state( ContractLock $lock ): ContractLockState {
+		$property = new ReflectionProperty( ContractLock::class, 'state' );
+		$property->setAccessible( true );
+		$state = $property->getValue( $lock );
+		$this->assertInstanceOf( ContractLockState::class, $state );
+
+		return $state;
+	}
+
+	/**
+	 * End a lock's trust window without waiting (the trust clock is monotonic real time).
+	 *
+	 * @param ContractLock $lock The lock.
+	 */
+	protected function age_lock_trust( ContractLock $lock ): void {
+		$property = new ReflectionProperty( ContractLockState::class, 'trusted_until' );
+		$property->setAccessible( true );
+		$property->setValue( $this->lock_state( $lock ), 0.0 );
+	}
+
+	/**
+	 * Create a contract through the facade and release its creation lock, for fixtures that
+	 * write to it without a lock.
+	 *
+	 * @param array<string, mixed> $args `Contracts::create()` fields.
+	 */
+	protected function create_released( array $args ): ContractView {
+		$created = Contracts::create( $args );
+		$this->assertIsArray( $created );
+		$created['lock']->release();
+
+		return $created['contract'];
+	}
+
+	/**
+	 * Insert a contract through the repository and release its creation lock.
+	 *
+	 * @param Contract $contract Contract to insert.
+	 * @return int The new contract id.
+	 */
+	protected function insert_released( Contract $contract ): int {
+		$lock = ( new ContractRepository() )->insert( $contract, 'create' );
+		$this->assertInstanceOf( ContractLock::class, $lock );
+		$lock->release();
+
+		return $lock->get_contract_id();
+	}
+
+	/**
+	 * `Contracts::add_cycle()` under a lock taken for the call and released after it.
+	 *
+	 * @param int                  $contract_id Contract id.
+	 * @param array<string, mixed> $args        Cycle fields.
+	 */
+	protected function add_cycle_locked( int $contract_id, array $args ): ?CycleView {
+		$lock = $this->take_lock( $contract_id, 'test' );
+		try {
+			return Contracts::add_cycle( $contract_id, $args, $lock );
+		} finally {
+			$lock->release();
+		}
+	}
+
+	/**
+	 * `Contracts::transition_cycle()` under a lock on the cycle's contract, taken for the call and released after it.
+	 *
+	 * @param int                  $cycle_id Cycle id.
+	 * @param array<string, mixed> $args     Transition fields.
+	 */
+	protected function transition_cycle_locked( int $cycle_id, array $args ): bool {
+		$lock = $this->take_lock( $this->get_cycle_contract_id( $cycle_id ), 'test' );
+		try {
+			return Contracts::transition_cycle( $cycle_id, $args, $lock );
+		} finally {
+			$lock->release();
+		}
+	}
+
+	/**
+	 * `ContractRepository::append_cycle()` under a lock taken for the call and released after it.
+	 *
+	 * @param Cycle $cycle Cycle to append.
+	 */
+	protected function append_cycle_locked( Cycle $cycle ): void {
+		$lock = $this->take_lock( $cycle->get_contract_id(), 'test' );
+		try {
+			( new ContractRepository() )->append_cycle( $cycle, $lock );
+		} finally {
+			$lock->release();
+		}
+	}
+
+	/**
+	 * `ContractRepository::transition_cycle()` under a lock on the cycle's contract, taken for the call and released after it.
+	 *
+	 * @param int                  $cycle_id    Cycle id.
+	 * @param string               $from_status Status the caller read.
+	 * @param string               $to_status   Status to write.
+	 * @param array<string, mixed> $changes     `order_id` / `reason` changes.
+	 */
+	protected function transition_cycle_row_locked( int $cycle_id, string $from_status, string $to_status, array $changes = array() ): bool {
+		$lock = $this->take_lock( $this->get_cycle_contract_id( $cycle_id ), 'test' );
+		try {
+			return ( new ContractRepository() )->transition_cycle( $cycle_id, $from_status, $to_status, $changes, $lock );
+		} finally {
+			$lock->release();
+		}
+	}
+
+	/**
+	 * The contract id a stored cycle belongs to.
+	 *
+	 * @param int $cycle_id Cycle id.
+	 */
+	protected function get_cycle_contract_id( int $cycle_id ): int {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$contract_id = $wpdb->get_var( $wpdb->prepare( "SELECT contract_id FROM {$table} WHERE id = %d", $cycle_id ) );
+		$this->assertIsNumeric( $contract_id, "Cycle {$cycle_id} exists." );
+
+		return (int) $contract_id;
+	}
+
+	/**
+	 * The statements `$run` sends to the database, in order.
+	 *
+	 * @param callable $run Code under test.
+	 * @return array<int, string>
+	 */
+	protected function statements_during( callable $run ): array {
+		$statements = array();
+		$record     = static function ( $query ) use ( &$statements ) {
+			$statements[] = (string) $query;
+			return $query;
+		};
+		add_filter( 'query', $record );
+		try {
+			$run();
+		} finally {
+			remove_filter( 'query', $record );
+		}
+
+		return $statements;
+	}
+
+	/**
+	 * Assert that no statement inserts into `$table` while reading it, and that no read takes
+	 * a lock: the shapes that gap-lock the index and deadlock concurrent inserts.
+	 *
+	 * @param array<int, string> $statements Statements sent.
+	 * @param string             $table      Table name.
+	 */
+	protected function assert_no_self_table_insert_select( array $statements, string $table ): void {
+		$name = preg_quote( $table, '/' );
+		foreach ( $statements as $statement ) {
+			if ( 1 === preg_match( '/^\s*INSERT\s+INTO\s+`?' . $name . '`?\s/i', $statement ) ) {
+				$this->assertSame( 0, preg_match( '/\bFROM\s+`?' . $name . '`?[\s)]/i', $statement ), "An insert into {$table} reads the same table." );
+			}
+			$this->assertSame( 0, preg_match( '/\bFOR\s+UPDATE\b|\bLOCK\s+IN\s+SHARE\s+MODE\b/i', $statement ), 'A read takes a lock.' );
+		}
+	}
+
+	/**
+	 * Call a private method, for engine internals no public path reaches in the case a test needs.
+	 *
+	 * @param object $instance The object.
+	 * @param string $method   Method name.
+	 * @param mixed  ...$args  Arguments.
+	 * @return mixed
+	 */
+	protected function call_private( object $instance, string $method, ...$args ) {
+		$reflection = new ReflectionMethod( $instance, $method );
+		$reflection->setAccessible( true );
+
+		return $reflection->invoke( $instance, ...$args );
+	}
+
+	/**
+	 * Whether the contract exists and who holds its lock (the repository's private holder read).
+	 *
+	 * @param int $contract_id Contract id.
+	 * @return array{exists: bool, operation: ?string}
+	 */
+	protected function get_lock_holder( int $contract_id ): array {
+		$holder = $this->call_private( new ContractRepository(), 'get_lock_holder', $contract_id );
+		$this->assertIsArray( $holder );
+
+		return array(
+			'exists'    => (bool) ( $holder['exists'] ?? false ),
+			'operation' => is_string( $holder['operation'] ?? null ) ? $holder['operation'] : null,
+		);
+	}
+
+	/**
+	 * Run the repository's private child-write fence on `$lock`.
+	 *
+	 * @param ContractLock $lock The lock.
+	 */
+	protected function fence_children( ContractLock $lock ): void {
+		$this->call_private( new ContractRepository(), 'fence_children', $lock );
+	}
+
+	/**
+	 * Make the next `$times` statements starting with `$prefix` match no row, as if their fence
+	 * refused them.
+	 *
+	 * @param string $prefix Start of the statement to refuse.
+	 * @param int    $times  How many matching statements to refuse.
+	 */
+	protected function refuse_next_write( string $prefix, int $times = 1 ): void {
+		$refuse = static function ( $query ) use ( $prefix, &$times, &$refuse ) {
+			if ( is_string( $query ) && 0 === strpos( ltrim( $query ), $prefix ) ) {
+				if ( --$times < 1 ) {
+					remove_filter( 'query', $refuse );
+				}
+				return $query . ' AND 0 = 1';
+			}
+			return $query;
+		};
+		add_filter( 'query', $refuse );
+	}
+
+	/**
+	 * Replace the next statement matching `$pattern` (after `$skip` matches) with invalid SQL, so
+	 * it fails with a database error. Error output is suppressed until the test ends.
+	 *
+	 * @param string $pattern Regex the statement to break must match.
+	 * @param int    $skip    Matching statements to let through first.
+	 */
+	protected function fail_next_query( string $pattern, int $skip = 0 ): void {
+		global $wpdb;
+
+		$break = static function ( $query ) use ( $pattern, &$skip, &$break ) {
+			if ( is_string( $query ) && 1 === preg_match( $pattern, $query ) && 0 === $skip-- ) {
+				remove_filter( 'query', $break );
+				return 'SELECT * FROM table_that_does_not_exist_for_tests';
+			}
+			return $query;
+		};
+		add_filter( 'query', $break );
+
+		if ( null === $this->suppressed_errors ) {
+			$this->suppressed_errors = $wpdb->suppress_errors( true );
+		}
+	}
+
 	/**
 	 * Create a plan through the plan facade: a monthly billing payload owned by
 	 * {@see self::PLAN_OWNER} unless overridden.
@@ -197,7 +482,10 @@ abstract class EngineIntegrationTestCase extends WP_UnitTestCase {
 		$status         = $args['status'] ?? ContractStatus::ACTIVE;
 		$args['status'] = ContractStatus::DRAFT;

-		$view = Contracts::create( $args );
+		$created = Contracts::create( $args );
+		$this->assertIsArray( $created );
+		$view = $created['contract'];
+		$lock = $created['lock'];
 		$id   = $view->get_id();

 		Contracts::add_cycle(
@@ -210,9 +498,11 @@ abstract class EngineIntegrationTestCase extends WP_UnitTestCase {
 				'ends_at_gmt'    => (string) $view->get_next_payment_gmt(),
 				'expected_total' => $view->get_billing_total(),
 				'currency'       => $view->get_currency(),
-			)
+			),
+			$lock
 		);
-		Contracts::update( $id, array( 'status' => $status ) );
+		Contracts::update( $id, array( 'status' => $status ), $lock );
+		$lock->release();

 		return $id;
 	}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Dispatch/DueDispatcherTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Dispatch/DueDispatcherTest.php
index 4e94bba7533..dcf4d19dd0b 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Dispatch/DueDispatcherTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Dispatch/DueDispatcherTest.php
@@ -1,6 +1,6 @@
 <?php
 /**
- * Integration tests for the DueDispatcher (find, claim, notify).
+ * Integration tests for the DueDispatcher (find, lock, notify).
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine
  */
@@ -13,12 +13,13 @@ use DateTimeImmutable;
 use DateTimeZone;
 use EngineIntegrationTestCase;
 use RuntimeException;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLock;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLockedException;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Dispatch\DueDispatcher;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;

 /**
@@ -58,47 +59,96 @@ class DueDispatcherTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox Overlapping ticks at the same moment notify a due contract once.
+	 * @testdox A tick that overlaps a running notification skips the contract that notification holds locked.
 	 */
-	public function test_overlapping_ticks_notify_once(): void {
-		$this->register_recording_handler();
+	public function test_an_overlapping_tick_skips_a_contract_being_notified(): void {
+		$nested = null;
+		ConsumerRegistry::register(
+			self::OWNER,
+			array(
+				'contract_due_handler' => function ( ContractView $contract, array $context ) use ( &$nested ): void {
+					$this->calls[] = array(
+						'contract' => $contract,
+						'context'  => $context,
+					);
+					if ( null === $nested ) {
+						$nested = ( new DueDispatcher() )->run_batch( $this->now() );
+					}
+				},
+			)
+		);
 		$this->create_due_contract();

 		$this->assertSame( 1, $this->sut->run_batch( $this->now() ) );
-		$this->assertSame( 0, $this->sut->run_batch( $this->now() ), 'The second tick finds the contract claimed.' );
+		$this->assertSame( 0, $nested, 'The overlapping tick finds the contract locked.' );
 		$this->assertCount( 1, $this->calls );
 	}

 	/**
-	 * @testdox A contract another dispatcher already claimed is not selected by the scan.
+	 * @testdox A tick that scanned before an overlapping notification released renotifies an idle handler's contract but skips a moved one.
 	 */
-	public function test_a_contract_claimed_before_the_scan_is_not_notified(): void {
+	public function test_an_overlapping_tick_renotifies_only_a_contract_left_due(): void {
+		$first  = $this->create_due_contract( '2026-02-13 00:00:00' );
+		$idle   = $this->create_due_contract( '2026-02-14 00:00:00' );
+		$moved  = $this->create_due_contract( '2026-02-15 00:00:00' );
+		$nested = null;
+		ConsumerRegistry::register(
+			self::OWNER,
+			array(
+				'contract_due_handler' => function ( ContractView $contract, array $context ) use ( $first, $moved, &$nested ): void {
+					$this->calls[] = array(
+						'contract' => $contract,
+						'context'  => $context,
+					);
+					if ( $first === $contract->get_id() && null === $nested ) {
+						// The outer tick has scanned all three; this one notifies and releases the other two first.
+						$nested = ( new DueDispatcher() )->run_batch( $this->now() );
+					}
+					if ( $moved === $contract->get_id() ) {
+						Contracts::update( $moved, array( 'next_payment_gmt' => '2026-03-15 00:00:00' ), $context['lock'] );
+					}
+				},
+			)
+		);
+
+		$this->assertSame( 2, $this->sut->run_batch( $this->now() ), 'The outer tick notifies the first and the idle contract.' );
+		$this->assertSame( 2, $nested );
+		$this->assertSame( array( $first, $idle, $moved, $idle ), $this->notified_ids(), 'Only the contract left due is notified again.' );
+	}
+
+	/**
+	 * @testdox A contract locked before the scan is not selected.
+	 */
+	public function test_a_contract_locked_before_the_scan_is_not_notified(): void {
 		$this->register_recording_handler();
 		$id = $this->create_due_contract();

-		$this->assertTrue( ( new ContractRepository() )->claim_contract( $id, '2026-02-15 00:00:00', DueDispatcher::CLAIM_TTL_SECONDS ) );
+		$this->take_lock( $id, 'hold' );

 		$this->assertSame( 0, $this->sut->run_batch( $this->now() ) );
 		$this->assertSame( array(), $this->calls );
+		$this->assertSame( 'hold', $this->get_lock_holder( $id )['operation'], 'The holder keeps its lock.' );
 	}

 	/**
-	 * @testdox A contract whose claim is lost between the scan and the claim is skipped.
+	 * @testdox A contract whose due moment moves, or that another operation locks, after the scan is skipped.
 	 */
-	public function test_a_claim_lost_after_the_scan_is_skipped(): void {
-		$first  = $this->create_due_contract( '2026-02-14 00:00:00' );
-		$second = $this->create_due_contract( '2026-02-15 00:00:00' );
+	public function test_a_lock_lost_after_the_scan_is_skipped(): void {
+		$first  = $this->create_due_contract( '2026-02-13 00:00:00' );
+		$second = $this->create_due_contract( '2026-02-14 00:00:00' );
+		$third  = $this->create_due_contract( '2026-02-15 00:00:00' );
 		ConsumerRegistry::register(
 			self::OWNER,
 			array(
-				'contract_due_handler' => function ( ContractView $contract, array $context ) use ( $first, $second ): void {
+				'contract_due_handler' => function ( ContractView $contract, array $context ) use ( $first, $second, $third ): void {
 					$this->calls[] = array(
 						'contract' => $contract,
 						'context'  => $context,
 					);
 					if ( $first === $contract->get_id() ) {
-						// Another writer moves the second contract after the scan read it.
+						// Other writers act on the next contracts after the scan read them.
 						Contracts::update( $second, array( 'next_payment_gmt' => '2026-02-14 12:00:00' ) );
+						$this->take_lock( $third, 'cancel' );
 					}
 				},
 			)
@@ -107,31 +157,28 @@ class DueDispatcherTest extends EngineIntegrationTestCase {
 		$this->assertSame( 1, $this->sut->run_batch( $this->now() ), 'Only the first contract is notified.' );
 		$this->assertCount( 1, $this->calls );
 		$this->assertSame( $first, $this->calls[0]['contract']->get_id() );
-		$this->assertNull( $this->stored_claim( $second ), 'The lost claim wrote nothing.' );
+		$this->assertNull( $this->get_lock_holder( $second )['operation'], 'The moved contract was not locked.' );
+		$this->assertSame( 'cancel', $this->get_lock_holder( $third )['operation'], 'The other operation keeps its lock.' );
 	}

 	/**
-	 * @testdox A handler that leaves the due moment is re-notified only after the claim expires.
+	 * @testdox A handler that leaves the due moment in place is notified again on the next tick.
 	 */
-	public function test_an_idle_handler_is_renotified_after_the_claim_expires(): void {
+	public function test_an_idle_handler_is_renotified_on_the_next_tick(): void {
 		$this->register_recording_handler();
-		$this->create_due_contract();
+		$id = $this->create_due_contract();

-		$this->pin_db_clock( '2026-02-15 00:00:00' );
-		$this->sut->run_batch( $this->now() );
-		$this->pin_db_clock( '2026-02-15 00:14:59' );
 		$this->sut->run_batch( $this->now() );
-		$this->assertCount( 1, $this->calls, 'Not re-notified within the claim TTL.' );
+		$this->assertNull( $this->get_lock_holder( $id )['operation'], 'The lock is released when the handler returns.' );

-		$this->pin_db_clock( '2026-02-15 00:15:00' );
 		$this->sut->run_batch( $this->now() );
-		$this->assertCount( 2, $this->calls, 'Re-notified once the claim expired on the database clock.' );
+		$this->assertCount( 2, $this->calls );
 	}

 	/**
-	 * @testdox A handler that rewrites the due moment releases the claim and is notified on the next tick.
+	 * @testdox A handler that moves the due moment with its lock is not notified again until the new moment.
 	 */
-	public function test_moving_the_due_moment_releases_the_claim(): void {
+	public function test_moving_the_due_moment_defers_the_next_notification(): void {
 		ConsumerRegistry::register(
 			self::OWNER,
 			array(
@@ -140,16 +187,18 @@ class DueDispatcherTest extends EngineIntegrationTestCase {
 						'contract' => $contract,
 						'context'  => $context,
 					);
-					Contracts::update( $contract->get_id(), array( 'next_payment_gmt' => '2026-02-16 00:00:00' ) );
+					Contracts::update( $contract->get_id(), array( 'next_payment_gmt' => '2026-02-16 00:00:00' ), $context['lock'] );
 				},
 			)
 		);
 		$this->create_due_contract();

-		$this->sut->run_batch( $this->now()->modify( '+1 day' ) );
-		$this->sut->run_batch( $this->now()->modify( '+1 day +1 second' ) );
+		$this->sut->run_batch( $this->now() );
+		$this->sut->run_batch( $this->now() );
+		$this->assertCount( 1, $this->calls, 'Not due again before the new moment.' );

-		$this->assertCount( 2, $this->calls, 'The new due moment is notified without waiting for the claim TTL.' );
+		$this->sut->run_batch( $this->now()->modify( '+1 day' ) );
+		$this->assertCount( 2, $this->calls );
 		$this->assertEquals( new DateTimeImmutable( '2026-02-16 00:00:00', new DateTimeZone( 'UTC' ) ), $this->calls[1]['context']['due_at'] );
 	}

@@ -165,7 +214,7 @@ class DueDispatcherTest extends EngineIntegrationTestCase {
 						'contract' => $contract,
 						'context'  => $context,
 					);
-					Contracts::update( $contract->get_id(), array( 'next_payment_gmt' => null ) );
+					Contracts::update( $contract->get_id(), array( 'next_payment_gmt' => null ), $context['lock'] );
 				},
 			)
 		);
@@ -178,7 +227,58 @@ class DueDispatcherTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox A throwing handler is logged, keeps its claim and does not stop the batch.
+	 * @testdox The handler gets a live lock for its contract: writes with it land, unlocked writes are refused.
+	 */
+	public function test_the_handler_writes_with_the_dispatcher_lock(): void {
+		$refused = null;
+		ConsumerRegistry::register(
+			self::OWNER,
+			array(
+				'contract_due_handler' => function ( ContractView $contract, array $context ) use ( &$refused ): void {
+					$lock = $context['lock'];
+					$this->assertInstanceOf( ContractLock::class, $lock );
+					$this->assertSame( $contract->get_id(), $lock->get_contract_id() );
+					$this->assertSame( DueDispatcher::LOCK_OPERATION, $lock->get_operation() );
+
+					try {
+						Contracts::update( $contract->get_id(), array( 'status' => ContractStatus::CANCELLED ) );
+					} catch ( ContractLockedException $e ) {
+						$refused = $e->get_operation();
+					}
+					Contracts::update( $contract->get_id(), array( 'status' => ContractStatus::ON_HOLD ), $lock );
+				},
+			)
+		);
+		$id = $this->create_due_contract();
+
+		$this->assertSame( 1, $this->sut->run_batch( $this->now() ) );
+		$this->assertSame( DueDispatcher::LOCK_OPERATION, $refused, 'The unlocked write inside the handler is refused.' );
+		$view = Contracts::get( $id );
+		$this->assertInstanceOf( ContractView::class, $view );
+		$this->assertSame( ContractStatus::ON_HOLD, $view->get_status() );
+	}
+
+	/**
+	 * @testdox A handler that releases the lock early does not break the dispatcher's release.
+	 */
+	public function test_a_handler_may_release_early(): void {
+		ConsumerRegistry::register(
+			self::OWNER,
+			array(
+				'contract_due_handler' => function ( ContractView $contract, array $context ): void {
+					$context['lock']->release();
+					$this->take_lock( $contract->get_id(), 'follow-up' );
+				},
+			)
+		);
+		$id = $this->create_due_contract();
+
+		$this->assertSame( 1, $this->sut->run_batch( $this->now() ) );
+		$this->assertSame( 'follow-up', $this->get_lock_holder( $id )['operation'], 'The dispatcher never clears a lock it no longer holds.' );
+	}
+
+	/**
+	 * @testdox A throwing handler is logged, keeps its lock until it expires and does not stop the batch.
 	 */
 	public function test_a_throwing_handler_is_logged_and_the_batch_continues(): void {
 		$failing = $this->create_due_contract( '2026-02-14 00:00:00' );
@@ -187,17 +287,18 @@ class DueDispatcherTest extends EngineIntegrationTestCase {
 			self::OWNER,
 			array(
 				'contract_due_handler' => function ( ContractView $contract, array $context ) use ( $failing ): void {
-					if ( $failing === $contract->get_id() ) {
-						throw new RuntimeException( 'Handler failure.' );
-					}
 					$this->calls[] = array(
 						'contract' => $contract,
 						'context'  => $context,
 					);
+					if ( $failing === $contract->get_id() ) {
+						throw new RuntimeException( 'Handler failure.' );
+					}
 				},
 			)
 		);

+		$this->pin_db_clock( '2026-02-15 00:00:00' );
 		$errors = $this->capture_engine_log(
 			'error',
 			array( 'contract_id' => $failing ),
@@ -207,9 +308,64 @@ class DueDispatcherTest extends EngineIntegrationTestCase {
 		);

 		$this->assertNotSame( array(), $errors, 'The failure is logged with the contract id.' );
-		$this->assertCount( 1, $this->calls );
-		$this->assertSame( $healthy, $this->calls[0]['contract']->get_id() );
-		$this->assertNotNull( $this->stored_claim( $failing ), 'The failing contract keeps its claim (back-off).' );
+		$this->assertCount( 2, $this->calls );
+		$this->assertSame( DueDispatcher::LOCK_OPERATION, $this->get_lock_holder( $failing )['operation'], 'The failing contract keeps its lock (back-off).' );
+
+		$this->pin_db_clock( '2026-02-15 00:09:59' );
+		$this->sut->run_batch( $this->now() );
+		$this->assertSame( array( $failing, $healthy, $healthy ), $this->notified_ids(), 'Not re-notified while the lock lives.' );
+
+		$this->pin_db_clock( '2026-02-15 00:10:00' );
+		$this->sut->run_batch( $this->now() );
+		$this->assertContains( $failing, array_slice( $this->notified_ids(), 3 ), 'Re-notified once the lock expired on the database clock.' );
+	}
+
+	/**
+	 * @testdox A failed release after the handler is logged as a release failure and does not stop the batch.
+	 */
+	public function test_a_failed_release_is_logged_and_the_batch_continues(): void {
+		$this->register_recording_handler();
+		$first  = $this->create_due_contract( '2026-02-14 00:00:00' );
+		$second = $this->create_due_contract( '2026-02-15 00:00:00' );
+
+		$this->fail_next_query( '/^\s*UPDATE\s+\S*contracts\s+SET\s+lock_token\s*=\s*NULL/i' );
+		$errors = $this->capture_engine_log(
+			'warning',
+			array( 'contract_id' => $first ),
+			function (): void {
+				$this->assertSame( 2, $this->sut->run_batch( $this->now() ), 'A failed release still counts as notified.' );
+			}
+		);
+
+		$this->assertNotSame( array(), $errors );
+		foreach ( $errors as $error ) {
+			$this->assertSame( 'release', $error['stage'] ?? null, 'Logged as a release failure, not a handler failure.' );
+		}
+		$this->assertSame( array( $first, $second ), $this->notified_ids() );
+		$this->assertNotNull( $this->get_lock_holder( $first )['operation'], 'The unreleased lock expires on its own.' );
+		$this->assertNull( $this->get_lock_holder( $second )['operation'] );
+	}
+
+	/**
+	 * @testdox A failed contract read after the lock is logged per contract; the lock is released and the batch continues.
+	 */
+	public function test_a_failed_read_after_the_lock_is_caught_per_contract(): void {
+		$this->register_recording_handler();
+		$first  = $this->create_due_contract( '2026-02-14 00:00:00' );
+		$second = $this->create_due_contract( '2026-02-15 00:00:00' );
+
+		$this->fail_next_query( '/^\s*SELECT\s+\*,\s*\(\s*lock_token/i' );
+		$errors = $this->capture_engine_log(
+			'error',
+			array( 'contract_id' => $first ),
+			function (): void {
+				$this->assertSame( 1, $this->sut->run_batch( $this->now() ) );
+			}
+		);
+
+		$this->assertNotSame( array(), $errors );
+		$this->assertSame( array( $second ), $this->notified_ids() );
+		$this->assertNull( $this->get_lock_holder( $first )['operation'], 'Nothing ran, so the lock is released.' );
 	}

 	/**
@@ -220,7 +376,7 @@ class DueDispatcherTest extends EngineIntegrationTestCase {
 		$id = $this->create_due_contract();

 		$this->assertSame( 0, $this->sut->run_batch( $this->now() ) );
-		$this->assertNull( $this->stored_claim( $id ), 'No claim is taken on a contract nobody handles.' );
+		$this->assertNull( $this->get_lock_holder( $id )['operation'], 'No lock is taken on a contract nobody handles.' );
 	}

 	/**
@@ -237,7 +393,7 @@ class DueDispatcherTest extends EngineIntegrationTestCase {

 		$this->assertSame( 0, $this->sut->run_batch( $this->now() ) );
 		$this->assertSame( array(), $this->calls );
-		$this->assertNull( $this->stored_claim( $foreign ) );
+		$this->assertNull( $this->get_lock_holder( $foreign )['operation'] );
 	}

 	/**
@@ -275,7 +431,7 @@ class DueDispatcherTest extends EngineIntegrationTestCase {

 		$this->assertSame( 0, $this->sut->run_batch( $this->now() ) );
 		$this->assertSame( array(), $this->calls );
-		$this->assertNull( $this->stored_claim( $id ), 'No claim is taken on a contract the engine does not schedule.' );
+		$this->assertNull( $this->get_lock_holder( $id )['operation'], 'No lock is taken on a contract the engine does not schedule.' );
 	}

 	/**
@@ -320,11 +476,24 @@ class DueDispatcherTest extends EngineIntegrationTestCase {

 		$this->assertSame( 0, $this->sut->run_batch( $this->now(), 0 ) );
 		$this->assertSame( 0, $this->sut->run_batch( $this->now(), -1 ) );
-		$this->assertNull( $this->stored_claim( $first ), 'A no-op tick claims nothing.' );
+		$this->assertNull( $this->get_lock_holder( $first )['operation'], 'A no-op tick locks nothing.' );

 		$this->assertSame( 1, $this->sut->run_batch( $this->now(), 1 ) );
 		$this->assertSame( $first, $this->calls[0]['contract']->get_id(), 'The oldest-due contract goes first.' );
-		$this->assertSame( 2, $this->sut->run_batch( $this->now() ) );
+		$this->assertSame( 3, $this->sut->run_batch( $this->now() ), 'Idle handlers are notified again on the next tick.' );
+	}
+
+	/**
+	 * @testdox The handler receives the dispatcher's lock in its context.
+	 */
+	public function test_the_handler_context_carries_the_lock(): void {
+		$this->register_recording_handler();
+		$this->create_due_contract();
+
+		$this->sut->run_batch( $this->now() );
+
+		$this->assertInstanceOf( ContractLock::class, $this->calls[0]['context']['lock'] );
+		$this->assertTrue( $this->lock_state( $this->calls[0]['context']['lock'] )->is_released(), 'Released once the handler returned.' );
 	}

 	/**
@@ -340,12 +509,15 @@ class DueDispatcherTest extends EngineIntegrationTestCase {
 		}
 		$fresh = $this->create_due_contract( '2026-02-15 00:00:00' );

-		// Every stuck contract was notified before and its claim has since expired.
+		// Every stuck contract was notified before; its lock has since been released. The fresh
+		// contract's creation lock was released before that.
 		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
 		foreach ( $stuck as $id ) {
 			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-			$wpdb->update( $table, array( 'claimed_until' => '2026-01-15 00:15:00' ), array( 'id' => $id ) );
+			$wpdb->update( $table, array( 'locked_until_gmt' => '2026-01-15 00:15:00' ), array( 'id' => $id ) );
 		}
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$wpdb->update( $table, array( 'locked_until_gmt' => '2026-01-10 00:00:00' ), array( 'id' => $fresh ) );

 		// A batch smaller than the stuck backlog: the fresh contract still makes it in.
 		$this->assertSame( 2, $this->sut->run_batch( $this->now(), 2 ) );
@@ -353,7 +525,7 @@ class DueDispatcherTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox Among notified contracts, the least recently notified goes first, so stuck older ones do not starve a later-due retry.
+	 * @testdox Among notified contracts, the least recently locked goes first, so stuck older ones do not starve a later-due retry.
 	 */
 	public function test_stuck_contracts_do_not_starve_a_later_due_retry(): void {
 		global $wpdb;
@@ -365,18 +537,18 @@ class DueDispatcherTest extends EngineIntegrationTestCase {
 		}
 		$retry = $this->create_due_contract( '2026-02-10 00:00:00' );

-		// The stuck contracts were re-notified recently; the retry's handler threw a while ago.
+		// The stuck contracts were re-notified recently; the retry's lock expired a while ago.
 		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
 		foreach ( $stuck as $id ) {
 			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-			$wpdb->update( $table, array( 'claimed_until' => '2026-02-14 00:15:00' ), array( 'id' => $id ) );
+			$wpdb->update( $table, array( 'locked_until_gmt' => '2026-02-14 00:15:00' ), array( 'id' => $id ) );
 		}
 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-		$wpdb->update( $table, array( 'claimed_until' => '2026-02-10 00:15:00' ), array( 'id' => $retry ) );
+		$wpdb->update( $table, array( 'locked_until_gmt' => '2026-02-10 00:15:00' ), array( 'id' => $retry ) );

 		// A batch smaller than the stuck backlog: the retry still makes it in.
 		$this->assertSame( 2, $this->sut->run_batch( $this->now(), 2 ) );
-		$this->assertSame( $retry, $this->calls[0]['contract']->get_id(), 'The least recently notified contract goes first despite its later due moment.' );
+		$this->assertSame( $retry, $this->calls[0]['contract']->get_id(), 'The least recently locked contract goes first despite its later due moment.' );
 	}

 	/**
@@ -440,6 +612,8 @@ class DueDispatcherTest extends EngineIntegrationTestCase {
 		$this->register_recording_handler();
 		$this->create_due_contract();

+		// The booted engine registers its own dispatcher; route the tick only to the SUT.
+		remove_all_actions( DueDispatcher::HOOK );
 		$this->sut->register_hooks();
 		do_action( DueDispatcher::HOOK ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment

@@ -474,11 +648,11 @@ class DueDispatcherTest extends EngineIntegrationTestCase {
 	 * Create an active contract with one item, due at `$next_payment_gmt`.
 	 *
 	 * @param string               $next_payment_gmt Due moment (UTC).
-	 * @param array<string, mixed> $overrides        `Contracts::create()` fields to replace.
+	 * @param array<string, mixed> $overrides        `$this->create_released()` fields to replace.
 	 * @return int The contract id.
 	 */
 	private function create_due_contract( string $next_payment_gmt = '2026-02-15 00:00:00', array $overrides = array() ): int {
-		$contract = Contracts::create(
+		$contract = $this->create_released(
 			array_merge(
 				array(
 					'extension_slug'   => self::OWNER,
@@ -507,18 +681,16 @@ class DueDispatcherTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * The stored due-dispatch claim of a contract.
+	 * The ids of the notified contracts, in call order.
 	 *
-	 * @param int $id Contract id.
+	 * @return array<int, int>
 	 */
-	private function stored_claim( int $id ): ?string {
-		global $wpdb;
-
-		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
-
-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-		$claim = $wpdb->get_var( $wpdb->prepare( "SELECT claimed_until FROM {$table} WHERE id = %d", $id ) );
-
-		return null === $claim ? null : (string) $claim;
+	private function notified_ids(): array {
+		return array_map(
+			static function ( array $call ): int {
+				return $call['contract']->get_id();
+			},
+			$this->calls
+		);
 	}
 }
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
index 578f3b0aefb..321f2599a6f 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Ownership/ContractCapabilitiesTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Ownership/ContractCapabilitiesTest.php
@@ -88,7 +88,7 @@ class ContractCapabilitiesTest extends EngineIntegrationTestCase {
 	 * @param int|null $customer_id Customer id.
 	 */
 	private function create_contract( ?int $customer_id ): ContractView {
-		return Contracts::create(
+		return $this->create_released(
 			array(
 				'extension_slug' => 'test-extension',
 				'status'         => 'active',
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractCustomerReadTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractCustomerReadTest.php
index 5e69e62d8f1..8070c679839 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractCustomerReadTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractCustomerReadTest.php
@@ -54,7 +54,7 @@ class ContractCustomerReadTest extends EngineIntegrationTestCase {
 			)
 		);

-		return $this->sut->insert( $contract );
+		return $this->insert_released( $contract );
 	}

 	/**
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractLockTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractLockTest.php
new file mode 100644
index 00000000000..d4d90fc7886
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractLockTest.php
@@ -0,0 +1,663 @@
+<?php
+/**
+ * Integration tests for the per-contract lock primitives in ContractRepository.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Storage;
+
+use EngineIntegrationTestCase;
+use InvalidArgumentException;
+use RuntimeException;
+use ReflectionProperty;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLock;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLockedException;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractLockState;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLock
+ */
+class ContractLockTest extends EngineIntegrationTestCase {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var ContractRepository
+	 */
+	private $sut;
+
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new ContractRepository();
+	}
+
+	private function insert_contract( ?string $next_payment_gmt = '2026-06-15 00:00:00' ): int {
+		return $this->insert_released(
+			Contract::create(
+				array(
+					'extension_slug'   => 'engine-tests',
+					'status'           => ContractStatus::ACTIVE,
+					'next_payment_gmt' => $next_payment_gmt,
+				)
+			)
+		);
+	}
+
+	/**
+	 * The raw lock columns of a contract row.
+	 *
+	 * @param int $id Contract id.
+	 * @return array<mixed>
+	 */
+	private function lock_columns( int $id ): array {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$row = $wpdb->get_row( $wpdb->prepare( "SELECT lock_token, locked_until_gmt, lock_operation FROM {$table} WHERE id = %d", $id ), ARRAY_A );
+		$this->assertIsArray( $row );
+
+		return $row;
+	}
+
+	/**
+	 * End a lock's trust window and make its local expiry estimate read as near.
+	 *
+	 * @param ContractLock $lock The lock.
+	 */
+	private function age_to_near_expiry( ContractLock $lock ): void {
+		$estimate = new ReflectionProperty( ContractLockState::class, 'expires_estimate' );
+		$estimate->setAccessible( true );
+		$estimate->setValue( $this->lock_state( $lock ), 0.0 );
+		$this->age_lock_trust( $lock );
+	}
+
+	private function acquire( int $id, string $operation = 'hold' ): ContractLock {
+		$lock = $this->sut->acquire_lock( $id, $operation );
+		$this->assertInstanceOf( ContractLock::class, $lock );
+
+		return $lock;
+	}
+
+	/**
+	 * @testdox A lock is acquired once; a second acquire returns null while it lives.
+	 */
+	public function test_acquire_wins_once(): void {
+		$id = $this->insert_contract();
+		$this->pin_db_clock( '2035-01-01 00:00:00' );
+
+		$lock = $this->acquire( $id );
+
+		$this->assertSame( $id, $lock->get_contract_id() );
+		$this->assertSame( 'hold', $lock->get_operation() );
+		$this->assertNull( $this->sut->acquire_lock( $id, 'cancel' ) );
+		$this->assertSame( '2035-01-01 00:10:00', $this->lock_columns( $id )['locked_until_gmt'] );
+		$this->assertSame(
+			array(
+				'exists'    => true,
+				'operation' => 'hold',
+			),
+			$this->get_lock_holder( $id )
+		);
+	}
+
+	/**
+	 * @testdox An expired lock is taken over, and the old holder's row writes are then refused.
+	 */
+	public function test_an_expired_lock_is_taken_over(): void {
+		$id = $this->insert_contract();
+		$this->pin_db_clock( '2035-01-01 00:00:00' );
+		$old = $this->acquire( $id );
+
+		$this->pin_db_clock( '2035-01-01 00:09:59' );
+		$this->assertNull( $this->sut->acquire_lock( $id, 'cancel' ), 'Still live one second before expiry.' );
+
+		$this->pin_db_clock( '2035-01-01 00:10:00' );
+		$new = $this->acquire( $id, 'cancel' );
+		$this->assertSame( 'cancel', $this->get_lock_holder( $id )['operation'] );
+
+		$contract = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+		$contract->set_status( ContractStatus::ON_HOLD );
+
+		try {
+			$this->sut->update_fields( $contract, array( 'status' ), $old );
+			$this->fail( 'Expected the taken-over lock to be refused.' );
+		} catch ( ContractLockedException $e ) {
+			$this->assertSame( 'cancel', $e->get_operation() );
+		}
+
+		$this->assertTrue( $this->sut->update_fields( $contract, array( 'status' ), $new ) );
+	}
+
+	/**
+	 * @testdox An acquire conditioned on a column value fails once the column moved.
+	 */
+	public function test_acquire_with_an_equality_condition(): void {
+		$id = $this->insert_contract( '2026-06-20 00:00:00' );
+
+		$this->assertNull( $this->sut->acquire_lock( $id, 'engine_due_dispatch', array( 'next_payment_gmt' => '2026-06-15 00:00:00' ) ) );
+		$this->assertNull( $this->get_lock_holder( $id )['operation'] );
+		$this->assertInstanceOf( ContractLock::class, $this->sut->acquire_lock( $id, 'engine_due_dispatch', array( 'next_payment_gmt' => '2026-06-20 00:00:00' ) ) );
+	}
+
+	/**
+	 * @testdox A null condition matches only a NULL column.
+	 */
+	public function test_acquire_with_a_null_condition(): void {
+		$set = $this->insert_contract( '2026-06-20 00:00:00' );
+		$this->assertNull( $this->sut->acquire_lock( $set, 'engine_due_dispatch', array( 'next_payment_gmt' => null ) ) );
+		$this->assertNull( $this->get_lock_holder( $set )['operation'] );
+
+		$cleared = $this->insert_contract( null );
+		$this->assertInstanceOf( ContractLock::class, $this->sut->acquire_lock( $cleared, 'engine_due_dispatch', array( 'next_payment_gmt' => null ) ) );
+	}
+
+	/**
+	 * @testdox Every condition must hold for the lock to land.
+	 */
+	public function test_acquire_requires_every_condition(): void {
+		$id = $this->insert_contract( '2026-06-20 00:00:00' );
+
+		$this->assertNull(
+			$this->sut->acquire_lock(
+				$id,
+				'engine_due_dispatch',
+				array(
+					'next_payment_gmt' => '2026-06-20 00:00:00',
+					'status'           => ContractStatus::ON_HOLD,
+				)
+			)
+		);
+		$this->assertNull( $this->get_lock_holder( $id )['operation'] );
+		$this->assertInstanceOf(
+			ContractLock::class,
+			$this->sut->acquire_lock(
+				$id,
+				'engine_due_dispatch',
+				array(
+					'next_payment_gmt' => '2026-06-20 00:00:00',
+					'status'           => ContractStatus::ACTIVE,
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox A condition on an unlisted column or with a non-scalar value throws and locks nothing.
+	 */
+	public function test_invalid_conditions_throw(): void {
+		$id = $this->insert_contract();
+
+		foreach ( array( array( 'lock_token' => null ), array( 'customer_id' => 1 ), array( 'status' => array( ContractStatus::ACTIVE ) ) ) as $conditions ) {
+			try {
+				$this->sut->acquire_lock( $id, 'engine_due_dispatch', $conditions );
+				$this->fail( 'Expected an invalid condition to throw.' );
+			} catch ( InvalidArgumentException $e ) {
+				$this->assertNull( $this->get_lock_holder( $id )['operation'] );
+			}
+		}
+	}
+
+	/**
+	 * @testdox An unknown contract cannot be locked and reads as missing.
+	 */
+	public function test_unknown_contract(): void {
+		$this->assertNull( $this->sut->acquire_lock( 999999, 'hold' ) );
+		$this->assertFalse( $this->get_lock_holder( 999999 )['exists'] );
+	}
+
+	/**
+	 * @testdox A failed lock holder read throws instead of reading as a missing contract.
+	 */
+	public function test_a_failed_lock_holder_read_throws(): void {
+		$id = $this->insert_contract();
+
+		$this->fail_next_query( '/AS live, lock_operation/' );
+		$this->expectException( RuntimeException::class );
+		$this->expectExceptionMessage( 'Failed to read the lock on contract' );
+		$this->get_lock_holder( $id );
+	}
+
+	/**
+	 * @testdox Operation labels must be 1-64 lowercase letters, digits, underscores or hyphens.
+	 */
+	public function test_operation_label_is_validated(): void {
+		$id = $this->insert_contract();
+
+		foreach ( array( '', str_repeat( 'o', 65 ), 'Hold', 'hold now', '<b>hold</b>', "hold\n", 'hôld' ) as $operation ) {
+			try {
+				$this->sut->acquire_lock( $id, $operation );
+				$this->fail( 'Expected an invalid label to be refused.' );
+			} catch ( InvalidArgumentException $e ) {
+				$this->assertNull( $this->get_lock_holder( $id )['operation'] );
+			}
+		}
+
+		$this->assertInstanceOf( ContractLock::class, $this->sut->acquire_lock( $id, str_repeat( 'o', 64 ) ) );
+		$this->assertInstanceOf( ContractLock::class, $this->sut->acquire_lock( $this->insert_contract(), 'renewal_retry-2' ) );
+	}
+
+	/**
+	 * @testdox Release clears the token and operation and keeps the release moment; it is idempotent.
+	 */
+	public function test_release_records_the_release_moment(): void {
+		$id = $this->insert_contract();
+		$this->pin_db_clock( '2035-01-01 00:00:00' );
+		$lock = $this->acquire( $id );
+
+		$this->pin_db_clock( '2035-01-01 00:02:00' );
+		$lock->release();
+		$lock->release();
+
+		$this->assertSame(
+			array(
+				'lock_token'       => null,
+				'locked_until_gmt' => '2035-01-01 00:02:00',
+				'lock_operation'   => null,
+			),
+			$this->lock_columns( $id )
+		);
+		$this->assertTrue( $this->lock_state( $lock )->is_released() );
+		$this->assertInstanceOf( ContractLock::class, $this->sut->acquire_lock( $id, 'cancel' ), 'A released contract can be locked again at once.' );
+	}
+
+	/**
+	 * @testdox Releasing a taken-over lock does not clear the new holder.
+	 */
+	public function test_release_is_token_guarded(): void {
+		$id = $this->insert_contract();
+		$this->pin_db_clock( '2035-01-01 00:00:00' );
+		$old = $this->acquire( $id );
+
+		$this->pin_db_clock( '2035-01-01 00:10:00' );
+		$this->acquire( $id, 'cancel' );
+		$old->release();
+
+		$holder = $this->get_lock_holder( $id );
+		$this->assertNotNull( $holder['operation'] );
+		$this->assertSame( 'cancel', $holder['operation'] );
+	}
+
+	/**
+	 * @testdox The fence extends a lock near expiry even in the second it was acquired.
+	 */
+	public function test_fence_extends_in_the_acquire_second(): void {
+		$id = $this->insert_contract();
+		$this->pin_db_clock( '2035-01-01 00:00:00' );
+		$lock = $this->acquire( $id );
+		$this->age_to_near_expiry( $lock );
+
+		$this->fence_children( $lock );
+
+		$this->assertTrue( $this->lock_state( $lock )->has_extended() );
+		$this->assertSame( 'hold', $this->get_lock_holder( $id )['operation'] );
+	}
+
+	/**
+	 * @testdox The fence's extend of a lost lock throws ContractLockedException.
+	 */
+	public function test_fence_extend_of_a_lost_lock_throws(): void {
+		$id = $this->insert_contract();
+		$this->pin_db_clock( '2035-01-01 00:00:00' );
+		$lock = $this->acquire( $id );
+		$this->age_to_near_expiry( $lock );
+
+		$this->pin_db_clock( '2035-01-01 00:10:00' );
+
+		$this->expectException( ContractLockedException::class );
+		$this->fence_children( $lock );
+	}
+
+	/**
+	 * @testdox The child fence trusts a fresh lock without a query, extends once past the window, then verifies.
+	 */
+	public function test_fence_children_trust_extend_verify(): void {
+		global $wpdb;
+
+		$id = $this->insert_contract();
+		$this->pin_db_clock( '2035-01-01 00:00:00' );
+		$lock = $this->acquire( $id );
+
+		$queries = $wpdb->num_queries;
+		$this->fence_children( $lock );
+		$this->assertSame( $queries, $wpdb->num_queries, 'A trusted lock costs no query.' );
+
+		$this->pin_db_clock( '2035-01-01 00:09:30' );
+		$this->age_lock_trust( $lock );
+		$this->fence_children( $lock );
+		$this->assertTrue( $this->lock_state( $lock )->has_extended(), 'Past the window the fence extends once.' );
+		$this->assertSame( '2035-01-01 00:19:30', $this->lock_columns( $id )['locked_until_gmt'] );
+		$this->assertTrue( $this->lock_state( $lock )->is_trusted() );
+
+		$this->pin_db_clock( '2035-01-01 00:15:00' );
+		$this->age_lock_trust( $lock );
+		$this->fence_children( $lock );
+		$this->assertTrue( $this->lock_state( $lock )->is_trusted(), 'With 270 s left the verify trusts it again.' );
+
+		$this->pin_db_clock( '2035-01-01 00:18:31' );
+		$this->age_lock_trust( $lock );
+		$this->expectException( ContractLockedException::class );
+		$this->fence_children( $lock );
+	}
+
+	/**
+	 * @testdox The child fence refuses an extended lock whose stored lock expired.
+	 */
+	public function test_fence_children_refuses_an_expired_lock(): void {
+		$id = $this->insert_contract();
+		$this->pin_db_clock( '2035-01-01 00:00:00' );
+		$lock = $this->acquire( $id );
+		$this->age_to_near_expiry( $lock );
+		$this->fence_children( $lock );
+		$this->assertTrue( $this->lock_state( $lock )->has_extended() );
+
+		$this->pin_db_clock( '2035-01-01 00:10:00' );
+		$this->age_lock_trust( $lock );
+
+		$this->expectException( ContractLockedException::class );
+		$this->fence_children( $lock );
+	}
+
+	/**
+	 * @testdox The child fence refuses a released lock.
+	 */
+	public function test_fence_children_refuses_a_released_lock(): void {
+		$lock = $this->acquire( $this->insert_contract() );
+		$lock->release();
+
+		$this->expectException( ContractLockedException::class );
+		$this->fence_children( $lock );
+	}
+
+	/**
+	 * @testdox Past the window, a lock near expiry by the local estimate is extended without a verify read.
+	 */
+	public function test_fence_children_extends_a_lock_near_expiry_by_the_local_estimate(): void {
+		global $wpdb;
+
+		$id = $this->insert_contract();
+		$this->pin_db_clock( '2035-01-01 00:00:00' );
+		$lock = $this->acquire( $id );
+		$this->age_to_near_expiry( $lock );
+
+		$this->pin_db_clock( '2035-01-01 00:08:30' );
+		$queries = $wpdb->num_queries;
+		$this->fence_children( $lock );
+
+		$this->assertSame( 1, $wpdb->num_queries - $queries, 'One extend write, no verify read.' );
+		$this->assertTrue( $this->lock_state( $lock )->has_extended() );
+		$this->assertSame( '2035-01-01 00:18:30', $this->lock_columns( $id )['locked_until_gmt'] );
+	}
+
+	/**
+	 * @testdox A lock taken over after expiry is refused on its first child write past the trust window.
+	 */
+	public function test_a_taken_over_lock_is_refused_on_the_first_child_write_past_the_window(): void {
+		$id = $this->insert_contract();
+		$this->pin_db_clock( '2035-01-01 00:00:00' );
+		$old = $this->acquire( $id );
+
+		$this->pin_db_clock( '2035-01-01 00:10:00' );
+		$this->acquire( $id, 'cancel' );
+		$this->age_lock_trust( $old );
+
+		try {
+			$this->sut->add_meta( $id, '_stale', 'x', false, $old );
+			$this->fail( 'Expected the taken-over lock to be refused.' );
+		} catch ( ContractLockedException $e ) {
+			$this->assertSame( 'cancel', $e->get_operation() );
+		}
+
+		$this->assertSame( array(), $this->sut->get_meta( $id, '_stale' ) );
+	}
+
+	/**
+	 * @testdox A lock built outside the engine repository gets a doing-it-wrong notice and is still built.
+	 */
+	public function test_a_lock_built_outside_the_repository_is_doing_it_wrong(): void {
+		$this->setExpectedIncorrectUsage( ContractLock::class . '::__construct' );
+
+		$lock = new ContractLock( 7, 'hold', str_repeat( 'a', 32 ) );
+
+		$this->assertSame( 7, $lock->get_contract_id() );
+	}
+
+	/**
+	 * @testdox Reading a lock's state outside the engine repository gets a doing-it-wrong notice and still returns it.
+	 */
+	public function test_lock_state_outside_the_repository_is_doing_it_wrong(): void {
+		$lock = $this->acquire( $this->insert_contract() );
+
+		$this->setExpectedIncorrectUsage( ContractLock::class . '::get_state' );
+
+		$this->assertFalse( $lock->get_state()->is_released() );
+	}
+
+	/**
+	 * @testdox Locks the repository builds, and fenced writes that read their state, raise no notice.
+	 */
+	public function test_repository_locks_raise_no_notice(): void {
+		$notices = 0;
+		$count   = static function () use ( &$notices ): void {
+			++$notices;
+		};
+		add_action( 'doing_it_wrong_run', $count );
+		try {
+			$id   = $this->insert_contract();
+			$lock = $this->acquire( $id );
+			$this->age_lock_trust( $lock );
+			$contract = $this->sut->find( $id );
+			$this->assertInstanceOf( Contract::class, $contract );
+			$contract->set_status( ContractStatus::ON_HOLD );
+			$this->assertTrue( $this->sut->update_fields( $contract, array( 'status' ), $lock ) );
+			$this->assertIsInt( $this->sut->add_meta( $id, 'note', 'x', false, $lock ) );
+			$lock->release();
+			$this->assertNotNull( $this->sut->insert( Contract::create( array( 'extension_slug' => 'engine-tests' ) ), 'checkout' ) );
+		} finally {
+			remove_action( 'doing_it_wrong_run', $count );
+		}
+
+		$this->assertSame( 0, $notices );
+	}
+
+	/**
+	 * @testdox A lock built from a forged token cannot write child rows once its trust is checked.
+	 */
+	public function test_a_forged_lock_cannot_write_child_rows_past_a_verify(): void {
+		$id = $this->insert_contract();
+		$this->pin_db_clock( '2035-01-01 00:00:00' );
+		$this->acquire( $id );
+
+		$this->setExpectedIncorrectUsage( ContractLock::class . '::__construct' );
+		$forged = new ContractLock( $id, 'forged', str_repeat( 'f', 32 ) );
+		$this->age_lock_trust( $forged );
+
+		try {
+			$this->sut->add_meta( $id, '_forged', 'x', false, $forged );
+			$this->fail( 'Expected the forged lock to be refused.' );
+		} catch ( ContractLockedException $e ) {
+			$this->assertSame( 'hold', $e->get_operation() );
+		}
+
+		$this->assertSame( array(), $this->sut->get_meta( $id, '_forged' ) );
+	}
+
+	/**
+	 * @testdox An unlocked row write the fence refuses throws, naming the internal lock that is held while the write is classified.
+	 */
+	public function test_a_refused_unlocked_update_throws(): void {
+		$id = $this->insert_contract();
+		$this->refuse_next_write( 'UPDATE `' . SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ) . '` SET' );
+
+		try {
+			Contracts::update( $id, array( 'status' => ContractStatus::ON_HOLD ) );
+			$this->fail( 'Expected the refused write to throw.' );
+		} catch ( ContractLockedException $e ) {
+			$this->assertSame( 'engine_contract_update', $e->get_operation() );
+		}
+	}
+
+	/**
+	 * @testdox Unlocked meta writes take and release the internal lock, are refused while another operation holds the lock, and keep their missing-contract results.
+	 */
+	public function test_unlocked_meta_writes_use_the_internal_lock(): void {
+		$id = $this->insert_contract();
+
+		$acquires = 0;
+		$count    = static function ( $query ) use ( &$acquires ) {
+			if ( is_string( $query ) && 1 === preg_match( "/lock_operation = 'engine_contract_update'/", $query ) ) {
+				++$acquires;
+			}
+			return $query;
+		};
+		add_filter( 'query', $count );
+		try {
+			$this->assertIsInt( $this->sut->add_meta( $id, '_note', 'a' ) );
+			$this->assertTrue( $this->sut->update_meta( $id, '_note', 'b' ) );
+			$this->assertFalse( $this->sut->update_meta( $id, '_note', 'b' ), 'The stored value: nothing to change.' );
+			$this->assertTrue( $this->sut->delete_meta( $id, '_note' ) );
+			$this->assertFalse( $this->sut->delete_meta( $id, '_absent' ) );
+		} finally {
+			remove_filter( 'query', $count );
+		}
+		$this->assertSame( 5, $acquires, 'Each write takes the internal lock.' );
+		$this->assertNull( $this->get_lock_holder( $id )['operation'], 'Each write releases it.' );
+
+		$this->sut->add_meta( $id, '_anchor', 'a' );
+		$this->acquire( $id, 'renewal' );
+		$writes = array(
+			'add_meta'    => function () use ( $id ): void {
+				$this->sut->add_meta( $id, '_anchor', 'b' );
+			},
+			'update_meta' => function () use ( $id ): void {
+				$this->sut->update_meta( $id, '_anchor', 'b' );
+			},
+			'delete_meta' => function () use ( $id ): void {
+				$this->sut->delete_meta( $id, '_anchor' );
+			},
+		);
+		foreach ( $writes as $name => $write ) {
+			try {
+				$write();
+				$this->fail( "Expected {$name} to be refused." );
+			} catch ( ContractLockedException $e ) {
+				$this->assertSame( 'renewal', $e->get_operation(), "{$name} names the holder." );
+			}
+		}
+		$this->assertSame( array( 'a' ), $this->sut->get_meta( $id, '_anchor' ) );
+
+		$this->assertFalse( $this->sut->delete_meta( 999999, '_anchor' ), 'A delete on a missing contract deletes nothing.' );
+		foreach ( array( 'add_meta', 'update_meta' ) as $method ) {
+			try {
+				$this->sut->$method( 999999, '_anchor', 'x' );
+				$this->fail( "Expected {$method} on a missing contract to throw." );
+			} catch ( InvalidArgumentException $e ) {
+				$this->assertStringContainsString( '999999', $e->getMessage() );
+			}
+		}
+	}
+
+	/**
+	 * @testdox Every child write fences its lock: a lock lost past the trust window is refused by meta, cycle, item and address writes, which then change nothing.
+	 */
+	public function test_child_writes_refuse_a_lost_lock(): void {
+		$id = $this->insert_contract();
+		$this->pin_db_clock( '2035-01-01 00:00:00' );
+		$lock = $this->acquire( $id );
+		$this->pin_db_clock( '2035-01-01 00:10:00' );
+		$this->acquire( $id, 'cancel' );
+		$this->age_lock_trust( $lock );
+
+		$writes = array(
+			'update_meta'       => function () use ( $id, $lock ): void {
+				$this->sut->update_meta( $id, '_note', 'x', null, $lock );
+			},
+			'delete_meta'       => function () use ( $id, $lock ): void {
+				$this->sut->delete_meta( $id, '_note', null, $lock );
+			},
+			'replace_items'     => function () use ( $id, $lock ): void {
+				$this->call_private( $this->sut, 'replace_items', $id, array( array( 'item_name' => 'Changed' ) ), $lock );
+			},
+			'replace_addresses' => function () use ( $id, $lock ): void {
+				$this->call_private( $this->sut, 'replace_addresses', $id, array( 'billing' => array( 'first_name' => 'Changed' ) ), $lock );
+			},
+			'append_cycle'      => function () use ( $id, $lock ): void {
+				$this->sut->append_cycle(
+					Cycle::create(
+						array(
+							'contract_id'   => $id,
+							'starts_at_gmt' => '2026-07-15 00:00:00',
+							'ends_at_gmt'   => '2026-08-15 00:00:00',
+							'currency'      => 'USD',
+						)
+					),
+					$lock
+				);
+			},
+		);
+		foreach ( $writes as $name => $write ) {
+			try {
+				$write();
+				$this->fail( "Expected {$name} with a lost lock to be refused." );
+			} catch ( ContractLockedException $e ) {
+				$this->assertSame( 'cancel', $e->get_operation(), "{$name} names the new holder." );
+			}
+		}
+
+		$stored = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $stored );
+		$this->assertSame( array(), $stored->get_items(), 'No item row is written.' );
+		$this->assertSame( array(), $stored->get_addresses(), 'No address row is written.' );
+		$this->assertSame( array(), $this->sut->find_cycle_history( $id ), 'No cycle row is written.' );
+	}
+
+	/**
+	 * @testdox Reads expose the lock state only while the lock lives, and never the token.
+	 */
+	public function test_reads_expose_lock_state_not_token(): void {
+		$id = $this->insert_contract();
+		$this->pin_db_clock( '2035-01-01 00:00:00' );
+
+		$unlocked = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $unlocked );
+		$this->assertNull( $unlocked->get_lock_state() );
+
+		$this->acquire( $id );
+		$token = $this->lock_columns( $id )['lock_token'];
+		$this->assertIsString( $token );
+
+		$reads = array(
+			$this->sut->find( $id ),
+			$this->sut->find_summary( $id ),
+			$this->sut->query( array( 'search' => (string) $id ) )[0] ?? null,
+		);
+		foreach ( $reads as $contract ) {
+			$this->assertInstanceOf( Contract::class, $contract );
+			$this->assertSame(
+				array(
+					'locked_until_gmt' => '2035-01-01 00:10:00',
+					'operation'        => 'hold',
+				),
+				$contract->get_lock_state()
+			);
+			$this->assertStringNotContainsString( $token, print_r( $contract, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
+			$this->assertStringNotContainsString( $token, (string) wp_json_encode( $contract->to_storage() ) );
+		}
+
+		$this->pin_db_clock( '2035-01-01 00:10:00' );
+		$expired = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $expired );
+		$this->assertNull( $expired->get_lock_state(), 'An expired lock reads as unlocked.' );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractMetaTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractMetaTest.php
index fe4d6da088d..c0ae4f060d4 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractMetaTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractMetaTest.php
@@ -12,8 +12,12 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integrati

 use EngineIntegrationTestCase;
 use InvalidArgumentException;
+use RuntimeException;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLock;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLockedException;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository
@@ -37,7 +41,7 @@ class ContractMetaTest extends EngineIntegrationTestCase {
 	public function setUp(): void {
 		parent::setUp();
 		$this->sut = new ContractRepository();
-		$this->id  = $this->sut->insert( Contract::create( array( 'extension_slug' => 'acme-subs' ) ) );
+		$this->id  = $this->insert_released( Contract::create( array( 'extension_slug' => 'acme-subs' ) ) );
 	}

 	public function test_add_meta_keeps_every_value_under_one_key_in_order(): void {
@@ -58,6 +62,67 @@ class ContractMetaTest extends EngineIntegrationTestCase {
 		$this->assertSame( array( 'one' ), $this->sut->get_meta( $this->id, 'note' ) );
 	}

+	/**
+	 * @testdox A repeated unique add, with or without the lock, keeps one row: a plain read of the key, then the insert.
+	 */
+	public function test_unique_add_is_idempotent(): void {
+		global $wpdb;
+
+		$this->assertIsInt( $this->sut->add_meta( $this->id, 'note', 'one', true ) );
+		$this->assertNull( $this->sut->add_meta( $this->id, 'note', 'two', true ) );
+
+		$lock = $this->lock();
+		$this->assertNull( $this->sut->add_meta( $this->id, 'note', 'three', true, $lock ) );
+		$lock->release();
+
+		$this->assertIsInt( $this->sut->add_meta( $this->id, 'fresh', 'x', true ) );
+
+		$queries = $wpdb->num_queries;
+		$this->assertIsInt( $this->sut->add_meta( $this->id, 'single', 'x', true ) );
+		$this->assertSame( 4, $wpdb->num_queries - $queries, 'Unlocked: the internal lock acquire, the key read, the insert, the release.' );
+
+		$lock       = $this->lock();
+		$queries    = $wpdb->num_queries;
+		$statements = $this->statements_during(
+			function () use ( $lock ): void {
+				$this->assertIsInt( $this->sut->add_meta( $this->id, 'single-locked', 'x', true, $lock ) );
+			}
+		);
+		$this->assertSame( 2, $wpdb->num_queries - $queries, 'With a trusted lock: the key read, then the insert.' );
+		$this->assert_no_self_table_insert_select( $statements, SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ) );
+		$lock->release();
+
+		$this->assertSame( array( 'one' ), $this->sut->get_meta( $this->id, 'note' ) );
+	}
+
+	/**
+	 * @testdox update_meta on an absent key adds once; a repeat updates instead of duplicating.
+	 */
+	public function test_update_meta_does_not_duplicate(): void {
+		$this->assertTrue( $this->sut->update_meta( $this->id, 'note', 'one' ) );
+		$this->assertTrue( $this->sut->update_meta( $this->id, 'note', 'two' ) );
+
+		$lock = $this->lock();
+		$this->assertTrue( $this->sut->update_meta( $this->id, 'locked', 'one', null, $lock ) );
+		$this->assertTrue( $this->sut->update_meta( $this->id, 'locked', 'two', null, $lock ) );
+		$lock->release();
+
+		$this->assertSame( array( 'two' ), $this->sut->get_meta( $this->id, 'note' ) );
+		$this->assertSame( array( 'two' ), $this->sut->get_meta( $this->id, 'locked' ) );
+	}
+
+	/**
+	 * @testdox A failed meta read throws instead of reading as an absent key.
+	 */
+	public function test_a_failed_meta_read_throws(): void {
+		$this->sut->add_meta( $this->id, 'note', 'one' );
+
+		$this->fail_next_query( '/^\s*SELECT\s+meta_key,\s*meta_value/i' );
+
+		$this->expectException( \RuntimeException::class );
+		$this->sut->get_meta( $this->id, 'note' );
+	}
+
 	public function test_update_meta_adds_when_absent(): void {
 		$this->assertTrue( $this->sut->update_meta( $this->id, 'note', 'one' ) );
 		$this->assertSame( array( 'one' ), $this->sut->get_meta( $this->id, 'note' ) );
@@ -80,6 +145,27 @@ class ContractMetaTest extends EngineIntegrationTestCase {
 		$this->assertFalse( $this->sut->update_meta( $this->id, 'note', 'four', 'missing' ) );
 	}

+	/**
+	 * @testdox update_meta() on an existing key writes one UPDATE: between the internal lock's acquire and release, or alone under a trusted lock.
+	 */
+	public function test_update_meta_on_an_existing_key_is_one_statement(): void {
+		global $wpdb;
+
+		$this->sut->add_meta( $this->id, 'note', 'one' );
+
+		$queries = $wpdb->num_queries;
+		$this->assertTrue( $this->sut->update_meta( $this->id, 'note', 'two' ) );
+		$this->assertSame( 3, $wpdb->num_queries - $queries, 'Unlocked: the internal lock acquire, the update, the release.' );
+
+		$lock    = $this->lock();
+		$queries = $wpdb->num_queries;
+		$this->assertTrue( $this->sut->update_meta( $this->id, 'note', 'three', null, $lock ) );
+		$this->assertSame( 1, $wpdb->num_queries - $queries, 'With a trusted lock.' );
+		$lock->release();
+
+		$this->assertSame( array( 'three' ), $this->sut->get_meta( $this->id, 'note' ) );
+	}
+
 	public function test_update_meta_to_the_same_value_reports_no_change(): void {
 		$this->sut->add_meta( $this->id, 'note', 'one' );

@@ -138,7 +224,7 @@ class ContractMetaTest extends EngineIntegrationTestCase {
 	}

 	public function test_meta_is_scoped_to_its_contract(): void {
-		$other = $this->sut->insert( Contract::create( array( 'extension_slug' => 'acme-subs' ) ) );
+		$other = $this->insert_released( Contract::create( array( 'extension_slug' => 'acme-subs' ) ) );
 		$this->sut->add_meta( $other, 'note', 'theirs' );

 		$this->assertSame( array(), $this->sut->get_meta( $this->id, 'note' ) );
@@ -165,4 +251,119 @@ class ContractMetaTest extends EngineIntegrationTestCase {
 			'delete' => array( 'delete_meta' ),
 		);
 	}
+
+	/**
+	 * @testdox Each meta write lands with the live lock.
+	 */
+	public function test_meta_writes_with_the_lock_land(): void {
+		$lock = $this->lock();
+
+		$this->assertIsInt( $this->sut->add_meta( $this->id, 'note', 'one', false, $lock ) );
+		$this->assertTrue( $this->sut->update_meta( $this->id, 'note', 'two', null, $lock ) );
+		$this->assertTrue( $this->sut->update_meta( $this->id, 'other', 'added', null, $lock ) );
+		$this->assertSame( array( 'two' ), $this->sut->get_meta( $this->id, 'note' ) );
+		$this->assertTrue( $this->sut->delete_meta( $this->id, 'note', null, $lock ) );
+		$this->assertSame( array(), $this->sut->get_meta( $this->id, 'note' ) );
+	}
+
+	/**
+	 * @testdox Unlocked meta writes are refused while another lock lives and land after release.
+	 */
+	public function test_unlocked_meta_writes_are_refused_while_locked(): void {
+		$this->sut->add_meta( $this->id, 'note', 'one' );
+		$lock = $this->lock();
+
+		$writes = array(
+			'add'            => function (): void {
+				$this->sut->add_meta( $this->id, 'note', 'two' );
+			},
+			'update'         => function (): void {
+				$this->sut->update_meta( $this->id, 'note', 'two' );
+			},
+			'add via update' => function (): void {
+				$this->sut->update_meta( $this->id, 'fresh', 'two' );
+			},
+			'delete'         => function (): void {
+				$this->sut->delete_meta( $this->id, 'note' );
+			},
+		);
+
+		foreach ( $writes as $name => $write ) {
+			try {
+				$write();
+				$this->fail( "Expected the unlocked {$name} to be refused." );
+			} catch ( ContractLockedException $e ) {
+				$this->assertSame( 'hold', $e->get_operation() );
+			}
+		}
+		$this->assertSame( array( 'note' => array( 'one' ) ), $this->sut->get_meta( $this->id ), 'Nothing was written.' );
+
+		$lock->release();
+		$this->assertTrue( $this->sut->update_meta( $this->id, 'note', 'two' ) );
+		$this->assertIsInt( $this->sut->add_meta( $this->id, 'note', 'three' ) );
+		$this->assertTrue( $this->sut->delete_meta( $this->id, 'note', 'two' ) );
+		$this->assertSame( array( 'three' ), $this->sut->get_meta( $this->id, 'note' ) );
+	}
+
+	/**
+	 * @testdox A meta add for an unknown contract throws and inserts nothing.
+	 */
+	public function test_a_meta_add_for_an_unknown_contract_throws(): void {
+		foreach ( array( 'add_meta', 'update_meta' ) as $method ) {
+			try {
+				$this->sut->{$method}( 999999, 'note', 'orphan' );
+				$this->fail( "Expected {$method}() to refuse an unknown contract." );
+			} catch ( InvalidArgumentException $e ) {
+				$this->assertSame( array(), $this->sut->get_meta( 999999 ) );
+			}
+		}
+		$this->assertFalse( $this->sut->delete_meta( 999999, 'note' ) );
+	}
+
+	/**
+	 * @testdox A meta write with a lock for another contract is refused.
+	 */
+	public function test_a_meta_write_with_another_contracts_lock_is_refused(): void {
+		$other = $this->insert_released( Contract::create( array( 'extension_slug' => 'acme-subs' ) ) );
+		$lock  = $this->sut->acquire_lock( $other, 'hold' );
+		$this->assertInstanceOf( ContractLock::class, $lock );
+
+		$this->expectException( InvalidArgumentException::class );
+		$this->sut->add_meta( $this->id, 'note', 'one', false, $lock );
+	}
+
+	/**
+	 * @testdox A failed meta write throws.
+	 */
+	public function test_a_failed_meta_write_throws(): void {
+		global $wpdb;
+
+		$this->sut->add_meta( $this->id, 'note', 'one' );
+
+		$break = static function ( string $query ): string {
+			return 1 === preg_match( '/^\s*(UPDATE|DELETE)\s/i', $query ) && false !== strpos( $query, 'contract_meta' ) ? 'SELECT * FROM table_that_does_not_exist_for_tests' : $query;
+		};
+		add_filter( 'query', $break );
+		$suppress = $wpdb->suppress_errors( true );
+		try {
+			foreach ( array( 'update_meta', 'delete_meta' ) as $method ) {
+				try {
+					$this->sut->{$method}( $this->id, 'note', 'two' );
+					$this->fail( "Expected {$method}() to throw." );
+				} catch ( RuntimeException $e ) {
+					$this->assertStringContainsString( 'contract meta', $e->getMessage() );
+				}
+			}
+		} finally {
+			$wpdb->suppress_errors( $suppress );
+			remove_filter( 'query', $break );
+		}
+	}
+
+	private function lock(): ContractLock {
+		$lock = $this->sut->acquire_lock( $this->id, 'hold' );
+		$this->assertInstanceOf( ContractLock::class, $lock );
+
+		return $lock;
+	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
index 22fd0b3330a..010c71c9475 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
@@ -10,7 +10,9 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Storage;

 use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLock;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLockedException;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
@@ -190,7 +192,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	 * @testdox A contract round-trips its live state, children, and config, no cycle graph.
 	 */
 	public function test_contract_round_trips_its_live_state(): void {
-		$id = $this->sut->insert( $this->make_contract() );
+		$id = $this->insert_released( $this->make_contract() );
 		$this->assertGreaterThan( 0, $id );

 		$fetched = $this->sut->find( $id );
@@ -232,7 +234,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	 * @testdox find_summary reads the contract row only, without children.
 	 */
 	public function test_find_summary_reads_the_contract_row_only(): void {
-		$id = $this->sut->insert( $this->make_contract() );
+		$id = $this->insert_released( $this->make_contract() );

 		$summary = $this->sut->find_summary( $id );

@@ -246,11 +248,11 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	 * @testdox find_by_origin_order returns the matching contracts oldest first, with plan terms hydrated.
 	 */
 	public function test_find_by_origin_order_returns_matches_oldest_first(): void {
-		$first  = $this->sut->insert( $this->make_contract() );
-		$second = $this->sut->insert( $this->make_contract() );
+		$first  = $this->insert_released( $this->make_contract() );
+		$second = $this->insert_released( $this->make_contract() );
 		$other  = $this->make_contract();
 		$other->set_origin_order_id( 2002 );
-		$this->sut->insert( $other );
+		$this->insert_released( $other );

 		$first_contract = $this->sut->find( $first );
 		$this->assertInstanceOf( Contract::class, $first_contract );
@@ -290,24 +292,24 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$contract->set_plan_snapshot_id(
 			( new SnapshotStore() )->insert( (int) $contract->get_id(), SnapshotStore::TYPE_PLAN, $plan->get_selling_plan_id(), $plan->to_payload(), $plan->get_schema_version() )
 		);
-		$this->sut->update_fields( $contract, array( 'plan_snapshot_id' ) );
+		$this->update_locked( $contract, array( 'plan_snapshot_id' ) );
 	}

 	/**
-	 * @testdox Meta written through the meta methods survives whole-contract writes.
+	 * @testdox Meta written through the meta methods survives contract writes.
 	 */
 	public function test_meta_survives_a_whole_entity_update(): void {
-		$id = $this->sut->insert( $this->make_contract() );
+		$id = $this->insert_released( $this->make_contract() );
 		$this->sut->add_meta( $id, 'source_channel', 'pdp' );

 		$contract = $this->sut->find( $id );
 		$this->assertInstanceOf( Contract::class, $contract );
 		$contract->set_status( ContractStatus::ON_HOLD );
-		$this->assertTrue( $this->sut->update( $contract ) );
+		$this->assertTrue( $this->update_locked( $contract, array( 'status', 'items', 'addresses' ) ) );
 		$this->assertSame( 'pdp', $this->sut->get_meta( $id, 'source_channel', true ) );

 		$contract->set_status( ContractStatus::ACTIVE );
-		$this->assertTrue( $this->sut->update_fields( $contract, array( 'status' ) ) );
+		$this->assertTrue( $this->update_locked( $contract, array( 'status' ) ) );
 		$this->assertSame( array( 'pdp' ), $this->sut->get_meta( $id, 'source_channel' ) );
 	}

@@ -315,9 +317,9 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	 * @testdox query returns contracts newest first, lightweight, honouring limit/offset.
 	 */
 	public function test_query_orders_newest_first_with_paging(): void {
-		$first  = $this->sut->insert( $this->make_contract() );
-		$second = $this->sut->insert( $this->make_contract() );
-		$third  = $this->sut->insert( $this->make_contract() );
+		$first  = $this->insert_released( $this->make_contract() );
+		$second = $this->insert_released( $this->make_contract() );
+		$third  = $this->insert_released( $this->make_contract() );

 		// Newest first (id DESC), and hydrated lightweight (row only, no children).
 		$all = $this->sut->query();
@@ -357,7 +359,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		string $start = '2026-06-15 00:00:00',
 		?int $origin_order_id = 1001
 	): int {
-		return $this->sut->insert(
+		return $this->insert_released(
 			Contract::create(
 				array(
 					'extension_slug'   => 'engine-tests',
@@ -694,7 +696,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	 * @testdox count_items_by_contract maps every requested id, zero-filling ids with no items.
 	 */
 	public function test_count_items_by_contract_maps_every_requested_id(): void {
-		$with_items = $this->sut->insert( $this->make_contract() ); // Seeds one line item.
+		$with_items = $this->insert_released( $this->make_contract() ); // Seeds one line item.
 		$no_items   = $this->insert_list_contract( ContractStatus::ACTIVE ); // Bare row, no items.
 		$absent     = 999999; // Never inserted.

@@ -714,7 +716,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	 * @testdox A manual/admin contract with a null origin order round-trips.
 	 */
 	public function test_contract_round_trips_a_null_origin_order(): void {
-		$id = $this->sut->insert(
+		$id = $this->insert_released(
 			Contract::create(
 				array(
 					'extension_slug'  => 'engine-tests',
@@ -738,7 +740,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	public function test_a_stored_null_extension_slug_hydrates_as_null(): void {
 		global $wpdb;

-		$id = $this->sut->insert(
+		$id = $this->insert_released(
 			Contract::create(
 				array(
 					'extension_slug'  => 'engine-tests',
@@ -761,17 +763,17 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox update persists the contract-row cache without touching the cycle rows.
+	 * @testdox update_fields persists the named contract columns.
 	 */
-	public function test_update_persists_the_contract_cache(): void {
-		$id = $this->sut->insert( $this->make_contract() );
+	public function test_update_fields_persists_the_contract_row(): void {
+		$id = $this->insert_released( $this->make_contract() );

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

-		$this->assertTrue( $this->sut->update( $contract ) );
+		$this->assertTrue( $this->update_locked( $contract, array( 'status', 'next_payment_gmt' ) ) );

 		$reloaded = $this->sut->find( $id );
 		$this->assertInstanceOf( Contract::class, $reloaded );
@@ -780,59 +782,43 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox update leaves unchanged child rows in place (no churn).
+	 * @testdox A row-only update_fields leaves the item rows in place.
 	 */
-	public function test_update_does_not_churn_unchanged_children(): void {
-		global $wpdb;
-
-		$id = $this->sut->insert( $this->make_contract() );
+	public function test_a_row_only_update_leaves_children_in_place(): void {
+		$id = $this->insert_released( $this->make_contract() );

-		$items_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ITEMS );
-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-		$item_id_before = (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$items_table} WHERE contract_id = %d", $id ) );
+		$item_ids_before = $this->item_ids( $id );

-		// A cache-only update (status) must not delete-and-reinsert the items row.
 		$contract = $this->sut->find( $id );
 		$this->assertInstanceOf( Contract::class, $contract );
 		$contract->set_status( ContractStatus::ON_HOLD );
-		$this->sut->update( $contract );
-
-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-		$item_id_after = (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$items_table} WHERE contract_id = %d", $id ) );
+		$this->update_locked( $contract, array( 'status' ) );

-		$this->assertSame( $item_id_before, $item_id_after, 'An unchanged item set must keep its row id (not be rewritten).' );
+		$this->assertSame( $item_ids_before, $this->item_ids( $id ), 'A row-only write must not rewrite the items.' );
 	}

 	/**
-	 * @testdox update rewrites child rows when they change.
+	 * @testdox update_fields replaces the items when named.
 	 */
-	public function test_update_rewrites_changed_children(): void {
-		$id = $this->sut->insert( $this->make_contract() );
+	public function test_update_fields_replaces_named_children(): void {
+		$id = $this->insert_released( $this->make_contract() );

-		$mutated = Contract::create(
+		$contract = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+		$contract->set_items(
 			array(
-				'extension_slug'  => 'engine-tests',
-				'status'          => ContractStatus::ACTIVE,
-				'customer_id'     => 42,
-				'currency'        => 'USD',
-				'selling_plan_id' => 7,
-				'origin_order_id' => 1001,
-				'start_gmt'       => '2026-06-15 00:00:00',
-				'items'           => array(
-					array(
-						'item_name'  => 'Tea tin',
-						'item_type'  => 'line_item',
-						'product_id' => 300,
-						'quantity'   => '2',
-						'subtotal'   => '24.00',
-						'total'      => '24.00',
-					),
+				array(
+					'item_name'  => 'Tea tin',
+					'item_type'  => 'line_item',
+					'product_id' => 300,
+					'quantity'   => '2',
+					'subtotal'   => '24.00',
+					'total'      => '24.00',
 				),
 			)
 		);
-		$mutated->set_id( $id );

-		$this->assertTrue( $this->sut->update( $mutated ) );
+		$this->assertTrue( $this->update_locked( $contract, array( 'items' ) ) );

 		$reloaded = $this->sut->find( $id );
 		$this->assertInstanceOf( Contract::class, $reloaded );
@@ -842,37 +828,47 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox update throws when the contract has no id.
+	 * @testdox update_fields throws when the contract has no id.
 	 */
-	public function test_update_throws_without_id(): void {
+	public function test_update_fields_throws_without_id(): void {
+		$lock = $this->lock( $this->insert_released( $this->make_contract() ) );
+
 		$this->expectException( \RuntimeException::class );
-		$this->sut->update( $this->make_contract() );
+		$this->sut->update_fields( $this->make_contract(), array( 'status' ), $lock );
 	}

 	/**
-	 * @testdox update rejects a deleted contract and writes no orphan child rows.
+	 * @testdox update_fields refuses the create-only creation key and extension slug.
 	 */
-	public function test_update_rejects_deleted_contract_and_writes_no_orphans(): void {
-		global $wpdb;
-
-		$id = $this->sut->insert( $this->make_contract() );
-		$this->assertTrue( $this->sut->delete( $id ) );
-
-		$stale = $this->make_contract();
-		$stale->set_id( $id );
+	public function test_update_fields_refuses_create_only_fields(): void {
+		$id       = $this->insert_released( $this->make_contract() );
+		$contract = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );

-		try {
-			$this->sut->update( $stale );
-			$this->fail( 'Expected RuntimeException when updating a contract whose row no longer exists.' );
-		} catch ( \RuntimeException $e ) {
-			$this->assertStringContainsString( 'no longer exists', $e->getMessage() );
+		foreach ( array( 'creation_key', 'extension_slug' ) as $field ) {
+			try {
+				$this->update_locked( $contract, array( $field ) );
+				$this->fail( "Expected {$field} to be refused." );
+			} catch ( \InvalidArgumentException $e ) {
+				$this->assertStringContainsString( $field, $e->getMessage() );
+			}
 		}
+	}
+
+	/**
+	 * The item row ids of a contract, oldest first.
+	 *
+	 * @param int $contract_id Contract id.
+	 * @return array<int, int>
+	 */
+	private function item_ids( int $contract_id ): array {
+		global $wpdb;

-		$items_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ITEMS );
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ITEMS );
 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-		$remaining = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$items_table} WHERE contract_id = %d", $id ) );
+		$ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM {$table} WHERE contract_id = %d ORDER BY id ASC", $contract_id ) );

-		$this->assertSame( '0', $remaining );
+		return array_map( 'intval', $ids );
 	}

 	/**
@@ -881,13 +877,14 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	public function test_update_fields_returns_false_for_a_deleted_contract_and_writes_no_children(): void {
 		global $wpdb;

-		$id = $this->sut->insert( $this->make_contract() );
+		$id   = $this->insert_released( $this->make_contract() );
+		$lock = $this->lock( $id );
 		$this->assertTrue( $this->sut->delete( $id ) );

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

-		$this->assertFalse( $this->sut->update_fields( $stale, array( 'status', 'items', 'addresses' ) ) );
+		$this->assertFalse( $this->sut->update_fields( $stale, array( 'status', 'items', 'addresses' ), $lock ) );

 		foreach ( array( SchemaInstaller::TABLE_CONTRACT_ITEMS, SchemaInstaller::TABLE_CONTRACT_ADDRESSES ) as $table ) {
 			$table = SchemaInstaller::get_table_name( $table );
@@ -897,40 +894,32 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox update_fields writing identical values (no changed rows) does not throw.
+	 * @testdox An unlocked Contracts::update writing identical values in the same second returns the view.
 	 */
-	public function test_update_fields_with_identical_values_does_not_throw(): void {
-		global $wpdb;
-
-		$id       = $this->sut->insert( $this->make_contract() );
-		$contract = $this->sut->find( $id );
-		$this->assertInstanceOf( Contract::class, $contract );
+	public function test_an_identical_unlocked_write_returns_the_view(): void {
+		$id = $this->insert_released( $this->make_contract() );

-		// A write in a new second changes date_updated_gmt; repeat until one changes no rows.
-		$zero_rows = false;
-		for ( $i = 0; $i < 3 && ! $zero_rows; $i++ ) {
-			$this->assertTrue( $this->sut->update_fields( $contract, array( 'status', 'next_payment_gmt' ) ) );
-			$zero_rows = 0 === $wpdb->rows_affected;
+		for ( $i = 0; $i < 2; $i++ ) {
+			$this->assertNotNull( Contracts::update( $id, array( 'status' => ContractStatus::ACTIVE ) ) );
 		}

-		$this->assertTrue( $zero_rows, 'A write changed no rows.' );
 		$stored = $this->sut->find( $id );
 		$this->assertInstanceOf( Contract::class, $stored );
 		$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
 	}

 	/**
-	 * @testdox append_cycle inserts a cycle reachable as the chain's current cycle.
+	 * @testdox append_cycle inserts a cycle reachable as the chain's newest cycle.
 	 */
-	public function test_append_cycle_and_find_chain_head(): void {
-		$id = $this->sut->insert( $this->make_contract() );
+	public function test_append_cycle_stores_the_newest_cycle(): void {
+		$id = $this->insert_released( $this->make_contract() );

 		$cycle = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', ...$this->seed_cycle_snapshots( $id ) );
-		$this->sut->append_cycle( $cycle );
+		$this->append_cycle_locked( $cycle );

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

-		$current = $this->sut->find_chain_head( $id );
+		$current = $this->find_newest_cycle( $id );
 		$this->assertInstanceOf( Cycle::class, $current );
 		$this->assertSame( $cycle->get_id(), $current->get_id() );
 		$this->assertSame( 1, $current->get_sequence_no() );
@@ -956,7 +945,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	 * @testdox expected_total round-trips at full DECIMAL(26,8) precision, not just two decimals.
 	 */
 	public function test_expected_total_round_trips_full_decimal_precision(): void {
-		$id = $this->sut->insert( $this->make_contract() );
+		$id = $this->insert_released( $this->make_contract() );

 		// Eight fractional digits: a DECIMAL(26,2) column would truncate this on the
 		// way in, so an exact reload proves the storage scale is (26,8).
@@ -972,47 +961,21 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 				'extension_slug' => 'lite',
 			)
 		);
-		$this->sut->append_cycle( $cycle );
+		$this->append_cycle_locked( $cycle );

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

-	/**
-	 * @testdox find_chain_head returns the highest-sequence cycle in the chain.
-	 */
-	public function test_find_chain_head_returns_the_head(): void {
-		$id = $this->sut->insert( $this->make_contract() );
-
-		$first = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' );
-		$this->sut->append_cycle( $first );
-
-		$second = $this->make_cycle( $id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00' );
-		$this->sut->append_cycle( $second );
-
-		$current = $this->sut->find_chain_head( $id );
-		$this->assertInstanceOf( Cycle::class, $current );
-		$this->assertSame( 2, $current->get_sequence_no() );
-	}
-
-	/**
-	 * @testdox find_chain_head returns null for a chain with no cycles.
-	 */
-	public function test_find_chain_head_is_null_when_empty(): void {
-		$id = $this->sut->insert( $this->make_contract() );
-
-		$this->assertNull( $this->sut->find_chain_head( $id ) );
-	}
-
 	/**
 	 * @testdox find_cycle_history returns a window of cycles newest first.
 	 */
 	public function test_find_cycle_history_pages_newest_first(): void {
-		$id = $this->sut->insert( $this->make_contract() );
+		$id = $this->insert_released( $this->make_contract() );

 		for ( $n = 1; $n <= 3; $n++ ) {
-			$this->sut->append_cycle( $this->make_cycle( $id, $n, $n, sprintf( '2026-%02d-15 00:00:00', 6 + $n ), sprintf( '2026-%02d-15 00:00:00', 7 + $n ) ) );
+			$this->append_cycle_locked( $this->make_cycle( $id, $n, $n, sprintf( '2026-%02d-15 00:00:00', 6 + $n ), sprintf( '2026-%02d-15 00:00:00', 7 + $n ) ) );
 		}

 		$page = $this->sut->find_cycle_history( $id, Cycle::KIND_BILLING, 2, 0 );
@@ -1029,14 +992,14 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	 * @testdox find_cycles_by_order_id returns every cycle linked to an order, newest first.
 	 */
 	public function test_find_cycles_by_order_id(): void {
-		$first_id  = $this->sut->insert( $this->make_contract() );
-		$second_id = $this->sut->insert( $this->make_contract() );
+		$first_id  = $this->insert_released( $this->make_contract() );
+		$second_id = $this->insert_released( $this->make_contract() );

 		// One aggregate order serves a cycle on each of two contracts (not 1:1).
-		$this->sut->append_cycle( $this->make_cycle( $first_id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', null, null, 9090 ) );
-		$this->sut->append_cycle( $this->make_cycle( $second_id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', null, null, 9090 ) );
+		$this->append_cycle_locked( $this->make_cycle( $first_id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', null, null, 9090 ) );
+		$this->append_cycle_locked( $this->make_cycle( $second_id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', null, null, 9090 ) );
 		// A different order on the first contract must not match.
-		$this->sut->append_cycle( $this->make_cycle( $first_id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00', null, null, 7070 ) );
+		$this->append_cycle_locked( $this->make_cycle( $first_id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00', null, null, 7070 ) );

 		$linked = $this->sut->find_cycles_by_order_id( 9090 );
 		$this->assertCount( 2, $linked );
@@ -1051,93 +1014,11 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox A claimed append stamps the cycle claim on the database clock; a transition without a claim releases it.
-	 */
-	public function test_cycle_claimed_until_round_trips(): void {
-		$id = $this->sut->insert( $this->make_contract() );
-
-		// Appended claimed: the INSERT stamps the claim from the database clock.
-		$this->pin_db_clock( '2026-07-15 00:00:00' );
-		$cycle = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' );
-		$this->sut->append_cycle( $cycle, 900 );
-
-		$reloaded = $this->sut->find_chain_head( $id );
-		$this->assertInstanceOf( Cycle::class, $reloaded );
-		$this->assertSame( '2026-07-15 00:15:00', $reloaded->get_claimed_until_gmt() );
-
-		// Cleared by a transition without a claim (a settled cycle holds no claim).
-		$this->assertTrue( $this->sut->transition_cycle( (int) $reloaded->get_id(), CycleStatus::PENDING, CycleStatus::BILLED, array() ) );
-
-		$settled = $this->sut->find_chain_head( $id );
-		$this->assertInstanceOf( Cycle::class, $settled );
-		$this->assertNull( $settled->get_claimed_until_gmt() );
-	}
-
-	/**
-	 * @testdox claim_cycle takes an expired claim once; a second caller loses.
-	 *
-	 * The expiry check and the new claim use the database clock, so the claim is seeded
-	 * relative to real time.
-	 */
-	public function test_claim_cycle_takes_an_expired_claim_once(): void {
-		$id       = $this->sut->insert( $this->make_contract() );
-		$cycle    = $this->append_pending_cycle_with_claim( $id, gmdate( 'Y-m-d H:i:s', time() - 60 ) );
-		$cycle_id = (int) $cycle->get_id();
-
-		$this->assertTrue( $this->sut->claim_cycle( $cycle_id, CycleStatus::PENDING, 900 ), 'The first caller takes the expired claim.' );
-		$this->assertFalse( $this->sut->claim_cycle( $cycle_id, CycleStatus::PENDING, 900 ), 'The second caller finds a live claim.' );
-
-		$reloaded = $this->sut->find_chain_head( $id );
-		$this->assertInstanceOf( Cycle::class, $reloaded );
-		$this->assertNotNull( $reloaded->get_claimed_until_gmt() );
-		$this->assertGreaterThan( time() + 800, strtotime( $reloaded->get_claimed_until_gmt() . ' UTC' ), 'The claim runs a TTL into the future.' );
-	}
-
-	/**
-	 * @testdox claim_cycle takes a pending cycle that has no claim.
-	 */
-	public function test_claim_cycle_takes_a_unclaimed_pending_cycle(): void {
-		$id    = $this->sut->insert( $this->make_contract() );
-		$cycle = $this->append_pending_cycle_with_claim( $id, null );
-
-		$this->assertTrue( $this->sut->claim_cycle( (int) $cycle->get_id(), CycleStatus::PENDING, 900 ) );
-		$this->assertFalse( $this->sut->claim_cycle( (int) $cycle->get_id(), CycleStatus::PENDING, 900 ) );
-	}
-
-	/**
-	 * @testdox claim_cycle refuses a live claim and a cycle that left the status the caller read.
-	 */
-	public function test_claim_cycle_refuses_a_live_claim_and_a_settled_cycle(): void {
-		$id    = $this->sut->insert( $this->make_contract() );
-		$cycle = $this->append_pending_cycle_with_claim( $id, gmdate( 'Y-m-d H:i:s', time() + 600 ) );
-
-		$this->assertFalse( $this->sut->claim_cycle( (int) $cycle->get_id(), CycleStatus::PENDING, 900 ), 'A live claim is held by someone else.' );
-
-		$this->assertTrue( $this->sut->transition_cycle( (int) $cycle->get_id(), CycleStatus::PENDING, CycleStatus::BILLED, array() ) );
-		$this->assertFalse( $this->sut->claim_cycle( (int) $cycle->get_id(), CycleStatus::PENDING, 900 ), 'A cycle no longer in the read status is not claimable.' );
-	}
-
-	/**
-	 * @testdox claim_cycle claims a cycle in whatever status the caller read, not only pending.
-	 */
-	public function test_claim_cycle_keys_on_the_callers_status(): void {
-		$id       = $this->sut->insert( $this->make_contract() );
-		$cycle_id = (int) $this->append_pending_cycle_with_claim( $id, null )->get_id();
-		$this->assertTrue( $this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::FAILED, array() ) );
-
-		$this->assertFalse( $this->sut->claim_cycle( $cycle_id, CycleStatus::PENDING, 900 ), 'A stale read loses.' );
-		$this->assertTrue( $this->sut->claim_cycle( $cycle_id, CycleStatus::FAILED, 900 ), 'The current status wins.' );
-		$this->assertFalse( $this->sut->claim_cycle( $cycle_id, CycleStatus::FAILED, 900 ), 'Only once while the claim is live.' );
-		$this->assertSame( CycleStatus::FAILED, $this->find_cycle( $cycle_id )->get_status()->get_value(), 'Claiming does not change the status.' );
-	}
-
-	/**
-	 * Append a pending cycle 1 carrying a crash-recovery claim, returning it (with its id).
+	 * Append a pending cycle 1, returning it (with its id).
 	 *
-	 * @param int         $contract_id   Contract id.
-	 * @param string|null $claimed_until The claim expiry GMT string to stamp, or null for none.
+	 * @param int $contract_id Contract id.
 	 */
-	private function append_pending_cycle_with_claim( int $contract_id, ?string $claimed_until ): Cycle {
+	private function append_pending_cycle( int $contract_id ): Cycle {
 		$cycle = Cycle::create(
 			array(
 				'contract_id'    => $contract_id,
@@ -1150,59 +1031,11 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 				'currency'       => 'USD',
 			)
 		);
-		$this->sut->append_cycle( $cycle );
-		$this->stamp_cycle_claim( (int) $cycle->get_id(), $claimed_until );
+		$this->append_cycle_locked( $cycle );

 		return $cycle;
 	}

-	/**
-	 * Seed a cycle's stored claim expiry directly (fixtures need claims in the past).
-	 *
-	 * @param int         $cycle_id      Cycle id.
-	 * @param string|null $claimed_until Claim expiry GMT string, or null for none.
-	 */
-	private function stamp_cycle_claim( int $cycle_id, ?string $claimed_until ): void {
-		global $wpdb;
-
-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-		$wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES ), array( 'claimed_until' => $claimed_until ), array( 'id' => $cycle_id ) );
-	}
-
-	/**
-	 * @testdox A claimed append is one INSERT: the claim is stamped in the same statement, with no read or follow-up write.
-	 */
-	public function test_a_claimed_append_is_one_insert(): void {
-		$id = $this->sut->insert( $this->make_contract() );
-
-		$cycles  = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
-		$queries = array();
-		$capture = static function ( string $query ) use ( &$queries ): string {
-			$queries[] = $query;
-			return $query;
-		};
-
-		$cycle = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' );
-		add_filter( 'query', $capture );
-		try {
-			$this->sut->append_cycle( $cycle, 900 );
-		} finally {
-			remove_filter( 'query', $capture );
-		}
-
-		$writes = array_values(
-			array_filter(
-				$queries,
-				static function ( string $query ) use ( $cycles ): bool {
-					return false !== strpos( $query, $cycles ) || false !== stripos( $query, 'UTC_TIMESTAMP' );
-				}
-			)
-		);
-		$this->assertCount( 1, $writes, 'Only the INSERT touches the cycles table or the database clock.' );
-		$this->assertStringStartsWith( "INSERT INTO `{$cycles}`", $writes[0] );
-		$this->assertStringContainsString( 'DATE_ADD( UTC_TIMESTAMP(), INTERVAL 900 SECOND )', $writes[0] );
-	}
-
 	/**
 	 * @testdox find_due returns due contracts whatever their status, oldest first.
 	 */
@@ -1263,7 +1096,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	 */
 	public function test_find_due_skips_an_unlisted_owner_until_it_is_listed(): void {
 		$now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
-		$id  = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_ENGINE, CycleStatus::BILLED, null, null, 'other-ext' );
+		$id  = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_ENGINE, CycleStatus::BILLED, null, 'other-ext' );

 		$this->assertNotContains( $id, $this->due_ids( $now, 50 ) );

@@ -1360,132 +1193,59 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox A live dispatch claim hides the contract from find_due until the claim expires on the database clock.
+	 * @testdox A live lock hides the contract from find_due until it expires on the database clock; an expired lock sorts it behind less recently locked contracts.
 	 */
-	public function test_a_live_claim_hides_the_contract_until_it_expires(): void {
+	public function test_a_live_lock_hides_the_contract_until_it_expires(): void {
 		$now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
-		$id  = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
+		$this->pin_db_clock( '2026-07-01 00:00:00' );
+		$id    = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
+		$later = $this->insert_contract_due_at( '2026-06-20 00:00:00', ContractStatus::ACTIVE );

 		$this->pin_db_clock( '2026-07-15 00:00:00' );
-		$this->assertTrue( $this->sut->claim_contract( $id, '2026-06-15 00:00:00', 900 ) );
+		$this->lock( $id, 'engine_due_dispatch' );

-		$this->pin_db_clock( '2026-07-15 00:14:59' );
-		$this->assertSame( array(), $this->due_ids( $now, 50 ), 'A live claim hides the contract.' );
-		$this->assertSame( array(), $this->due_ids( $now->modify( '+1 day' ), 50 ), 'A later scan cutoff does not expire the claim; the database clock does.' );
+		$this->pin_db_clock( '2026-07-15 00:09:59' );
+		$this->assertSame( array( $later ), $this->due_ids( $now, 50 ), 'A live lock hides the contract.' );
+		$this->assertSame( array( $later ), $this->due_ids( $now->modify( '+1 day' ), 50 ), 'A later scan cutoff does not expire the lock; the database clock does.' );

-		$this->pin_db_clock( '2026-07-15 00:15:00' );
-		$this->assertSame( array( $id ), $this->due_ids( $now, 50 ), 'An expired claim no longer hides it.' );
+		$this->pin_db_clock( '2026-07-15 00:10:00' );
+		$this->assertSame( array( $later, $id ), $this->due_ids( $now, 50 ), 'An expired lock no longer hides it; the less recently locked contract leads.' );
 	}

 	/**
-	 * @testdox claim_contract wins once on the database clock; a second claim on the same due moment loses.
+	 * @testdox An unlocked update_fields writes the contract row in one UPDATE, between the internal lock's acquire and release.
 	 */
-	public function test_claim_contract_wins_once(): void {
-		$id = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
-
-		$this->pin_db_clock( '2035-01-01 00:00:00' );
-		$this->assertTrue( $this->sut->claim_contract( $id, '2026-06-15 00:00:00', 900 ) );
-		$this->assertFalse( $this->sut->claim_contract( $id, '2026-06-15 00:00:00', 900 ), 'An overlapping dispatcher loses.' );
-		$this->assertSame( '2035-01-01 00:15:00', $this->stored_claim( $id ), 'The claim is stamped from the database clock at the write.' );
-
-		$this->pin_db_clock( '2035-01-01 00:15:00' );
-		$this->assertTrue( $this->sut->claim_contract( $id, '2026-06-15 00:00:00', 900 ), 'An expired claim can be taken again.' );
-	}
-
-	/**
-	 * @testdox claim_contract loses when the due moment changed since the scan read it.
-	 */
-	public function test_claim_contract_loses_when_the_due_moment_moved(): void {
-		$id = $this->insert_contract_due_at( '2026-06-20 00:00:00', ContractStatus::ACTIVE );
-
-		$this->assertFalse( $this->sut->claim_contract( $id, '2026-06-15 00:00:00', 900 ) );
-		$this->assertNull( $this->stored_claim( $id ) );
-	}
-
-	/**
-	 * @testdox Moving or clearing next_payment_gmt releases the claim; re-saving it or other writes keep it.
-	 */
-	public function test_due_moment_writes_release_the_claim(): void {
+	public function test_a_contract_write_is_one_update_statement(): void {
 		$id = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );

-		$this->assertTrue( $this->sut->claim_contract( $id, '2026-06-15 00:00:00', 900 ) );
 		$contract = $this->sut->find( $id );
 		$this->assertInstanceOf( Contract::class, $contract );
 		$contract->set_status( ContractStatus::ON_HOLD );
-		$this->sut->update_fields( $contract, array( 'status' ) );
-		$this->assertNotNull( $this->stored_claim( $id ), 'A write that leaves the due moment alone keeps the claim.' );
-
-		Contracts::update( $id, array( 'next_payment_gmt' => '2026-06-15 00:00:00' ) );
-		$this->assertNotNull( $this->stored_claim( $id ), 'Re-saving the same due moment keeps the claim (no queue jump).' );
-
-		$this->sut->update( $contract );
-		$this->assertNotNull( $this->stored_claim( $id ), 'A full update() with the same due moment keeps the claim.' );
-
-		$reformatted = $this->sut->find( $id );
-		$this->assertInstanceOf( Contract::class, $reformatted );
-		$reformatted->set_next_payment_gmt( '2026-06-15T00:00:00' );
-		$this->sut->update_fields( $reformatted, array( 'next_payment_gmt' ) );
-		$this->assertNotNull( $this->stored_claim( $id ), 'The same moment in another DATETIME literal form keeps the claim (MySQL compares DATETIME values).' );
-		$stored = $this->sut->find( $id );
-		$this->assertInstanceOf( Contract::class, $stored );
-		$this->assertSame( '2026-06-15 00:00:00', $stored->get_next_payment_gmt() );
-
-		Contracts::update( $id, array( 'next_payment_gmt' => '2026-06-16 00:00:00' ) );
-		$this->assertNull( $this->stored_claim( $id ), 'Moving the due moment releases the claim.' );
+		$contract->set_next_payment_gmt( null );

-		$this->assertTrue( $this->sut->claim_contract( $id, '2026-06-16 00:00:00', 900 ) );
-		Contracts::update( $id, array( 'next_payment_gmt' => null ) );
-		$this->assertNull( $this->stored_claim( $id ), 'Clearing the due moment releases the claim.' );
+		$lock       = $this->lock( $id );
+		$statements = $this->contract_updates_during(
+			function () use ( $contract, $lock ): void {
+				$this->sut->update_fields( $contract, array( 'status', 'next_payment_gmt' ), $lock );
+			}
+		);

-		Contracts::update( $id, array( 'next_payment_gmt' => '2026-06-15 00:00:00' ) );
-		$this->assertTrue( $this->sut->claim_contract( $id, '2026-06-15 00:00:00', 900 ) );
-		$moved = $this->sut->find( $id );
-		$this->assertInstanceOf( Contract::class, $moved );
-		$moved->set_next_payment_gmt( '2026-07-01 00:00:00' );
-		$this->sut->update( $moved );
-		$this->assertNull( $this->stored_claim( $id ), 'A full update() that moves the due moment releases the claim.' );
+		$this->assertCount( 1, $statements, 'Under a trusted lock: the row write only.' );
+		$this->assertStringContainsString( '`status` = ', $statements[0] );
+		$this->assertStringContainsString( '`next_payment_gmt` = NULL', $statements[0], 'Status and due moment are written in one UPDATE.' );
 		$stored = $this->sut->find( $id );
 		$this->assertInstanceOf( Contract::class, $stored );
-		$this->assertSame( '2026-07-01 00:00:00', $stored->get_next_payment_gmt() );
-	}
-
-	/**
-	 * @testdox update_fields and update each write the contract row in exactly one UPDATE statement.
-	 */
-	public function test_a_contract_write_is_one_update_statement(): void {
-		$id = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
-
-		foreach ( array( 'update_fields', 'update' ) as $write ) {
-			$contract = $this->sut->find( $id );
-			$this->assertInstanceOf( Contract::class, $contract );
-			$on_hold = ContractStatus::ON_HOLD === $contract->get_status();
-			$contract->set_status( $on_hold ? ContractStatus::ACTIVE : ContractStatus::ON_HOLD );
-			$contract->set_next_payment_gmt( $on_hold ? '2026-06-15 00:00:00' : null );
-
-			$statements = $this->contract_updates_during(
-				function () use ( $write, $contract ): void {
-					if ( 'update_fields' === $write ) {
-						$this->sut->update_fields( $contract, array( 'status', 'next_payment_gmt' ) );
-					} else {
-						$this->sut->update( $contract );
-					}
-				}
-			);
-
-			$this->assertCount( 1, $statements, "{$write}() writes status and due moment in one UPDATE." );
-			$stored = $this->sut->find( $id );
-			$this->assertInstanceOf( Contract::class, $stored );
-			$this->assertSame( $contract->get_status(), $stored->get_status() );
-			$this->assertSame( $contract->get_next_payment_gmt(), $stored->get_next_payment_gmt() );
-		}
+		$this->assertSame( ContractStatus::ON_HOLD, $stored->get_status() );
+		$this->assertNull( $stored->get_next_payment_gmt() );
 	}

 	/**
-	 * @testdox When the contract write is lost, neither the status nor the due moment nor the claim changes.
+	 * @testdox When the contract write is lost, neither the status nor the due moment changes, and the lock stays.
 	 */
 	public function test_a_lost_contract_write_changes_neither_status_nor_due_moment(): void {
-		$id = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
-		$this->assertTrue( $this->sut->claim_contract( $id, '2026-06-15 00:00:00', 900 ) );
+		$id   = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
+		$lock = $this->sut->acquire_lock( $id, 'test' );
+		$this->assertNotNull( $lock );

 		$contract = $this->sut->find( $id );
 		$this->assertInstanceOf( Contract::class, $contract );
@@ -1498,7 +1258,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {

 		add_filter( 'query', $drop );
 		try {
-			$this->sut->update_fields( $contract, array( 'status', 'next_payment_gmt' ) );
+			$this->sut->update_fields( $contract, array( 'status', 'next_payment_gmt' ), $lock );
 			$this->fail( 'Expected the lost write to throw.' );
 		} catch ( \RuntimeException $e ) {
 			$this->assertSame( 'Failed to update contract.', $e->getMessage() );
@@ -1510,7 +1270,26 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$this->assertInstanceOf( Contract::class, $stored );
 		$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
 		$this->assertSame( '2026-06-15 00:00:00', $stored->get_next_payment_gmt() );
-		$this->assertNotNull( $this->stored_claim( $id ) );
+		$this->assertNotNull( $this->get_lock_holder( $id )['operation'] );
+	}
+
+	/**
+	 * @testdox Writing next_payment_gmt touches no lock column.
+	 */
+	public function test_due_moment_writes_leave_the_lock_alone(): void {
+		$id   = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
+		$lock = $this->sut->acquire_lock( $id, 'engine_due_dispatch' );
+		$this->assertNotNull( $lock );
+		$before = $this->get_lock_holder( $id );
+
+		$contract = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+		$contract->set_next_payment_gmt( '2026-07-15 00:00:00' );
+		$this->assertTrue( $this->sut->update_fields( $contract, array( 'next_payment_gmt' ), $lock ) );
+		$contract->set_next_payment_gmt( null );
+		$this->assertTrue( $this->sut->update_fields( $contract, array( 'next_payment_gmt' ), $lock ) );
+
+		$this->assertSame( $before, $this->get_lock_holder( $id ) );
 	}

 	/**
@@ -1551,7 +1330,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox An over-long payment method or title is refused by insert, update_fields and update alike, and nothing is stored.
+	 * @testdox An over-long payment method or title is refused by insert and update_fields alike, and nothing is stored.
 	 * @dataProvider provide_over_long_payment_instruments
 	 *
 	 * @param string $method Payment method id.
@@ -1563,41 +1342,35 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$fresh = $this->make_contract();
 		$fresh->set_payment_instrument( $over_long );
 		try {
-			$this->sut->insert( $fresh );
+			$this->insert_released( $fresh );
 			$this->fail( 'Expected insert() to refuse the over-long value.' );
 		} catch ( \RuntimeException $e ) {
 			$this->assertNull( $fresh->get_id(), 'Nothing is inserted.' );
 		}

-		$id = $this->sut->insert( $this->make_contract() );
-		foreach ( array( 'update_fields', 'update' ) as $write ) {
-			$contract = $this->sut->find( $id );
-			$this->assertInstanceOf( Contract::class, $contract );
-			$contract->set_payment_instrument( $over_long );
-			$contract->set_status( ContractStatus::ON_HOLD );
-
-			try {
-				if ( 'update_fields' === $write ) {
-					$this->sut->update_fields( $contract, array( 'status', 'payment_method', 'payment_method_title' ) );
-				} else {
-					$this->sut->update( $contract );
-				}
-				$this->fail( "Expected {$write}() to refuse the over-long value." );
-			} catch ( \RuntimeException $e ) {
-				$this->assertStringContainsString( 'longer than the column allows', $e->getMessage() );
-			}
+		$id       = $this->insert_released( $this->make_contract() );
+		$contract = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+		$contract->set_payment_instrument( $over_long );
+		$contract->set_status( ContractStatus::ON_HOLD );

-			$stored = $this->sut->find( $id );
-			$this->assertInstanceOf( Contract::class, $stored );
-			$this->assertSame( 'woocommerce_payments', $stored->get_payment_instrument()->get_gateway(), "{$write}() wrote nothing." );
-			$this->assertSame( 'Credit card', $stored->get_payment_instrument()->get_title() );
-			$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
+		try {
+			$this->update_locked( $contract, array( 'status', 'payment_method', 'payment_method_title' ) );
+			$this->fail( 'Expected update_fields() to refuse the over-long value.' );
+		} catch ( \RuntimeException $e ) {
+			$this->assertStringContainsString( 'longer than the column allows', $e->getMessage() );
 		}

+		$stored = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $stored );
+		$this->assertSame( 'woocommerce_payments', $stored->get_payment_instrument()->get_gateway(), 'update_fields() wrote nothing.' );
+		$this->assertSame( 'Credit card', $stored->get_payment_instrument()->get_title() );
+		$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
+
 		$fits = $this->sut->find( $id );
 		$this->assertInstanceOf( Contract::class, $fits );
 		$fits->set_payment_instrument( new PaymentInstrumentRef( 55, str_repeat( 'm', 100 ), str_repeat( 'é', 200 ) ) );
-		$this->sut->update_fields( $fits, array( 'payment_method', 'payment_method_title' ) );
+		$this->update_locked( $fits, array( 'payment_method', 'payment_method_title' ) );
 		$written = $this->sut->find( $id );
 		$this->assertInstanceOf( Contract::class, $written );
 		$this->assertSame( str_repeat( 'é', 200 ), $written->get_payment_instrument()->get_title(), 'A value at the column length (in characters) is written.' );
@@ -1607,11 +1380,11 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	 * @testdox An over-long cycle kind is refused by append_cycle and nothing is stored; one at the column length is written.
 	 */
 	public function test_an_over_long_cycle_kind_is_refused_on_append(): void {
-		$id = $this->sut->insert( $this->make_contract() );
+		$id = $this->insert_released( $this->make_contract() );

 		$over_long = $this->make_cycle_of_kind( $id, str_repeat( 'k', 21 ) );
 		try {
-			$this->sut->append_cycle( $over_long );
+			$this->append_cycle_locked( $over_long );
 			$this->fail( 'Expected append_cycle() to refuse the over-long kind.' );
 		} catch ( \RuntimeException $e ) {
 			$this->assertStringContainsString( 'longer than the column allows', $e->getMessage() );
@@ -1620,7 +1393,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$this->assertSame( array(), $this->sut->find_cycle_history( $id, str_repeat( 'k', 21 ) ) );

 		$fits = $this->make_cycle_of_kind( $id, str_repeat( 'k', 20 ) );
-		$this->sut->append_cycle( $fits );
+		$this->append_cycle_locked( $fits );
 		$this->assertNotNull( $fits->get_id(), 'A kind at the column length is written.' );
 	}

@@ -1634,36 +1407,19 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		);
 	}

-	/**
-	 * The stored due-dispatch claim of a contract.
-	 *
-	 * @param int $id Contract id.
-	 */
-	private function stored_claim( int $id ): ?string {
-		global $wpdb;
-
-		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
-
-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-		$claim = $wpdb->get_var( $wpdb->prepare( "SELECT claimed_until FROM {$table} WHERE id = %d", $id ) );
-
-		return null === $claim ? null : (string) $claim;
-	}
-
 	/**
 	 * @testdox transition_cycle is a compare-and-set: one caller wins, a caller that read the old status loses.
 	 */
 	public function test_transition_cycle_is_an_atomic_compare_and_set(): void {
-		$cycle_id = $this->claimd_pending_cycle_id();
+		$cycle_id = $this->pending_cycle_id();

-		$this->assertTrue( $this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::BILLED, array( 'order_id' => 4242 ) ) );
+		$this->assertTrue( $this->transition_cycle_row_locked( $cycle_id, CycleStatus::PENDING, CycleStatus::BILLED, array( 'order_id' => 4242 ) ) );

 		$settled = $this->find_cycle( $cycle_id );
 		$this->assertSame( CycleStatus::BILLED, $settled->get_status()->get_value() );
 		$this->assertSame( 4242, $settled->get_order_id() );
-		$this->assertNull( $settled->get_claimed_until_gmt(), 'Without claim_seconds the claim is cleared.' );

-		$this->assertFalse( $this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::FAILED, array( 'order_id' => 9999 ) ) );
+		$this->assertFalse( $this->transition_cycle_row_locked( $cycle_id, CycleStatus::PENDING, CycleStatus::FAILED, array( 'order_id' => 9999 ) ) );

 		$after = $this->find_cycle( $cycle_id );
 		$this->assertSame( CycleStatus::BILLED, $after->get_status()->get_value(), 'The losing transition writes nothing.' );
@@ -1674,34 +1430,71 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	 * @testdox A same-status transition_cycle that changes nothing still returns true; on a cycle in another status it returns false.
 	 */
 	public function test_a_same_status_transition_that_changes_nothing_returns_true(): void {
-		$cycle_id = $this->claimd_pending_cycle_id();
+		$cycle_id = $this->pending_cycle_id();

 		// Start early in a second so both calls stamp the same date_updated_gmt and the
 		// second one matches the row without changing it.
 		while ( fmod( microtime( true ), 1.0 ) > 0.5 ) {
 			usleep( 50000 );
 		}
-		$this->assertTrue( $this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::PENDING, array( 'order_id' => 4242 ) ) );
-		$this->assertTrue( $this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::PENDING, array( 'order_id' => 4242 ) ), 'Writing the stored values again still succeeds.' );
+		$this->assertTrue( $this->transition_cycle_row_locked( $cycle_id, CycleStatus::PENDING, CycleStatus::PENDING, array( 'order_id' => 4242 ) ) );
+		$this->assertTrue( $this->transition_cycle_row_locked( $cycle_id, CycleStatus::PENDING, CycleStatus::PENDING, array( 'order_id' => 4242 ) ), 'Writing the stored values again still succeeds.' );

-		$this->assertFalse( $this->sut->transition_cycle( $cycle_id, CycleStatus::BILLED, CycleStatus::BILLED, array( 'order_id' => 4242 ) ), 'A cycle in another status is not matched.' );
+		$this->assertFalse( $this->transition_cycle_row_locked( $cycle_id, CycleStatus::BILLED, CycleStatus::BILLED, array( 'order_id' => 4242 ) ), 'A cycle in another status is not matched.' );
 		$this->assertSame( CycleStatus::PENDING, $this->find_cycle( $cycle_id )->get_status()->get_value() );
 	}

+	/**
+	 * @testdox A same-status transition that writes nothing is a no-op win only while the cycle holds every value: $label.
+	 * @dataProvider provide_same_status_values
+	 *
+	 * @param array<string, mixed> $stored  Values the cycle holds.
+	 * @param array<string, mixed> $changes Values the transition passes.
+	 * @param bool                 $holds   Whether the stored values already hold.
+	 * @param string               $label   Case label.
+	 */
+	public function test_a_same_status_transition_that_writes_nothing_compares_the_values( array $stored, array $changes, bool $holds, string $label ): void {
+		$cycle_id = $this->pending_cycle_id();
+		$this->assertTrue( $this->transition_cycle_row_locked( $cycle_id, CycleStatus::PENDING, CycleStatus::PENDING, $stored ) );
+
+		$cycles = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+		$this->refuse_next_write( "UPDATE {$cycles} SET" );
+		$this->assertSame( $holds, $this->transition_cycle_row_locked( $cycle_id, CycleStatus::PENDING, CycleStatus::PENDING, $changes ), $label );
+	}
+
+	/**
+	 * @return array<string, array{0: array<string, mixed>, 1: array<string, mixed>, 2: bool, 3: string}>
+	 */
+	public function provide_same_status_values(): array {
+		$order  = array( 'order_id' => 4242 );
+		$reason = array( 'reason' => 'payment-failed' );
+
+		return array(
+			'other order_id'        => array( $order, array( 'order_id' => 5151 ), false, 'another order_id' ),
+			'stored order_id'       => array( $order, $order, true, 'the stored order_id' ),
+			'other reason'          => array( $reason, array( 'reason' => 'card-expired' ), false, 'another reason' ),
+			'stored reason'         => array( $reason, $reason, true, 'the stored reason' ),
+			'null against an order' => array( $order, array( 'order_id' => null ), false, 'null against a stored order_id' ),
+			'null against null'     => array( array( 'order_id' => null ), array( 'order_id' => null ), true, 'null against a cleared order_id' ),
+			'null against a reason' => array( $reason, array( 'reason' => null ), false, 'null against a stored reason' ),
+			'omitted keys'          => array( $order + $reason, array(), true, 'no values passed' ),
+		);
+	}
+
 	/**
 	 * @testdox transition_cycle keeps the stored order when order_id is omitted and clears it on null.
 	 */
 	public function test_transition_cycle_order_id_is_written_only_when_present(): void {
-		$cycle_id = $this->claimd_pending_cycle_id();
-		$this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::PENDING, array( 'order_id' => 4242 ) );
+		$cycle_id = $this->pending_cycle_id();
+		$this->transition_cycle_row_locked( $cycle_id, CycleStatus::PENDING, CycleStatus::PENDING, array( 'order_id' => 4242 ) );

-		$this->assertTrue( $this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::FAILED, array( 'reason' => 'payment-failed' ) ) );
+		$this->assertTrue( $this->transition_cycle_row_locked( $cycle_id, CycleStatus::PENDING, CycleStatus::FAILED, array( 'reason' => 'payment-failed' ) ) );
 		$failed = $this->find_cycle( $cycle_id );
 		$this->assertSame( 4242, $failed->get_order_id(), 'An omitted order_id keeps the stored one.' );
 		$this->assertSame( 'payment-failed', $failed->get_reason() );

 		$this->assertTrue(
-			$this->sut->transition_cycle(
+			$this->transition_cycle_row_locked(
 				$cycle_id,
 				CycleStatus::FAILED,
 				CycleStatus::CANCELLED,
@@ -1716,30 +1509,14 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$this->assertNull( $cancelled->get_reason(), 'A null reason clears it.' );
 	}

-	/**
-	 * @testdox transition_cycle sets a claim from claim_seconds, e.g. retrying a failed cycle.
-	 */
-	public function test_transition_cycle_sets_a_claim(): void {
-		$cycle_id = $this->claimd_pending_cycle_id();
-		$this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::FAILED, array() );
-
-		$this->assertTrue( $this->sut->transition_cycle( $cycle_id, CycleStatus::FAILED, CycleStatus::PENDING, array( 'claim_seconds' => 900 ) ) );
-
-		$retried = $this->find_cycle( $cycle_id );
-		$this->assertSame( CycleStatus::PENDING, $retried->get_status()->get_value() );
-		$this->assertNotNull( $retried->get_claimed_until_gmt() );
-		$this->assertGreaterThan( time() + 800, strtotime( $retried->get_claimed_until_gmt() . ' UTC' ) );
-		$this->assertFalse( $this->sut->claim_cycle( $cycle_id, CycleStatus::PENDING, 900 ), 'The retrier holds the claim.' );
-	}
-
 	/**
 	 * @testdox transition_cycle rejects an unregistered target status and writes nothing.
 	 */
 	public function test_transition_cycle_rejects_an_unregistered_target(): void {
-		$cycle_id = $this->claimd_pending_cycle_id();
+		$cycle_id = $this->pending_cycle_id();

 		try {
-			$this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, 'never-registered', array( 'order_id' => 4242 ) );
+			$this->transition_cycle_row_locked( $cycle_id, CycleStatus::PENDING, 'never-registered', array( 'order_id' => 4242 ) );
 			$this->fail( 'Expected a DomainException for an unregistered target status.' );
 		} catch ( \DomainException $e ) {
 			$this->assertSame( CycleStatus::PENDING, $this->find_cycle( $cycle_id )->get_status()->get_value() );
@@ -1751,9 +1528,9 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	 */
 	public function test_transition_cycle_accepts_an_extension_registered_target(): void {
 		StatusRegistry::register( StatusRegistry::KIND_CYCLE, 'disputed' );
-		$cycle_id = $this->claimd_pending_cycle_id();
+		$cycle_id = $this->pending_cycle_id();

-		$this->assertTrue( $this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, 'disputed', array() ) );
+		$this->assertTrue( $this->transition_cycle_row_locked( $cycle_id, CycleStatus::PENDING, 'disputed', array() ) );

 		$this->assertSame( 'disputed', $this->find_cycle( $cycle_id )->get_status()->get_value() );
 	}
@@ -1762,7 +1539,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	 * @testdox A billed cycle hydrates its plan and items snapshots.
 	 */
 	public function test_a_billed_cycle_hydrates_its_snapshots(): void {
-		$id           = $this->sut->insert( $this->make_contract() );
+		$id           = $this->insert_released( $this->make_contract() );
 		$snapshot_ids = $this->seed_cycle_snapshots( $id );
 		$cycle        = Cycle::create(
 			array(
@@ -1778,21 +1555,32 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 				'items_snapshot_id' => $snapshot_ids[1],
 			)
 		);
-		$this->sut->append_cycle( $cycle );
+		$this->append_cycle_locked( $cycle );

-		$head = $this->sut->find_chain_head( $id );
+		$head = $this->find_newest_cycle( $id );
 		$this->assertInstanceOf( Cycle::class, $head );
 		$this->assertInstanceOf( PlanSnapshot::class, $head->get_plan_snapshot() );
 		$this->assertInstanceOf( ItemsSnapshot::class, $head->get_items_snapshot() );
 	}

 	/**
-	 * A pending cycle 1 with an expired claim on a new contract.
+	 * A pending cycle 1 on a new contract.
+	 */
+	private function pending_cycle_id(): int {
+		$id = $this->insert_released( $this->make_contract() );
+
+		return (int) $this->append_pending_cycle( $id )->get_id();
+	}
+
+	/**
+	 * The newest billing cycle of a contract, or null when its chain is empty.
+	 *
+	 * @param int $contract_id Contract id.
 	 */
-	private function claimd_pending_cycle_id(): int {
-		$id = $this->sut->insert( $this->make_contract() );
+	private function find_newest_cycle( int $contract_id ): ?Cycle {
+		$history = $this->sut->find_cycle_history( $contract_id, Cycle::KIND_BILLING, 1 );

-		return (int) $this->append_pending_cycle_with_claim( $id, gmdate( 'Y-m-d H:i:s', time() - 60 ) )->get_id();
+		return $history[0] ?? null;
 	}

 	/**
@@ -1822,7 +1610,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	public function test_an_unknown_stored_contract_status_round_trips(): void {
 		global $wpdb;

-		$id    = $this->sut->insert( $this->make_contract() );
+		$id    = $this->insert_released( $this->make_contract() );
 		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
 		$wpdb->update( $table, array( 'status' => 'legacy-paused' ), array( 'id' => $id ) );
@@ -1832,7 +1620,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$this->assertSame( 'legacy-paused', $contract->get_status() );

 		$contract->set_next_payment_gmt( '2026-09-15 00:00:00' );
-		$this->assertTrue( $this->sut->update( $contract ) );
+		$this->assertTrue( $this->update_locked( $contract, array( 'status', 'next_payment_gmt' ) ) );

 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
 		$stored = $wpdb->get_row( $wpdb->prepare( "SELECT status, next_payment_gmt FROM {$table} WHERE id = %d", $id ), ARRAY_A );
@@ -1846,15 +1634,15 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	public function test_an_unknown_stored_cycle_status_hydrates_through_every_cycle_read(): void {
 		global $wpdb;

-		$id    = $this->sut->insert( $this->make_contract() );
+		$id    = $this->insert_released( $this->make_contract() );
 		$cycle = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', ...$this->seed_cycle_snapshots( $id ) );
-		$this->sut->append_cycle( $cycle );
+		$this->append_cycle_locked( $cycle );

 		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
 		$wpdb->update( $table, array( 'status' => 'legacy-x' ), array( 'id' => $cycle->get_id() ) );

-		$head = $this->sut->find_chain_head( $id );
+		$head = $this->find_newest_cycle( $id );
 		$this->assertInstanceOf( Cycle::class, $head );
 		$this->assertSame( 'legacy-x', $head->get_status()->get_value() );

@@ -1902,7 +1690,6 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	 * @param string      $status           The contract status (a ContractStatus value).
 	 * @param string      $schedule_source  Who keeps the schedule (the engine, or a gateway/extension slug).
 	 * @param string      $head_status      The head cycle status (a CycleStatus value).
-	 * @param string|null $claimed_until    The head cycle claim expiry, or null for none.
 	 * @param string|null $head_ends_at     The head period end; defaults to `$next_payment_gmt`.
 	 * @param string      $owner            The owning extension slug; defaults to the registered test owner.
 	 */
@@ -1911,7 +1698,6 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		string $status,
 		string $schedule_source = Contract::SCHEDULE_SOURCE_ENGINE,
 		string $head_status = CycleStatus::BILLED,
-		?string $claimed_until = null,
 		?string $head_ends_at = null,
 		string $owner = self::OWNER
 	): int {
@@ -1928,10 +1714,10 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 				'extension_slug'   => $owner,
 			)
 		);
-		$id       = $this->sut->insert( $contract );
+		$id       = $this->insert_released( $contract );

 		if ( null !== $next_payment_gmt ) {
-			$this->sut->append_cycle(
+			$this->append_cycle_locked(
 				Cycle::create(
 					array(
 						'contract_id'    => $id,
@@ -1945,11 +1731,6 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 					)
 				)
 			);
-			if ( null !== $claimed_until ) {
-				$head = $this->sut->find_chain_head( $id );
-				$this->assertInstanceOf( Cycle::class, $head );
-				$this->stamp_cycle_claim( (int) $head->get_id(), $claimed_until );
-			}
 		}

 		return $id;
@@ -1961,8 +1742,8 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	public function test_duplicate_sequence_no_is_rejected(): void {
 		global $wpdb;

-		$id = $this->sut->insert( $this->make_contract() );
-		$this->sut->append_cycle( $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' ) );
+		$id = $this->insert_released( $this->make_contract() );
+		$this->append_cycle_locked( $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' ) );

 		$cycles_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
 		$now          = gmdate( 'Y-m-d H:i:s' );
@@ -1994,8 +1775,8 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	public function test_duplicate_count_is_rejected(): void {
 		global $wpdb;

-		$id = $this->sut->insert( $this->make_contract() );
-		$this->sut->append_cycle( $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' ) );
+		$id = $this->insert_released( $this->make_contract() );
+		$this->append_cycle_locked( $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' ) );

 		$cycles_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
 		$now          = gmdate( 'Y-m-d H:i:s' );
@@ -2025,15 +1806,15 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	/**
 	 * @testdox append_cycle surfaces a UNIQUE collision as DuplicateCycleException.
 	 *
-	 * The create-as-claim race signal must be distinguishable from any other write failure,
+	 * The append race signal must be distinguishable from any other write failure,
 	 * so the money-path can treat the collision as benign without masking real errors.
 	 */
 	public function test_append_cycle_throws_duplicate_cycle_exception_on_collision(): void {
-		$id = $this->sut->insert( $this->make_contract() );
-		$this->sut->append_cycle( $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' ) );
+		$id = $this->insert_released( $this->make_contract() );
+		$this->append_cycle_locked( $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' ) );

 		$this->expectException( DuplicateCycleException::class );
-		$this->sut->append_cycle( $this->make_cycle( $id, 2, 1, '2026-08-15 00:00:00', '2026-09-15 00:00:00' ) );
+		$this->append_cycle_locked( $this->make_cycle( $id, 2, 1, '2026-08-15 00:00:00', '2026-09-15 00:00:00' ) );
 	}

 	/**
@@ -2060,14 +1841,14 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	 * @testdox append_cycle assigns sequence 1 to the first unpositioned cycle and leaves the count alone.
 	 */
 	public function test_append_cycle_assigns_the_first_sequence_no(): void {
-		$id    = $this->sut->insert( $this->make_contract() );
+		$id    = $this->insert_released( $this->make_contract() );
 		$cycle = $this->make_unpositioned_cycle( $id );

-		$this->sut->append_cycle( $cycle );
+		$this->append_cycle_locked( $cycle );

 		$this->assertSame( 1, $cycle->get_sequence_no() );
 		$this->assertNull( $cycle->get_count() );
-		$head = $this->sut->find_chain_head( $id );
+		$head = $this->find_newest_cycle( $id );
 		$this->assertInstanceOf( Cycle::class, $head );
 		$this->assertSame( $cycle->get_id(), $head->get_id() );
 	}
@@ -2076,30 +1857,74 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	 * @testdox append_cycle assigns the head's sequence + 1 within the cycle's own chain.
 	 */
 	public function test_append_cycle_assigns_the_next_sequence_no_in_its_chain(): void {
-		$id = $this->sut->insert( $this->make_contract() );
-		$this->sut->append_cycle( $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' ) );
-		$this->sut->append_cycle( $this->make_cycle( $id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00' ) );
+		$id = $this->insert_released( $this->make_contract() );
+		$this->append_cycle_locked( $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' ) );
+		$this->append_cycle_locked( $this->make_cycle( $id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00' ) );

 		$next  = $this->make_unpositioned_cycle( $id, array( 'count' => 3 ) );
 		$other = $this->make_unpositioned_cycle( $id, array( 'kind' => 'shipping' ) );
-		$this->sut->append_cycle( $next );
-		$this->sut->append_cycle( $other );
+		$this->append_cycle_locked( $next );
+		$this->append_cycle_locked( $other );

 		$this->assertSame( 3, $next->get_sequence_no() );
 		$this->assertSame( 3, $next->get_count() );
 		$this->assertSame( 1, $other->get_sequence_no() );
 	}

+	/**
+	 * @testdox Appends without a sequence_no take the next position: one read of the position, then the insert.
+	 */
+	public function test_append_without_a_sequence_no_takes_the_next_position(): void {
+		global $wpdb;
+
+		$id = $this->insert_released( $this->make_contract() );
+
+		$first = $this->make_unpositioned_cycle( $id );
+		$this->append_cycle_locked( $first );
+
+		$lock   = $this->lock( $id );
+		$second = $this->make_unpositioned_cycle( $id );
+		$this->sut->append_cycle( $second, $lock );
+
+		$third          = $this->make_unpositioned_cycle( $id );
+		$queries        = $wpdb->num_queries;
+		$statements     = $this->statements_during(
+			function () use ( $third, $lock ): void {
+				$this->sut->append_cycle( $third, $lock );
+			}
+		);
+		$append_queries = $wpdb->num_queries - $queries;
+		$lock->release();
+		$this->assert_no_self_table_insert_select( $statements, SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES ) );
+
+		$this->assertSame( array( 1, 2, 3 ), array( $first->get_sequence_no(), $second->get_sequence_no(), $third->get_sequence_no() ) );
+		$this->assertSame( 2, $append_queries, 'Under a trusted lock: the position read, then the insert.' );
+		$head = $this->find_newest_cycle( $id );
+		$this->assertInstanceOf( Cycle::class, $head );
+		$this->assertSame( $third->get_id(), $head->get_id() );
+	}
+
+	/**
+	 * @testdox A failed contract read throws instead of reading as missing.
+	 */
+	public function test_failed_contract_read_throws(): void {
+		$id = $this->insert_released( $this->make_contract() );
+
+		$this->fail_next_query( '/^\s*SELECT\s+\*,\s*\(\s*lock_token/i' );
+		$this->expectException( \RuntimeException::class );
+		$this->sut->find( $id );
+	}
+
 	/**
 	 * @testdox Multiple non-counting cycles (count = null) coexist in one chain.
 	 */
 	public function test_multiple_null_count_cycles_coexist(): void {
-		$id = $this->sut->insert( $this->make_contract() );
+		$id = $this->insert_released( $this->make_contract() );

 		// MySQL treats NULLs as distinct, so two count = null cycles do not collide
 		// under UNIQUE(contract_id, kind, count).
-		$this->sut->append_cycle( $this->make_cycle( $id, 1, null, '2026-07-15 00:00:00', '2026-08-15 00:00:00' ) );
-		$this->sut->append_cycle( $this->make_cycle( $id, 2, null, '2026-08-15 00:00:00', '2026-09-15 00:00:00' ) );
+		$this->append_cycle_locked( $this->make_cycle( $id, 1, null, '2026-07-15 00:00:00', '2026-08-15 00:00:00' ) );
+		$this->append_cycle_locked( $this->make_cycle( $id, 2, null, '2026-08-15 00:00:00', '2026-09-15 00:00:00' ) );

 		$history = $this->sut->find_cycle_history( $id );
 		$this->assertCount( 2, $history );
@@ -2113,8 +1938,8 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 	public function test_delete_removes_contract_children_cycles_and_snapshots(): void {
 		global $wpdb;

-		$id = $this->sut->insert( $this->make_contract() );
-		$this->sut->append_cycle( $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', ...$this->seed_cycle_snapshots( $id ) ) );
+		$id = $this->insert_released( $this->make_contract() );
+		$this->append_cycle_locked( $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', ...$this->seed_cycle_snapshots( $id ) ) );

 		$this->assertTrue( $this->sut->delete( $id ) );
 		$this->assertNull( $this->sut->find( $id ) );
@@ -2130,4 +1955,662 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 			$this->assertSame( '0', $remaining, "Rows must be removed from {$table} when the contract is deleted." );
 		}
 	}
+
+	/**
+	 * Acquire a lock, asserting it was granted.
+	 *
+	 * @param int    $contract_id Contract id.
+	 * @param string $operation   Operation label.
+	 */
+	private function lock( int $contract_id, string $operation = 'test' ): ContractLock {
+		$lock = $this->sut->acquire_lock( $contract_id, $operation );
+		$this->assertInstanceOf( ContractLock::class, $lock );
+
+		return $lock;
+	}
+
+	/**
+	 * `update_fields()` under a lock taken for the call and released after it.
+	 *
+	 * @param Contract           $contract Stored contract carrying the values.
+	 * @param array<int, string> $fields   Fields to write.
+	 */
+	private function update_locked( Contract $contract, array $fields ): bool {
+		$lock = $this->lock( (int) $contract->get_id() );
+		try {
+			return $this->sut->update_fields( $contract, $fields, $lock );
+		} finally {
+			$lock->release();
+		}
+	}
+
+	/**
+	 * Run `$run` with the next query matching `$pattern` replaced by invalid SQL.
+	 *
+	 * @param string   $pattern Regex the statement to break must match.
+	 * @param callable $run     Code under test.
+	 * @param int      $skip    Matching statements to let through first.
+	 */
+	private function with_failing_query( string $pattern, callable $run, int $skip = 0 ): void {
+		global $wpdb;
+
+		$broken = false;
+		$break  = static function ( string $query ) use ( $pattern, &$broken, &$skip ): string {
+			if ( ! $broken && 1 === preg_match( $pattern, $query ) && 0 === $skip-- ) {
+				$broken = true;
+				return 'SELECT * FROM table_that_does_not_exist_for_tests';
+			}
+			return $query;
+		};
+
+		add_filter( 'query', $break );
+		$suppress = $wpdb->suppress_errors( true );
+		try {
+			$run();
+		} finally {
+			$wpdb->suppress_errors( $suppress );
+			remove_filter( 'query', $break );
+		}
+		$this->assertTrue( $broken, 'The targeted statement ran.' );
+	}
+
+	/**
+	 * A stored contract with its status changed in memory.
+	 *
+	 * @param int    $id     Contract id.
+	 * @param string $status New status.
+	 */
+	private function with_status( int $id, string $status ): Contract {
+		$contract = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+		$contract->set_status( $status );
+
+		return $contract;
+	}
+
+	/**
+	 * The stored status of a contract.
+	 *
+	 * @param int $id Contract id.
+	 */
+	private function stored_status( int $id ): string {
+		$contract = $this->sut->find_summary( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+
+		return $contract->get_status();
+	}
+
+	/**
+	 * @testdox A row write with the live lock lands; without a lock it is refused while the lock lives and lands after.
+	 */
+	public function test_contract_row_writes_are_fenced(): void {
+		$id = $this->insert_released( $this->make_contract() );
+		$this->pin_db_clock( '2035-01-01 00:00:00' );
+		$lock = $this->lock( $id, 'hold' );
+
+		$this->assertTrue( $this->sut->update_fields( $this->with_status( $id, ContractStatus::ON_HOLD ), array( 'status' ), $lock ) );
+		$this->assertSame( ContractStatus::ON_HOLD, $this->stored_status( $id ) );
+
+		try {
+			Contracts::update( $id, array( 'status' => ContractStatus::CANCELLED ) );
+			$this->fail( 'Expected an unlocked write to be refused while the lock lives.' );
+		} catch ( ContractLockedException $e ) {
+			$this->assertSame( 'hold', $e->get_operation() );
+		}
+		$this->assertSame( ContractStatus::ON_HOLD, $this->stored_status( $id ) );
+
+		$this->pin_db_clock( '2035-01-01 00:10:00' );
+		$this->assertNotNull( Contracts::update( $id, array( 'status' => ContractStatus::CANCELLED ) ) );
+		$this->assertSame( ContractStatus::CANCELLED, $this->stored_status( $id ) );
+
+		try {
+			$this->sut->update_fields( $this->with_status( $id, ContractStatus::ACTIVE ), array( 'status' ), $lock );
+			$this->fail( 'Expected the expired lock to be refused.' );
+		} catch ( ContractLockedException $e ) {
+			$this->assertNull( $e->get_operation(), 'No live holder.' );
+		}
+		$this->assertSame( ContractStatus::CANCELLED, $this->stored_status( $id ) );
+	}
+
+	/**
+	 * @testdox A lock for another contract is refused before any write.
+	 */
+	public function test_a_lock_for_another_contract_is_refused(): void {
+		$id    = $this->insert_released( $this->make_contract() );
+		$other = $this->insert_released( $this->make_contract() );
+		$lock  = $this->lock( $other );
+
+		$this->expectException( \InvalidArgumentException::class );
+		$this->sut->update_fields( $this->with_status( $id, ContractStatus::ON_HOLD ), array( 'status' ), $lock );
+	}
+
+	/**
+	 * @testdox An identical same-second write with the lock still changes the row (write_seq) and returns true.
+	 */
+	public function test_an_identical_locked_write_still_changes_the_row(): void {
+		global $wpdb;
+
+		$id       = $this->insert_released( $this->make_contract() );
+		$lock     = $this->lock( $id );
+		$contract = $this->with_status( $id, ContractStatus::ACTIVE );
+
+		for ( $i = 0; $i < 2; $i++ ) {
+			$this->assertTrue( $this->sut->update_fields( $contract, array( 'status' ), $lock ) );
+			$this->assertSame( 1, $wpdb->rows_affected, 'A matched row always counts as changed.' );
+		}
+	}
+
+	/**
+	 * @testdox A write to a deleted contract returns false with and without a lock.
+	 */
+	public function test_a_missing_row_returns_false(): void {
+		$id       = $this->insert_released( $this->make_contract() );
+		$contract = $this->with_status( $id, ContractStatus::ON_HOLD );
+		$lock     = $this->lock( $id );
+		$this->sut->delete( $id );
+
+		$this->assertFalse( $this->sut->update_fields( $contract, array( 'status' ), $lock ) );
+		$this->assertNull( Contracts::update( $id, array( 'status' => ContractStatus::ON_HOLD ) ), 'Unlocked, the update reads the contract as missing.' );
+	}
+
+	/**
+	 * @testdox An unlocked items replacement runs under an internal lock: refused while another lock lives, released after.
+	 */
+	public function test_an_unlocked_children_write_takes_an_internal_lock(): void {
+		$id    = $this->insert_released( $this->make_contract() );
+		$items = $this->item_ids( $id );
+		$lock  = $this->lock( $id, 'hold' );
+
+		$args = array(
+			'status' => ContractStatus::ON_HOLD,
+			'items'  => $this->two_items(),
+		);
+		try {
+			Contracts::update( $id, $args );
+			$this->fail( 'Expected the write to be refused.' );
+		} catch ( ContractLockedException $e ) {
+			$this->assertSame( 'hold', $e->get_operation() );
+		}
+		$this->assertSame( $items, $this->item_ids( $id ), 'Nothing is written.' );
+		$this->assertSame( ContractStatus::ACTIVE, $this->stored_status( $id ) );
+
+		$lock->release();
+		$this->assertNotNull( Contracts::update( $id, $args ) );
+		$this->assertSame( ContractStatus::ON_HOLD, $this->stored_status( $id ) );
+		$this->assertNull( $this->get_lock_holder( $id )['operation'], 'The internal lock is released.' );
+	}
+
+	/**
+	 * @testdox Cycle appends and transitions land under the lock; one that expired and was taken over is refused.
+	 */
+	public function test_cycle_writes_are_fenced(): void {
+		$id = $this->insert_released( $this->make_contract() );
+		$this->pin_db_clock( '2035-01-01 00:00:00' );
+		$lock = $this->lock( $id, 'renewal' );
+
+		$cycle = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' );
+		$this->sut->append_cycle( $cycle, $lock );
+		$cycle_id = (int) $cycle->get_id();
+		$this->assertTrue( $this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::FAILED, array(), $lock ) );
+
+		$this->pin_db_clock( '2035-01-01 00:10:00' );
+		$this->lock( $id, 'cancel' );
+		$this->age_lock_trust( $lock );
+		try {
+			$this->sut->append_cycle( $this->make_cycle( $id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00' ), $lock );
+			$this->fail( 'Expected an append with a taken-over lock to be refused.' );
+		} catch ( ContractLockedException $e ) {
+			$this->assertSame( 'cancel', $e->get_operation() );
+		}
+		$this->assertCount( 1, $this->sut->find_cycle_history( $id ), 'Nothing is appended.' );
+
+		try {
+			$this->sut->transition_cycle( $cycle_id, CycleStatus::FAILED, CycleStatus::BILLED, array(), $lock );
+			$this->fail( 'Expected a transition with a taken-over lock to be refused.' );
+		} catch ( ContractLockedException $e ) {
+			$this->assertSame( CycleStatus::FAILED, $this->find_cycle( $cycle_id )->get_status()->get_value() );
+		}
+	}
+
+	/**
+	 * @testdox A cycle appended with a lock whose contract row was deleted still lands: the lock is the existence check, and only the create discard deletes a contract.
+	 */
+	public function test_an_append_under_a_lock_on_a_deleted_contract_lands(): void {
+		$id   = $this->insert_released( $this->make_contract() );
+		$lock = $this->lock( $id );
+		$this->sut->delete( $id );
+
+		$cycle = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' );
+		$this->sut->append_cycle( $cycle, $lock );
+
+		$this->assertNotNull( $cycle->get_id() );
+		$this->assertCount( 1, $this->sut->find_cycle_history( $id ) );
+	}
+
+	/**
+	 * @testdox A unique meta add under a lock whose contract row was deleted throws, writing nothing.
+	 */
+	public function test_a_unique_meta_add_on_a_deleted_contract_throws(): void {
+		$id   = $this->insert_released( $this->make_contract() );
+		$lock = $this->lock( $id );
+		$this->sut->delete( $id );
+
+		try {
+			$this->sut->add_meta( $id, 'note', 'x', true, $lock );
+			$this->fail( 'Expected the add to a missing contract to throw.' );
+		} catch ( \InvalidArgumentException $e ) {
+			$this->assertStringContainsString( (string) $id, $e->getMessage() );
+		}
+		$this->assertSame( array(), $this->sut->get_meta( $id, 'note' ) );
+	}
+
+	/**
+	 * @testdox A lock for one contract cannot append to or transition another contract's cycles.
+	 */
+	public function test_a_lock_cannot_write_another_contracts_cycles(): void {
+		$id       = $this->insert_released( $this->make_contract() );
+		$other    = $this->insert_released( $this->make_contract() );
+		$cycle_id = (int) $this->append_pending_cycle( $other )->get_id();
+		$lock     = $this->lock( $id );
+
+		try {
+			$this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::BILLED, array(), $lock );
+			$this->fail( 'Expected another contract\'s cycle to be refused.' );
+		} catch ( \InvalidArgumentException $e ) {
+			$this->assertSame( CycleStatus::PENDING, $this->find_cycle( $cycle_id )->get_status()->get_value() );
+		}
+
+		$this->expectException( \InvalidArgumentException::class );
+		$this->sut->append_cycle( $this->make_cycle( $other, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00' ), $lock );
+	}
+
+	/**
+	 * @testdox A lost compare-and-set under the lock returns false; a missing cycle throws.
+	 */
+	public function test_a_lost_locked_transition_returns_false(): void {
+		$id       = $this->insert_released( $this->make_contract() );
+		$cycle_id = (int) $this->append_pending_cycle( $id )->get_id();
+		$lock     = $this->lock( $id );
+
+		$this->assertTrue( $this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::BILLED, array(), $lock ) );
+		$this->assertFalse( $this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::FAILED, array(), $lock ) );
+
+		$this->expectException( \InvalidArgumentException::class );
+		$this->sut->transition_cycle( 999999, CycleStatus::PENDING, CycleStatus::BILLED, array(), $lock );
+	}
+
+	/**
+	 * @testdox A database failure in a cycle transition throws instead of returning false.
+	 */
+	public function test_a_failed_transition_throws(): void {
+		$cycle_id = $this->pending_cycle_id();
+
+		$this->with_failing_query(
+			'/^\s*UPDATE\s+\S*cycles/i',
+			function () use ( $cycle_id ): void {
+				try {
+					$this->transition_cycle_row_locked( $cycle_id, CycleStatus::PENDING, CycleStatus::BILLED, array() );
+					$this->fail( 'Expected a RuntimeException.' );
+				} catch ( \RuntimeException $e ) {
+					$this->assertStringContainsString( 'Failed to transition cycle', $e->getMessage() );
+				}
+			}
+		);
+		$this->assertSame( CycleStatus::PENDING, $this->find_cycle( $cycle_id )->get_status()->get_value() );
+	}
+
+	/**
+	 * @testdox Replacing items leaves exactly the new rows, in order.
+	 */
+	public function test_replacing_items_leaves_the_new_rows_in_order(): void {
+		$id       = $this->insert_released( $this->make_contract() );
+		$contract = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+		$contract->set_items( $this->two_items() );
+		$lock = $this->lock( $id );
+
+		$this->sut->update_fields( $contract, array( 'items' ), $lock );
+
+		$this->assertSame( array( 'Tea tin', 'Mug' ), $this->item_names( $id ) );
+	}
+
+	/**
+	 * @testdox A failed item delete leaves old and new rows side by side, and the next replacement repairs it.
+	 */
+	public function test_a_failed_item_delete_is_repaired_by_the_next_replacement(): void {
+		$id       = $this->insert_released( $this->make_contract() );
+		$contract = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+		$contract->set_items( $this->two_items() );
+		$lock = $this->lock( $id );
+
+		$this->with_failing_query(
+			'/^\s*DELETE\s+FROM\s+\S*contract_items/i',
+			function () use ( $contract, $lock ): void {
+				try {
+					$this->sut->update_fields( $contract, array( 'items' ), $lock );
+					$this->fail( 'Expected the failed delete to throw.' );
+				} catch ( \RuntimeException $e ) {
+					$this->assertStringContainsString( 'Failed to delete old item rows', $e->getMessage() );
+				}
+			}
+		);
+		$this->assertSame( array( 'Coffee bag', 'Tea tin', 'Mug' ), $this->item_names( $id ), 'Old and new rows side by side, never an empty set.' );
+
+		$this->sut->update_fields( $contract, array( 'items' ), $lock );
+		$this->assertSame( array( 'Tea tin', 'Mug' ), $this->item_names( $id ) );
+	}
+
+	/**
+	 * @testdox A failed item insert keeps the old item set.
+	 */
+	public function test_a_failed_item_insert_keeps_the_old_items(): void {
+		$id       = $this->insert_released( $this->make_contract() );
+		$contract = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+		$contract->set_items( $this->two_items() );
+		$lock = $this->lock( $id );
+
+		$this->with_failing_query(
+			'/^\s*INSERT\s+INTO\s+\S*contract_items/i',
+			function () use ( $contract, $lock ): void {
+				try {
+					$this->sut->update_fields( $contract, array( 'items' ), $lock );
+					$this->fail( 'Expected the failed insert to throw.' );
+				} catch ( \RuntimeException $e ) {
+					$this->assertStringContainsString( 'Failed to insert item row', $e->getMessage() );
+				}
+			}
+		);
+		$this->assertSame( array( 'Coffee bag' ), $this->item_names( $id ) );
+	}
+
+	/**
+	 * @testdox A failed second item insert removes the first new row and leaves exactly the old set.
+	 */
+	public function test_a_failed_second_item_insert_leaves_exactly_the_old_items(): void {
+		$id       = $this->insert_released( $this->make_contract() );
+		$contract = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+		$contract->set_items( $this->two_items() );
+		$lock = $this->lock( $id );
+
+		$this->with_failing_query(
+			'/^\s*INSERT\s+INTO\s+\S*contract_items/i',
+			function () use ( $contract, $lock ): void {
+				try {
+					$this->sut->update_fields( $contract, array( 'items' ), $lock );
+					$this->fail( 'Expected the failed insert to throw.' );
+				} catch ( \RuntimeException $e ) {
+					$this->assertStringContainsString( 'Failed to insert item row', $e->getMessage() );
+				}
+			},
+			1
+		);
+		$this->assertSame( array( 'Coffee bag' ), $this->item_names( $id ) );
+
+		$this->sut->update_fields( $contract, array( 'items' ), $lock );
+		$this->assertSame( array( 'Tea tin', 'Mug' ), $this->item_names( $id ) );
+	}
+
+	/**
+	 * @testdox A failed address insert on create leaves no contract, and a retry with the same creation key succeeds.
+	 */
+	public function test_a_failed_create_is_discarded_and_can_be_retried(): void {
+		global $wpdb;
+
+		$key   = 'order:1';
+		$build = function () use ( $key ): Contract {
+			$contract = $this->make_contract();
+			$storage  = $contract->to_storage();
+			return Contract::create(
+				array(
+					'extension_slug' => 'lite',
+					'creation_key'   => $key,
+					'items'          => $contract->get_items(),
+					'addresses'      => $contract->get_addresses(),
+					'currency'       => $storage['currency'],
+				)
+			);
+		};
+
+		$this->with_failing_query(
+			'/^\s*INSERT\s+INTO\s+\S*contract_addresses/i',
+			function () use ( $build ): void {
+				try {
+					$this->sut->insert( $build(), 'checkout' );
+					$this->fail( 'Expected the failed address insert to throw.' );
+				} catch ( \RuntimeException $e ) {
+					$this->assertStringContainsString( 'address row', $e->getMessage() );
+				}
+			},
+			1
+		);
+
+		$contracts = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$items     = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ITEMS );
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$this->assertSame( '0', $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$contracts} WHERE creation_key = %s", $key ) ) );
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$this->assertSame( '0', $wpdb->get_var( "SELECT COUNT(*) FROM {$items} WHERE contract_id NOT IN ( SELECT id FROM {$contracts} )" ), 'No orphaned items.' );
+
+		$retry = $build();
+		$this->assertNotNull( $this->sut->insert( $retry, 'checkout' ) );
+		$this->assertIsInt( $retry->get_id() );
+		$this->assertSame( array( 'Coffee bag' ), $this->item_names( (int) $retry->get_id() ) );
+	}
+
+	/**
+	 * @testdox A failed discard after a failed child insert on create is logged, and the child insert failure is what is thrown.
+	 */
+	public function test_a_failed_discard_is_logged_and_keeps_the_insert_failure(): void {
+		$contract = $this->make_contract();
+		$thrown   = null;
+
+		$this->fail_next_query( '/^\s*INSERT\s+INTO\s+\S*contract_addresses/i' );
+		$this->fail_next_query( '/^\s*DELETE\s+FROM\s+`?\S*contracts`?\s+WHERE\s+`id`/i' );
+		$logs = $this->capture_engine_log(
+			'error',
+			array(),
+			function () use ( $contract, &$thrown ): void {
+				try {
+					$this->insert_released( $contract );
+				} catch ( \RuntimeException $e ) {
+					$thrown = $e;
+				}
+			}
+		);
+
+		$this->assertInstanceOf( \RuntimeException::class, $thrown );
+		$this->assertStringContainsString( 'address row', $thrown->getMessage() );
+
+		$id = (int) $contract->get_id();
+		$this->assertNotSame( array(), $logs, 'The failed discard is logged.' );
+		foreach ( $logs as $log ) {
+			$this->assertSame( $id, $log['contract_id'] ?? null, 'The log names the contract.' );
+		}
+		$this->assertNotNull( $this->sut->find_summary( $id ), 'The contract row the discard could not delete is still stored.' );
+	}
+
+	/**
+	 * @testdox A failed internal-lock release is logged and keeps the write's result, or its exception.
+	 */
+	public function test_a_failed_internal_release_does_not_mask_the_write(): void {
+		$id       = $this->insert_released( $this->make_contract() );
+		$contract = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+		$contract->set_items( $this->two_items() );
+
+		$release = '/SET lock_token = NULL/';
+		$logs    = $this->capture_engine_log(
+			'warning',
+			array( 'contract_id' => $id ),
+			function () use ( $id, $release ): void {
+				$this->fail_next_query( $release );
+				$this->assertNotNull( Contracts::update( $id, array( 'items' => $this->two_items() ) ), 'The landed write is reported.' );
+			}
+		);
+		$this->assertNotSame( array(), $logs, 'The failed release is logged.' );
+		$this->assertSame( array( 'Tea tin', 'Mug' ), $this->item_names( $id ) );
+
+		$this->pin_db_clock( gmdate( 'Y-m-d H:i:s', time() + 2 * ContractLock::TTL_SECONDS ) );
+		$logs = $this->capture_engine_log(
+			'warning',
+			array( 'contract_id' => $id ),
+			function () use ( $id, $release ): void {
+				$this->fail_next_query( '/^\s*INSERT\s+INTO\s+\S*contract_items/i' );
+				$this->fail_next_query( $release );
+				try {
+					Contracts::update( $id, array( 'items' => $this->two_items() ) );
+					$this->fail( 'Expected the failed item insert to throw.' );
+				} catch ( \RuntimeException $e ) {
+					$this->assertStringContainsString( 'item', $e->getMessage(), 'The write failure surfaces, not the release failure.' );
+				}
+			}
+		);
+		$this->assertNotSame( array(), $logs, 'The failed release is logged.' );
+	}
+
+	/**
+	 * @testdox Replacing addresses upserts the given types in place and removes the others.
+	 */
+	public function test_replacing_addresses_upserts_and_prunes(): void {
+		$id       = $this->insert_released( $this->make_contract() );
+		$contract = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $contract );
+		$lock = $this->lock( $id );
+
+		$contract->set_addresses(
+			array(
+				Contract::ADDRESS_BILLING => array(
+					'first_name' => 'Grace',
+					'last_name'  => 'Hopper',
+					'country'    => 'US',
+				),
+			)
+		);
+		$this->sut->update_fields( $contract, array( 'addresses' ), $lock );
+
+		$stored = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $stored );
+		$addresses = $stored->get_addresses();
+		$this->assertSame( array( Contract::ADDRESS_BILLING ), array_keys( $addresses ) );
+		$this->assertSame( 'Grace', $addresses[ Contract::ADDRESS_BILLING ]['first_name'] );
+		$this->assertNull( $addresses[ Contract::ADDRESS_BILLING ]['email'], 'An omitted column is cleared on upsert.' );
+
+		$contract->set_addresses( array() );
+		$this->sut->update_fields( $contract, array( 'addresses' ), $lock );
+		$cleared = $this->sut->find( $id );
+		$this->assertInstanceOf( Contract::class, $cleared );
+		$this->assertSame( array(), $cleared->get_addresses() );
+	}
+
+	/**
+	 * @testdox A creation key is unique per extension; a duplicate inserts nothing and returns null.
+	 */
+	public function test_creation_key_is_unique_per_extension(): void {
+		global $wpdb;
+
+		$this->assertNotNull( $this->sut->insert( $this->keyed_contract( 'lite', 'order:42' ), 'checkout' ) );
+		$this->assertNull( $this->sut->insert( $this->keyed_contract( 'lite', 'order:42' ), 'checkout' ) );
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$this->assertSame( '1', $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE creation_key = %s AND extension_slug = %s", 'order:42', 'lite' ) ) );
+
+		$this->assertNotNull( $this->sut->insert( $this->keyed_contract( 'other-ext', 'order:42' ), 'checkout' ), 'Another extension may use the same key.' );
+		$this->assertNotNull( $this->sut->insert( $this->keyed_contract( 'lite', 'Order:42' ), 'checkout' ), 'Keys differing only in case are distinct.' );
+		$this->assertNotNull( $this->sut->insert( $this->keyed_contract( 'lite', 'ördér:42' ), 'checkout' ), 'Keys differing only in accents are distinct.' );
+		$this->assertNotNull( $this->sut->insert( $this->keyed_contract( 'lite', null ), 'checkout' ) );
+		$this->assertNotNull( $this->sut->insert( $this->keyed_contract( 'lite', null ), 'checkout' ), 'Null keys never collide.' );
+	}
+
+	/**
+	 * @testdox insert() creates the contract locked from birth.
+	 */
+	public function test_insert_is_locked_from_birth(): void {
+		$contract = $this->make_contract();
+		$lock     = $this->sut->insert( $contract, 'checkout' );
+		$id       = (int) $contract->get_id();
+
+		$this->assertNotNull( $lock );
+		$this->assertSame( $id, $lock->get_contract_id() );
+		$this->assertSame( 'checkout', $this->get_lock_holder( $id )['operation'] );
+		$this->assertCount( 1, $this->item_ids( $id ), 'Items are written under the lock.' );
+
+		try {
+			Contracts::update( $id, array( 'status' => ContractStatus::ON_HOLD ) );
+			$this->fail( 'Expected an unlocked write to be refused.' );
+		} catch ( ContractLockedException $e ) {
+			$this->assertSame( 'checkout', $e->get_operation() );
+		}
+
+		$lock->release();
+		$this->assertNotNull( Contracts::update( $id, array( 'status' => ContractStatus::ON_HOLD ) ) );
+	}
+
+	/**
+	 * @testdox insert() with a taken creation key returns null and locks nothing.
+	 */
+	public function test_insert_with_a_duplicate_key_returns_null(): void {
+		$first = $this->insert_released( $this->keyed_contract( 'lite', 'order:7' ) );
+
+		$this->assertNull( $this->sut->insert( $this->keyed_contract( 'lite', 'order:7' ), 'checkout' ) );
+		$this->assertNull( $this->get_lock_holder( $first )['operation'] );
+	}
+
+	/**
+	 * A minimal contract with a creation key.
+	 *
+	 * @param string      $extension_slug Owning extension.
+	 * @param string|null $creation_key   Creation key, or null.
+	 */
+	private function keyed_contract( string $extension_slug, ?string $creation_key ): Contract {
+		return Contract::create(
+			array(
+				'extension_slug' => $extension_slug,
+				'creation_key'   => $creation_key,
+			)
+		);
+	}
+
+	/**
+	 * Two replacement items.
+	 *
+	 * @return array<int, array<string, mixed>>
+	 */
+	private function two_items(): array {
+		return array(
+			array(
+				'item_name' => 'Tea tin',
+				'item_type' => 'line_item',
+				'quantity'  => '1',
+				'total'     => '10.00',
+			),
+			array(
+				'item_name' => 'Mug',
+				'item_type' => 'line_item',
+				'quantity'  => '1',
+				'total'     => '5.00',
+			),
+		);
+	}
+
+	/**
+	 * The item names of a contract, oldest row first.
+	 *
+	 * @param int $contract_id Contract id.
+	 * @return array<int, string>
+	 */
+	private function item_names( int $contract_id ): array {
+		$contract = $this->sut->find( $contract_id );
+		$this->assertInstanceOf( Contract::class, $contract );
+
+		return array_map(
+			static function ( array $item ): string {
+				return is_string( $item['item_name'] ?? null ) ? $item['item_name'] : '';
+			},
+			$contract->get_items()
+		);
+	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php
index 655a14cc634..a1b70b12ca7 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php
@@ -291,10 +291,10 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox The schema version is 2.7.0 (due dispatch).
+	 * @testdox The schema version is 2.8.0 (contract lock).
 	 */
-	public function test_schema_version_is_2_7_0(): void {
-		$this->assertSame( '2.7.0', SchemaInstaller::get_version() );
+	public function test_schema_version_is_2_8_0(): void {
+		$this->assertSame( '2.8.0', SchemaInstaller::get_version() );
 	}

 	/**
@@ -314,19 +314,72 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox Contracts carry a nullable claimed_until column for the due-dispatch claim.
+	 * @testdox Contracts carry nullable lock and creation-key columns and no claimed_until.
 	 */
-	public function test_contracts_claimed_until_is_nullable(): void {
+	public function test_contracts_lock_and_creation_key_columns_are_nullable(): void {
+		global $wpdb;
+
+		$table    = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		$expected = array(
+			'lock_token'       => 'char(32)',
+			'locked_until_gmt' => 'datetime',
+			'lock_operation'   => 'varchar(64)',
+			'creation_key'     => 'varchar(100)',
+		);
+
+		foreach ( $expected as $column => $type ) {
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+			$row = $wpdb->get_row( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", $column ), ARRAY_A );
+
+			$this->assertIsArray( $row, "Expected a contracts.{$column} column." );
+			$this->assertSame( 'YES', $row['Null'] ?? null, "Expected contracts.{$column} to be NULLable." );
+			$this->assertSame( $type, $row['Type'] ?? null );
+		}
+
+		$this->assertFalse( $this->has_column( $table, 'claimed_until' ) );
+	}
+
+	/**
+	 * @testdox Contracts carry a write_seq counter that starts at 0.
+	 */
+	public function test_contracts_write_seq_column(): void {
 		global $wpdb;

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

 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-		$row = $wpdb->get_row( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'claimed_until' ), ARRAY_A );
+		$row = $wpdb->get_row( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'write_seq' ), ARRAY_A );

-		$this->assertIsArray( $row, 'Expected a contracts.claimed_until column.' );
-		$this->assertSame( 'YES', $row['Null'] ?? null, 'Expected contracts.claimed_until to be NULLable.' );
-		$this->assertSame( 'datetime', $row['Type'] ?? null );
+		$this->assertIsArray( $row, 'Expected a contracts.write_seq column.' );
+		$this->assertSame( 'NO', $row['Null'] ?? null );
+		$this->assertSame( '0', $row['Default'] ?? null );
+		$type = $row['Type'] ?? null;
+		$this->assertIsString( $type );
+		$this->assertMatchesRegularExpression( '/^bigint(\(\d+\))? unsigned$/', $type );
+	}
+
+	/**
+	 * @testdox The creation index makes (extension_slug, creation_key) unique.
+	 */
+	public function test_contracts_creation_index_is_unique(): void {
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+		$this->assertSame( array( 'extension_slug', 'creation_key' ), $this->index_columns( $table, 'creation' ) );
+		$this->assertTrue( $this->index_is_unique( $table, 'creation' ) );
+	}
+
+	/**
+	 * @testdox The creation key column compares binary, in the table charset.
+	 */
+	public function test_contracts_creation_key_is_binary_collated(): void {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$row = $wpdb->get_row( $wpdb->prepare( "SHOW FULL COLUMNS FROM {$table} LIKE %s", 'creation_key' ), ARRAY_A );
+
+		$this->assertIsArray( $row );
+		$this->assertSame( $wpdb->charset . '_bin', $row['Collation'] ?? null );
 	}

 	/**
@@ -382,24 +435,25 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
 			'items_snapshot_id',
 			'order_id',
 			'extension_slug',
-			'claimed_until',
 			'retry_at',
 		);

 		foreach ( $columns as $column ) {
 			$this->assertTrue( $this->has_column( $table, $column ), "Expected cycles.{$column} column." );
 		}
+
+		$this->assertFalse( $this->has_column( $table, 'claimed_until' ), 'Cycles carry no claim column.' );
 	}

 	/**
-	 * @testdox The cycle dispatcher columns (claimed_until claim, reserved retry_at) are nullable.
+	 * @testdox The reserved cycle retry_at column is nullable.
 	 */
 	public function test_cycles_table_dispatcher_columns_are_nullable(): void {
 		global $wpdb;

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

-		foreach ( array( 'claimed_until', 'retry_at' ) as $column ) {
+		foreach ( array( 'retry_at' ) as $column ) {
 			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
 			$row = $wpdb->get_row( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", $column ), ARRAY_A );

diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php
index 8b9965b8950..9671edfa1bc 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php
@@ -48,8 +48,6 @@ class ArgumentValidatorTest extends EngineIntegrationTestCase {
 			'money'                 => array( 'validate_money', array( 'tax_total', '1.5' ), '1.50000000' ),
 			'money null is zero'    => array( 'validate_money', array( 'tax_total', null ), '0.00000000' ),
 			'list of arrays'        => array( 'validate_list_of_arrays', array( 'items', array( array( 'name' => 'a' ) ) ), array( array( 'name' => 'a' ) ) ),
-			'positive int'          => array( 'validate_positive_int', array( 'claim_seconds', 900 ), 900 ),
-			'positive int digits'   => array( 'validate_positive_int', array( 'claim_seconds', '60' ), 60 ),
 			'non-negative int zero' => array( 'validate_non_negative_int', array( 'offset', 0 ), 0 ),
 			'non-negative digits'   => array( 'validate_non_negative_int', array( 'offset', '7' ), 7 ),
 			'id list'               => array( 'validate_id_list', array( 'ids', array( 3, '4' ) ), array( 3, 4 ) ),
@@ -88,8 +86,6 @@ class ArgumentValidatorTest extends EngineIntegrationTestCase {
 			'money'            => array( 'validate_money', array( 'tax_total', 'ten' ), '"tax_total" must be a number or a numeric string.' ),
 			'list of arrays'   => array( 'validate_list_of_arrays', array( 'items', array( 'a' => array() ) ), '"items" must be a list of arrays.' ),
 			'negative int'     => array( 'validate_non_negative_int', array( 'offset', -1 ), '"offset" must be a non-negative integer.' ),
-			'zero positive'    => array( 'validate_positive_int', array( 'claim_seconds', 0 ), '"claim_seconds" must be a positive integer.' ),
-			'null positive'    => array( 'validate_positive_int', array( 'claim_seconds', null ), '"claim_seconds" must be a positive integer.' ),
 			'id list entry'    => array( 'validate_id_list', array( 'ids', array( 3, 0 ) ), '"ids" must be a list of positive integers.' ),
 			'id list null'     => array( 'validate_id_list', array( 'ids', array( null ) ), '"ids" must be a list of positive integers.' ),
 			'id list map'      => array( 'validate_id_list', array( 'ids', array( 'a' => 3 ) ), '"ids" must be a list of positive integers.' ),
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/ContractLockTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/ContractLockTest.php
new file mode 100644
index 00000000000..2ff15868fe4
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/ContractLockTest.php
@@ -0,0 +1,237 @@
+<?php
+/**
+ * Unit tests for the opaque ContractLock and the lock exceptions.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Api;
+
+use Closure;
+use LogicException;
+use ReflectionClass;
+use ReflectionMethod;
+use ReflectionNamedType;
+use ReflectionProperty;
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLock;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLockedException;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractLockState;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLock
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLockedException
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractLockState
+ */
+class ContractLockTest extends TestCase {
+
+	private const TOKEN = '0123456789abcdef0123456789abcdef';
+
+	/**
+	 * A lock built in the repository's scope, as the engine builds it, so no notice is raised
+	 * (unit tests load no WordPress).
+	 */
+	private function issue(): ContractLock {
+		$factory = Closure::bind(
+			static function ( string $token ): ContractLock {
+				return new ContractLock( 7, 'hold', $token );
+			},
+			null,
+			ContractRepository::class
+		);
+
+		return $factory( self::TOKEN );
+	}
+
+	/**
+	 * Seconds of trust left on a lock state.
+	 *
+	 * @param ContractLockState $state The state.
+	 */
+	private function trust_left( ContractLockState $state ): float {
+		$property = new ReflectionProperty( ContractLockState::class, 'trusted_until' );
+		$property->setAccessible( true );
+		$until = $property->getValue( $state );
+		$this->assertIsFloat( $until );
+		$now = hrtime();
+		$this->assertIsArray( $now );
+
+		return $until - ( $now[0] + $now[1] / 1e9 );
+	}
+
+	/**
+	 * @testdox The only public methods are the constructor, the accessors, the engine-only state, release, and the methods that refuse cloning and serialization.
+	 */
+	public function test_public_surface(): void {
+		$names = array();
+		foreach ( ( new ReflectionClass( ContractLock::class ) )->getMethods( ReflectionMethod::IS_PUBLIC ) as $method ) {
+			$names[] = $method->getName();
+		}
+		sort( $names );
+
+		$this->assertSame(
+			array( '__clone', '__construct', '__debugInfo', '__serialize', '__unserialize', 'get_contract_id', 'get_operation', 'get_state', 'release', 'serialize', 'unserialize' ),
+			$names
+		);
+	}
+
+	/**
+	 * @testdox The lock exposes its contract and operation and no public method returns any other string.
+	 */
+	public function test_no_public_method_returns_the_token(): void {
+		$lock = $this->issue();
+
+		$this->assertSame( 7, $lock->get_contract_id() );
+		$this->assertSame( 'hold', $lock->get_operation() );
+
+		$string_methods = array();
+		foreach ( ( new ReflectionClass( ContractLock::class ) )->getMethods( ReflectionMethod::IS_PUBLIC ) as $method ) {
+			$type = $method->getReturnType();
+			if ( $type instanceof ReflectionNamedType && 'string' === $type->getName() ) {
+				$string_methods[] = $method->getName();
+			}
+		}
+
+		$this->assertSame( array( 'get_operation' ), $string_methods );
+	}
+
+	/**
+	 * @testdox Debug output never carries the token.
+	 */
+	public function test_debug_output_omits_the_token(): void {
+		$lock = $this->issue();
+
+		ob_start();
+		var_dump( $lock ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_dump
+		$dumped = (string) ob_get_clean();
+
+		$this->assertStringNotContainsString( self::TOKEN, print_r( $lock, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
+		$this->assertStringNotContainsString( self::TOKEN, $dumped );
+		$this->assertStringContainsString( 'hold', $dumped );
+	}
+
+	/**
+	 * @testdox A lock cannot be serialized.
+	 */
+	public function test_serialize_throws(): void {
+		$this->expectException( LogicException::class );
+
+		serialize( $this->issue() ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
+	}
+
+	/**
+	 * @testdox A crafted serialized lock cannot be unserialized.
+	 */
+	public function test_unserialize_of_a_crafted_payload_throws(): void {
+		$class   = ContractLock::class;
+		$payload = sprintf( 'O:%d:"%s":1:{s:11:"contract_id";i:7;}', strlen( $class ), $class );
+
+		$this->expectException( LogicException::class );
+
+		unserialize( $payload ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
+	}
+
+	/**
+	 * @testdox A crafted legacy C: payload, which skips __unserialize(), cannot be unserialized either.
+	 */
+	public function test_unserialize_of_a_crafted_legacy_payload_throws(): void {
+		$class   = ContractLock::class;
+		$payload = sprintf( 'C:%d:"%s":0:{}', strlen( $class ), $class );
+
+		$this->expectException( LogicException::class );
+
+		unserialize( $payload ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
+	}
+
+	/**
+	 * @testdox A lock cannot be cloned.
+	 */
+	public function test_clone_throws(): void {
+		$lock = $this->issue();
+
+		$this->expectException( LogicException::class );
+
+		$copy = clone $lock;
+		unset( $copy );
+	}
+
+	/**
+	 * @testdox A stamp trusts the lock for at most 60 s, less when it ends sooner, and not at all with 60 s or less left.
+	 */
+	public function test_trust_window_follows_the_last_stamp(): void {
+		$state = new ContractLockState( self::TOKEN );
+		$this->assertTrue( $state->is_trusted() );
+		$this->assertLessThanOrEqual( 60.0, $this->trust_left( $state ) );
+		$this->assertGreaterThan( 59.0, $this->trust_left( $state ) );
+
+		$state->stamp( 90 );
+		$this->assertLessThanOrEqual( 30.0, $this->trust_left( $state ) );
+		$this->assertTrue( $state->is_trusted() );
+
+		$state->stamp( 60 );
+		$this->assertFalse( $state->is_trusted() );
+
+		$state->stamp( 0 );
+		$this->assertFalse( $state->is_trusted() );
+
+		$state->stamp( ContractLock::TTL_SECONDS );
+		$this->assertLessThanOrEqual( 60.0, $this->trust_left( $state ) );
+	}
+
+	/**
+	 * @testdox A lock is near expiry by the local estimate when 120 s or less remain.
+	 */
+	public function test_near_expiry_estimate(): void {
+		$state = new ContractLockState( self::TOKEN );
+		$this->assertFalse( $state->is_near_expiry() );
+
+		$state->stamp( 120 );
+		$this->assertTrue( $state->is_near_expiry() );
+
+		$state->stamp( 200 );
+		$this->assertFalse( $state->is_near_expiry() );
+	}
+
+	/**
+	 * @testdox A released lock is never trusted, even after a stamp.
+	 */
+	public function test_a_released_lock_is_never_trusted(): void {
+		$state = new ContractLockState( self::TOKEN );
+		$state->mark_released();
+
+		$this->assertTrue( $state->is_released() );
+		$this->assertFalse( $state->is_trusted() );
+
+		$state->stamp( ContractLock::TTL_SECONDS );
+		$this->assertFalse( $state->is_trusted() );
+	}
+
+	/**
+	 * @testdox The extend allowance is recorded once used.
+	 */
+	public function test_extend_allowance_flag(): void {
+		$state = new ContractLockState( self::TOKEN );
+		$this->assertFalse( $state->has_extended() );
+
+		$state->mark_extended();
+		$this->assertTrue( $state->has_extended() );
+	}
+
+	/**
+	 * @testdox ContractLockedException carries the holder's label, never a token.
+	 */
+	public function test_locked_exception_exposes_the_holder(): void {
+		$exception = new ContractLockedException( 7, 'renewal' );
+
+		$this->assertSame( 7, $exception->get_contract_id() );
+		$this->assertSame( 'renewal', $exception->get_operation() );
+		$this->assertStringContainsString( 'renewal', $exception->getMessage() );
+		$this->assertStringContainsString( '7', $exception->getMessage() );
+
+		$unknown = new ContractLockedException( 7, null );
+		$this->assertNull( $unknown->get_operation() );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/ContractViewTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/ContractViewTest.php
index 37d7c0cb246..2b5fd091724 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/ContractViewTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/ContractViewTest.php
@@ -144,4 +144,50 @@ class ContractViewTest extends TestCase {
 		$this->assertSame( array(), $empty->get_items() );
 		$this->assertSame( array(), $empty->get_addresses() );
 	}
+
+	/**
+	 * @testdox A contract read while locked exposes the lock's expiry and operation.
+	 */
+	public function test_a_live_lock_is_exposed(): void {
+		$row = array_merge(
+			$this->row(),
+			array(
+				'lock_live'        => '1',
+				'lock_token'       => 'abcdef0123456789abcdef0123456789',
+				'locked_until_gmt' => '2026-02-01 00:10:00',
+				'lock_operation'   => 'renewal',
+			)
+		);
+
+		$state = ContractView::from_contract( Contract::from_storage( $row ), false )->get_lock_state();
+
+		$this->assertIsArray( $state );
+		$this->assertSame( 'renewal', $state['operation'] );
+		$this->assertSame( '2026-02-01 00:10:00', $state['locked_until']->format( 'Y-m-d H:i:s' ) );
+		$this->assertSame( 'UTC', $state['locked_until']->getTimezone()->getName() );
+	}
+
+	/**
+	 * @testdox A contract read while not locked has no lock state, and the view never holds a token.
+	 */
+	public function test_no_lock_state_and_no_token(): void {
+		$row = array_merge(
+			$this->row(),
+			array(
+				'lock_live'        => '0',
+				'lock_token'       => 'abcdef0123456789abcdef0123456789',
+				'locked_until_gmt' => '2026-02-01 00:10:00',
+				'lock_operation'   => 'renewal',
+			)
+		);
+
+		$view = ContractView::from_contract( Contract::from_storage( $row ), false );
+
+		$this->assertNull( $view->get_lock_state() );
+		$this->assertNull( ContractView::from_contract( Contract::from_storage( $this->row() ), false )->get_lock_state() );
+		$this->assertStringNotContainsString( 'abcdef0123456789abcdef0123456789', print_r( $view, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
+		foreach ( get_class_methods( $view ) as $method ) {
+			$this->assertStringNotContainsStringIgnoringCase( 'token', str_replace( 'payment_token', '', $method ) );
+		}
+	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
index 857b6f720a2..5dc3ddbe532 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
@@ -542,6 +542,7 @@ class ContractTest extends TestCase {
 				'trial_end_gmt',
 				'end_gmt',
 				'schedule_source',
+				'creation_key',
 			),
 			array_keys( $row )
 		);
@@ -675,4 +676,146 @@ class ContractTest extends TestCase {

 		$this->assertSame( 'legacy-paused', $contract->to_storage()['status'] );
 	}
+
+	/**
+	 * @testdox A creation key round-trips through create(), to_storage() and from_storage().
+	 */
+	public function test_creation_key_round_trips(): void {
+		$contract = Contract::create(
+			array(
+				'extension_slug' => 'acme-subs',
+				'creation_key'   => 'order:42',
+			)
+		);
+
+		$this->assertSame( 'order:42', $contract->to_storage()['creation_key'] );
+		$this->assertSame( 'order:42', Contract::from_storage( $contract->to_storage() )->to_storage()['creation_key'] );
+		$this->assertNull( $this->make_contract()->to_storage()['creation_key'] );
+	}
+
+	/**
+	 * @testdox create() trims surrounding whitespace from a creation key.
+	 */
+	public function test_create_trims_the_creation_key(): void {
+		$contract = Contract::create(
+			array(
+				'extension_slug' => 'acme-subs',
+				'creation_key'   => " order:42 \n",
+			)
+		);
+
+		$this->assertSame( 'order:42', $contract->to_storage()['creation_key'] );
+	}
+
+	/**
+	 * @testdox create() rejects an empty, over-long or non-string creation key.
+	 * @dataProvider invalid_creation_keys
+	 *
+	 * @param mixed $creation_key Invalid creation key.
+	 */
+	public function test_create_rejects_an_invalid_creation_key( $creation_key ): void {
+		$this->expectException( DomainException::class );
+
+		Contract::create(
+			array(
+				'extension_slug' => 'acme-subs',
+				'creation_key'   => $creation_key,
+			)
+		);
+	}
+
+	/**
+	 * Invalid creation keys.
+	 *
+	 * @return array<string, array{0: mixed}>
+	 */
+	public function invalid_creation_keys(): array {
+		return array(
+			'empty'      => array( '' ),
+			'only space' => array( "  \t" ),
+			'192 chars'  => array( str_repeat( 'k', 192 ) ),
+			'101 chars'  => array( str_repeat( 'k', 101 ) ),
+			'non-string' => array( 42 ),
+		);
+	}
+
+	/**
+	 * @testdox A 100-character multibyte creation key is accepted.
+	 */
+	public function test_create_accepts_a_100_character_multibyte_key(): void {
+		$creation_key = str_repeat( 'é', 100 );
+
+		$contract = Contract::create(
+			array(
+				'extension_slug' => 'acme-subs',
+				'creation_key'   => $creation_key,
+			)
+		);
+
+		$this->assertSame( $creation_key, $contract->to_storage()['creation_key'] );
+	}
+
+	/**
+	 * @testdox from_storage() exposes the lock state only while the row says the lock is live.
+	 */
+	public function test_lock_state_follows_the_live_flag(): void {
+		$row = array_merge(
+			$this->valid_row(),
+			array(
+				'lock_token'       => 'abcdef0123456789abcdef0123456789',
+				'locked_until_gmt' => '2026-02-01 00:10:00',
+				'lock_operation'   => 'hold',
+			)
+		);
+
+		$live = Contract::from_storage( array_merge( $row, array( 'lock_live' => '1' ) ) );
+		$this->assertSame(
+			array(
+				'locked_until_gmt' => '2026-02-01 00:10:00',
+				'operation'        => 'hold',
+			),
+			$live->get_lock_state()
+		);
+
+		$this->assertNull( Contract::from_storage( array_merge( $row, array( 'lock_live' => '0' ) ) )->get_lock_state() );
+		$this->assertNull( Contract::from_storage( $row )->get_lock_state() );
+	}
+
+	/**
+	 * @testdox The lock columns and token never reach to_storage().
+	 */
+	public function test_lock_columns_are_not_in_to_storage(): void {
+		$row = array_merge(
+			$this->valid_row(),
+			array(
+				'lock_live'        => 1,
+				'lock_token'       => 'abcdef0123456789abcdef0123456789',
+				'locked_until_gmt' => '2026-02-01 00:10:00',
+				'lock_operation'   => 'hold',
+			)
+		);
+
+		$stored = Contract::from_storage( $row )->to_storage();
+
+		foreach ( array( 'lock_live', 'lock_token', 'locked_until_gmt', 'lock_operation' ) as $key ) {
+			$this->assertArrayNotHasKey( $key, $stored );
+		}
+		$this->assertStringNotContainsString( 'abcdef0123456789abcdef0123456789', print_r( Contract::from_storage( $row ), true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
+	}
+
+	/**
+	 * @testdox create() never adopts a caller-supplied lock state.
+	 */
+	public function test_create_ignores_lock_state(): void {
+		$contract = Contract::create(
+			array(
+				'extension_slug'   => 'acme-subs',
+				'lock_live'        => 1,
+				'locked_until_gmt' => '2026-02-01 00:10:00',
+				'lock_operation'   => 'hold',
+			)
+		);
+
+		$this->assertNull( $contract->get_lock_state() );
+	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
index 6822273a816..6797c6623d4 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
@@ -172,13 +172,6 @@ class CycleTest extends TestCase {
 		$this->assertSame( 'acme-subs', $cycle->get_extension_slug() );
 	}

-	public function test_create_ignores_a_claim_expiry(): void {
-		$cycle = $this->make_pending( array( 'claimed_until' => '2026-03-01 00:15:00' ) );
-
-		$this->assertNull( $cycle->get_claimed_until_gmt(), 'The database stamps a claim on append; a new cycle carries none.' );
-		$this->assertNull( $cycle->to_storage()['claimed_until'] );
-	}
-
 	public function test_create_ignores_snapshot_value_objects(): void {
 		$cycle = $this->make_pending(
 			array(