Commit 2c40fb65560 for woocommerce

commit 2c40fb65560bed47d40c3e5aca287c1a9881bcb2
Author: Patrick Zielinski <patrick.zielinski@a8c.com>
Date:   Wed Apr 29 11:42:10 2026 -0400

    [Email Editor] Auto-apply core template updates for unmodified email posts (#64450)

    * [Email Editor] Fire divergence-sweep completion action

    Append do_action( 'woocommerce_email_template_divergence_sweep_complete' )
    at the end of WCEmailTemplateDivergenceDetector::run_sweep() so RSM-139's
    auto-applier can hook the completion event without inspecting detector
    internals.

    * [Email Editor] Add WCEmailTemplateAutoApplier::apply_to_post atom

    Introduces the per-post atom that rewrites a woo_email post to its current
    core canonical content and stamps four sync meta keys (version, source_hash,
    last_synced_at, status=in_sync) inside a single SQL transaction.

    Two-mode contract via $opts['require_uncustomized']:
    - Auto-applier path (default): hash-gated; rejects WP_Error when the post
      has been edited since stamping, has no stored hash, or belongs to a
      non-sync-enabled email.
    - Reset endpoint path (false): unconditional rewrite; non-sync-enabled
      emails get a content-only reset with all-nulls return shape.

    * [Email Editor] Cover apply_to_post error paths in auto-applier tests

    Adds coverage for the four WP_Error outcomes the per-post atom can return
    when called from the auto-applier path: post_modified_since_stamp (race
    safety bail-out), no_stored_hash, not_sync_enabled, post_not_found.

    * [Email Editor] Cover apply_to_post require_uncustomized=false BC paths

    Adds the two backward-compatibility regression tests for the reset endpoint
    code path: unconditional rewrite of a modified post, and content-only reset
    for non-sync-enabled emails returning all-nulls in the sync fields.

    * [Email Editor] Cover apply_to_post transaction rollback on wp_update_post failure

    Locks in the all-or-nothing guarantee: when wp_update_post returns WP_Error,
    the transaction rolls back so neither post_content nor any of the four sync
    meta keys are modified.

    * [Email Editor] Cover is_auto_applying re-entrancy flag

    Future save_post listeners (RSM-143 customised-post detector, RSM-145 Tracks
    event firing) need to differentiate merchant edits from system-initiated
    writes. This test locks in that the flag reads true during wp_update_post
    and false again after apply_to_post returns.

    * [Email Editor] Schedule auto-apply as an Action Scheduler async action

    WCEmailTemplateAutoApplier::schedule() enqueues the batched runner via
    as_enqueue_async_action so the upgrade request is not blocked. Includes a
    guard against double-enqueueing for the case where the detector sweep
    runs twice in one request (woocommerce_updated + BACKFILL_COMPLETE_ACTION).

    * [Email Editor] Add WCEmailTemplateAutoApplier::run batched runner

    The Action Scheduler callback finds every woo_email post whose status meta
    is core_updated_uncustomized and applies the per-post atom inside a try/catch
    loop. Per-post failures are logged via WC_Logger with context
    'email_template_auto_applier' at the right severity:

    - post_modified_since_stamp (race outcome) -> info
    - no_stored_hash / not_sync_enabled        -> warning
    - WP_Error from wp_update_post / Throwable -> error
    - email plugin deactivated mid-cycle       -> silent

    run() returns false (one-shot - no AS retry, matches WCEmailTemplateSyncBackfill).

    * [Email Editor] Wire auto-applier into the email-editor integration

    Registers two hooks in Integration::register_hooks():

    - woocommerce_email_template_divergence_sweep_complete -> schedule()
    - AUTO_APPLY_AS_HOOK                                   -> run()

    This closes the trigger chain: woocommerce_updated -> detector sweep ->
    completion action -> schedule -> AS job -> run -> apply_to_post per post.

    * [Email Editor] Delegate reset endpoint per-post write to auto-applier

    EmailApiController::reset_response now delegates the canonical content
    rewrite + sync meta stamp to WCEmailTemplateAutoApplier::apply_to_post
    (passing require_uncustomized=false), so reset and auto-apply share one
    canonical-write code path.

    Wire-level BC contract preserved verbatim: same URL, same response shape,
    same null semantics for non-sync-enabled emails, same error codes
    (woocommerce_email_editor_not_initialized / woocommerce_email_not_found /
    woocommerce_email_reset_failed).

    Adds one BC regression test: transaction rollback leaves post + meta
    unchanged when wp_update_post fails (a strict improvement, observably
    identical to pre-refactor behaviour because the old code short-circuited
    before reaching the meta writes). The all-nulls non-sync-enabled BC
    contract is already locked in by the existing
    test_reset_response_resets_content_without_meta_for_non_sync_enabled_email
    test.

    * Add changelog entry for RSM-139 auto-apply core template updates

    * [Email Editor] Fix lint / PHPStan findings on RSM-139 work

    Change WCEmailTemplateAutoApplier::run() return type from bool to void.
    Action Scheduler async-action callbacks discard the return value, so the
    bool return tripped PHPStan's return.void rule on the add_action wiring
    in Integration.php without providing any signal.

    * [Email Editor] Document transaction scope and AS-hook visibility on auto-applier

    Add two docblock notes raised by final review:

    - apply_to_post(): note that third-party callbacks on save_post,
      post_updated, and wp_after_insert_post fire inside the transaction
      and participate in the rollback.
    - AUTO_APPLY_AS_HOOK / AUTO_APPLY_AS_GROUP: mark as internal AS plumbing
      and point extension authors at the upgrade-time signal hook
      (woocommerce_email_template_divergence_sweep_complete) instead.

    No code change.

    * [Email Editor] Drop transaction wrap from apply_to_post per #64355 review

    The pre-RSM-139 reset endpoint shipped without a transaction (PR #64355
    review on commit 80bbaea, accepted before merge into 2fa660b3b9): if
    wp_update_post fails, no meta writes happen, so there's nothing to roll
    back. The transaction added in this branch reintroduced the rejected
    pattern. Drop it to match the shipped contract.

    Cascade:
    - Remove START TRANSACTION / COMMIT / ROLLBACK from apply_to_post.
    - Drop the \Throwable catch handler; only the try/finally for the
      re-entrancy flag remains. global $wpdb is no longer needed.
    - Update apply_to_post docblock: drop the atomicity claim, point at the
      PR review for context.
    - Rename test_apply_to_post_rolls_back_on_wp_update_post_failure to
      test_apply_to_post_returns_wp_error_when_wp_update_post_fails.
    - Rename test_reset_response_rolls_back_when_wp_update_post_fails to
      test_reset_response_returns_wp_error_when_wp_update_post_fails.

    Trade-off accepted (consistent with #64355): if wp_update_post succeeds
    but a meta write fails on entry 2-of-4, the post is half-stamped until
    the next divergence sweep re-classifies and the auto-applier retries.
    update_post_meta failures are environmental and rare; self-healing.

    * [Email Editor] Fix branch-lint findings on RSM-139

    Addresses phpcs errors surfaced by lint:changes:branch in CI:

    - WCEmailTemplateAutoApplier.php: add the four missing
      Squiz.Commenting.LongConditionClosingComment markers (//end if,
      //end try x2, //end foreach).
    - WCEmailTemplateAutoApplierTest.php: align two assignments in
      whitelist_email_page_template, convert the do_action trigger comment
      to a /** */ docblock per WooCommerce.Commenting.CommentHooks, and
      add @param tags on the recording-logger anonymous class methods per
      Squiz.Commenting.FunctionComment.MissingParamTag (inheritDoc alone
      does not satisfy the rule).
    - WCEmailTemplateDivergenceDetectorTest.php: align $fired = 0 with the
      adjacent $listener assignment.

    * [Email Editor] Tighten apply_to_post TOCTOU window per CodeRabbit

    Move the post-content hash re-check from the early validation block to
    immediately before wp_update_post, so the snapshot we hash is not stale
    across compute_canonical_post_content's filter pipeline (a few ms but
    non-zero). The stored-hash existence check stays early — it's a sanity
    check on meta we own, not a race-sensitive read.

    Net change: one fewer get_post-derived hash compare, one new in-try
    get_post + sha1 immediately before the write. Same WP_Error code
    (post_modified_since_stamp) and contract; existing tests still pass.

    Does not eliminate the race entirely (a window remains between this
    re-check and wp_update_post), but no longer can the gap span the
    filesystem read + filter pipeline. Genuinely closing the race would
    require DB row locking, which we deliberately dropped per #64355
    review.

    * [Email Editor] Address PR #64450 reviewer feedback

    Three changes from triple0t's review:

    - Rename AUTO_APPLY_AS_GROUP value from 'woocommerce-email-editor' to
      'woocommerce-email-editor-integration'. The bare 'woocommerce-email-editor'
      namespace is reserved for the standalone email editor package; integration
      glue under Internal/EmailEditor lives under the *-integration suffix.
    - Broaden the candidate query post_status filter from 'publish' to 'any'.
      Generated woo_email posts default to publish, but third-party flows may
      move them to draft / private / future and those should not be silently
      skipped. WP's 'any' excludes trash / auto-draft / inherit.
    - Reorder the two add_action calls in Integration::register_hooks() so the
      AUTO_APPLY_AS_HOOK callback (the executor) is registered before the
      woocommerce_email_template_divergence_sweep_complete listener (the
      dispatcher).

    Plus test coverage for the broadened post_status filter: a data-provider
    test that asserts non-publish statuses (draft/private/future) are still
    processed, and an explicit trash test that asserts trashed posts are
    skipped. 'pending' is omitted from the data set because WP's wp_update_post
    invokes map_meta_cap('publish_post') for any write touching a pending post,
    which trips a _doing_it_wrong notice in the unit-test bootstrap; pending
    isn't a realistic status for transactional templates anyway.

    ---------

    Co-authored-by: PZ01 <patrick.zielinski@automattic.com>

diff --git a/plugins/woocommerce/changelog/rsm-139-auto-apply-core-template-updates b/plugins/woocommerce/changelog/rsm-139-auto-apply-core-template-updates
new file mode 100644
index 00000000000..0046eabb407
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-139-auto-apply-core-template-updates
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Auto-apply the current core block template render to woo_email posts classified as core_updated_uncustomized after a plugin upgrade. Batched via Action Scheduler so the upgrade request is not blocked. Posts edited by the merchant are left untouched.
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php b/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
index 0c16132de39..be4e396fdd5 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
@@ -5,7 +5,7 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Internal\EmailEditor;

 use Automattic\WooCommerce\EmailEditor\Validator\Builder;
-use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateAutoApplier;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
@@ -445,49 +445,24 @@ class EmailApiController {
 			);
 		}

-		$canonical   = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
-		$sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( (string) $email_type );
-
-		$update_result = wp_update_post(
-			array(
-				'ID'           => $post_id,
-				'post_content' => $canonical,
-			),
-			true
+		$result = WCEmailTemplateAutoApplier::apply_to_post(
+			$email,
+			$post_id,
+			array( 'require_uncustomized' => false )
 		);

-		if ( is_wp_error( $update_result ) ) {
+		if ( is_wp_error( $result ) ) {
 			return new WP_Error(
 				'woocommerce_email_reset_failed',
 				sprintf(
 					/* translators: %s: underlying error message */
 					__( 'Failed to reset email content: %s', 'woocommerce' ),
-					$update_result->get_error_message()
+					$result->get_error_message()
 				),
 				array( 'status' => 500 )
 			);
 		}

-		$source_hash = null;
-		$synced_at   = null;
-		if ( null !== $sync_config ) {
-			$source_hash = sha1( $canonical );
-			$synced_at   = gmdate( 'Y-m-d H:i:s' );
-			update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, (string) $sync_config['version'] );
-			update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, $source_hash );
-			update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, $synced_at );
-			update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC );
-		}
-
-		return new WP_REST_Response(
-			array(
-				'content'     => $canonical,
-				'version'     => null !== $sync_config ? (string) $sync_config['version'] : null,
-				'source_hash' => $source_hash,
-				'synced_at'   => $synced_at,
-				'status'      => null !== $sync_config ? WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC : null,
-			),
-			200
-		);
+		return new WP_REST_Response( $result, 200 );
 	}
 }
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
index 309de62f81d..7410c5742f1 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\EmailEditor\Engine\Dependency_Check;
 use Automattic\WooCommerce\Internal\Admin\EmailPreview\EmailPreview;
 use Automattic\WooCommerce\Internal\EmailEditor\EmailPatterns\PatternsController;
 use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\TemplatesController;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateAutoApplier;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncBackfill;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmails;
@@ -149,6 +150,8 @@ class Integration {
 		add_action( 'rest_api_init', array( $this->email_api_controller, 'register_routes' ) );
 		add_action( 'woocommerce_updated', array( WCEmailTemplateDivergenceDetector::class, 'run_sweep' ), 20 );
 		add_action( WCEmailTemplateSyncBackfill::BACKFILL_COMPLETE_ACTION, array( WCEmailTemplateDivergenceDetector::class, 'run_sweep' ), 10 );
+		add_action( WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK, array( WCEmailTemplateAutoApplier::class, 'run' ), 10 );
+		add_action( 'woocommerce_email_template_divergence_sweep_complete', array( WCEmailTemplateAutoApplier::class, 'schedule' ), 10 );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php
new file mode 100644
index 00000000000..b061e9abf47
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php
@@ -0,0 +1,386 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger_Interface;
+use Automattic\WooCommerce\Internal\EmailEditor\Logger;
+
+/**
+ * Auto-applies the current core block template to `woo_email` posts that have
+ * been classified `core_updated_uncustomized` by {@see WCEmailTemplateDivergenceDetector}.
+ *
+ * Mirrors how legacy static-file emails always reflected the latest core
+ * template: when the merchant has not customised a generated post since it was
+ * last stamped, we silently rewrite its content to the new core render and
+ * flip its status meta back to `in_sync`.
+ *
+ * The per-post atom ({@see self::apply_to_post()}) is shared with the
+ * `POST /woocommerce-email-editor/v1/emails/{id}/reset` endpoint
+ * (see {@see \Automattic\WooCommerce\Internal\EmailEditor\EmailApiController::reset_response()})
+ * so reset and auto-apply share one canonical-write code path.
+ *
+ * @package Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
+ * @since 10.8.0
+ */
+class WCEmailTemplateAutoApplier {
+	/**
+	 * Action Scheduler hook name for the batched auto-apply runner.
+	 *
+	 * Internal AS plumbing — not intended for extension consumption. To react to
+	 * the upgrade-time signal, hook {@see 'woocommerce_email_template_divergence_sweep_complete'}
+	 * instead.
+	 *
+	 * @var string
+	 */
+	public const AUTO_APPLY_AS_HOOK = 'woocommerce_email_template_auto_apply_uncustomized';
+
+	/**
+	 * Action Scheduler group for the batched auto-apply runner.
+	 *
+	 * Internal AS plumbing — not intended for extension consumption. The
+	 * `woocommerce-email-editor` namespace is reserved for the standalone email
+	 * editor package; integration glue lives under
+	 * `woocommerce-email-editor-integration` to keep the boundary explicit.
+	 *
+	 * @var string
+	 */
+	public const AUTO_APPLY_AS_GROUP = 'woocommerce-email-editor-integration';
+
+	/**
+	 * Re-entrancy flag set while the atom rewrites a post.
+	 *
+	 * Future `save_post` listeners (e.g. RSM-143's customised-post detector,
+	 * RSM-145's Tracks event firing) should consult {@see self::is_auto_applying()}
+	 * before interpreting a write as a merchant edit.
+	 *
+	 * @var bool
+	 */
+	private static bool $is_auto_applying = false;
+
+	/**
+	 * Logger instance. Lazily instantiated on first use; overridable for tests.
+	 *
+	 * @var Email_Editor_Logger_Interface|null
+	 */
+	private static ?Email_Editor_Logger_Interface $logger = null;
+
+	/**
+	 * Apply the current core template render to a single `woo_email` post and stamp sync meta.
+	 *
+	 * Two callers, two modes:
+	 *   - Auto-applier (default): `$opts['require_uncustomized'] === true`. Rejects with
+	 *     `WP_Error` when the post has no stored hash, has been edited since stamping,
+	 *     or belongs to a non-sync-enabled email.
+	 *   - Reset endpoint: `$opts['require_uncustomized'] === false`. Unconditional rewrite.
+	 *     Non-sync-enabled emails receive a content-only reset and the return shape carries
+	 *     `null` for the four sync fields (BC contract with the pre-RSM-139 reset endpoint).
+	 *
+	 * The four meta writes are skipped entirely if `wp_update_post` fails, so a
+	 * `WP_Error` return leaves the post and existing meta untouched. Matches the
+	 * pre-RSM-139 reset endpoint shape (see PR #64355 review on `2fa660b3b9`).
+	 *
+	 * @param \WC_Email $email   The transactional email instance.
+	 * @param int       $post_id The post ID.
+	 * @param array     $opts    Options. Recognised keys:
+	 *                           - `require_uncustomized` (bool, default true): see above.
+	 * @return array|\WP_Error On success, an array with keys `content`, `version`,
+	 *                         `source_hash`, `synced_at`, `status`. On failure, a `WP_Error`.
+	 *
+	 * @since 10.8.0
+	 */
+	public static function apply_to_post( \WC_Email $email, int $post_id, array $opts = array() ) {
+		$require_uncustomized = ! isset( $opts['require_uncustomized'] ) || (bool) $opts['require_uncustomized'];
+
+		$sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( (string) $email->id );
+
+		if ( $require_uncustomized && null === $sync_config ) {
+			return new \WP_Error(
+				'not_sync_enabled',
+				sprintf(
+					/* translators: %s: email ID */
+					__( 'Email "%s" is not registered for template sync.', 'woocommerce' ),
+					(string) $email->id
+				)
+			);
+		}
+
+		$post = get_post( $post_id );
+		if ( ! $post instanceof \WP_Post || \Automattic\WooCommerce\Internal\EmailEditor\Integration::EMAIL_POST_TYPE !== $post->post_type ) {
+			return new \WP_Error(
+				'post_not_found',
+				sprintf(
+					/* translators: %d: post ID */
+					__( 'No woo_email post found for ID %d.', 'woocommerce' ),
+					$post_id
+				)
+			);
+		}
+
+		$stored_source_hash = '';
+		if ( $require_uncustomized ) {
+			$stored_source_hash = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true );
+			if ( '' === $stored_source_hash || ! self::is_sha1_hash( $stored_source_hash ) ) {
+				return new \WP_Error(
+					'no_stored_hash',
+					sprintf(
+						/* translators: %d: post ID */
+						__( 'Post %d has no stored source hash; cannot safely auto-apply.', 'woocommerce' ),
+						$post_id
+					)
+				);
+			}
+		}//end if
+
+		$canonical   = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+		$source_hash = null;
+		$synced_at   = null;
+		$status      = null;
+		$version     = null;
+
+		self::$is_auto_applying = true;
+		try {
+			// Re-hash post_content immediately before the write to minimise the
+			// TOCTOU gap between the snapshot and wp_update_post. The first $post
+			// load above is too early — `compute_canonical_post_content` runs in
+			// between and yields the window where a merchant save could otherwise
+			// be silently overwritten.
+			if ( $require_uncustomized ) {
+				$latest_post = get_post( $post_id );
+				if ( ! $latest_post instanceof \WP_Post
+					|| sha1( (string) $latest_post->post_content ) !== $stored_source_hash
+				) {
+					return new \WP_Error(
+						'post_modified_since_stamp',
+						sprintf(
+							/* translators: %d: post ID */
+							__( 'Post %d has been modified since the last sync stamp; skipping auto-apply.', 'woocommerce' ),
+							$post_id
+						)
+					);
+				}
+			}
+
+			$updated = wp_update_post(
+				array(
+					'ID'           => $post_id,
+					'post_content' => $canonical,
+				),
+				true
+			);
+
+			if ( is_wp_error( $updated ) ) {
+				return $updated;
+			}
+
+			if ( null !== $sync_config ) {
+				$source_hash = sha1( $canonical );
+				$synced_at   = gmdate( 'Y-m-d H:i:s' );
+				$status      = WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC;
+				$version     = (string) $sync_config['version'];
+
+				update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, $version );
+				update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, $source_hash );
+				update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, $synced_at );
+				update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, $status );
+			}
+		} finally {
+			self::$is_auto_applying = false;
+		}//end try
+
+		return array(
+			'content'     => $canonical,
+			'version'     => $version,
+			'source_hash' => $source_hash,
+			'synced_at'   => $synced_at,
+			'status'      => $status,
+		);
+	}
+
+	/**
+	 * Enqueue the batched auto-apply runner as an Action Scheduler async action.
+	 *
+	 * Hooked to {@see 'woocommerce_email_template_divergence_sweep_complete'}. The
+	 * `as_has_scheduled_action()` short-circuit guards against double-enqueueing
+	 * when the detector sweep runs twice in one request — once on
+	 * `woocommerce_updated`, once on `BACKFILL_COMPLETE_ACTION`.
+	 *
+	 * @since 10.8.0
+	 */
+	public static function schedule(): void {
+		if ( as_has_scheduled_action( self::AUTO_APPLY_AS_HOOK, array(), self::AUTO_APPLY_AS_GROUP ) ) {
+			return;
+		}
+
+		as_enqueue_async_action( self::AUTO_APPLY_AS_HOOK, array(), self::AUTO_APPLY_AS_GROUP );
+	}
+
+	/**
+	 * Action Scheduler callback. Apply the canonical core render to every
+	 * `woo_email` post whose status meta is `core_updated_uncustomized`.
+	 *
+	 * Per-post `try`/`catch` ensures one bad post never breaks the rest of the
+	 * batch (acceptance criterion). Status meta is never mutated by this method
+	 * on failure — the next sweep re-classifies.
+	 *
+	 * Declared `void` because Action Scheduler async-action callbacks discard
+	 * the return value; a return type would just be noise (and trip PHPStan's
+	 * `return.void` rule on `add_action`).
+	 *
+	 * @since 10.8.0
+	 */
+	public static function run(): void {
+		if ( 'yes' !== get_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION ) ) {
+			return;
+		}
+
+		// `post_status=any` includes draft / private / pending / future and
+		// excludes trash / auto-draft / inherit — anything not in the trash bucket
+		// is fair game for auto-apply.
+		$candidate_ids = get_posts(
+			array(
+				'post_type'      => \Automattic\WooCommerce\Internal\EmailEditor\Integration::EMAIL_POST_TYPE,
+				'post_status'    => 'any',
+				'posts_per_page' => -1,
+				'fields'         => 'ids',
+				'no_found_rows'  => true,
+				'meta_query'     => array(  // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded set; sync-registered emails only.
+					array(
+						'key'   => WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+						'value' => WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED,
+					),
+				),
+			)
+		);
+
+		if ( empty( $candidate_ids ) ) {
+			return;
+		}
+
+		$registry      = WCEmailTemplateSyncRegistry::get_sync_enabled_emails();
+		$posts_manager = WCTransactionalEmailPostsManager::get_instance();
+		$emails_by_id  = $posts_manager->get_emails_by_id();
+
+		foreach ( $candidate_ids as $post_id ) {
+			$post_id = (int) $post_id;
+			try {
+				$email_id = (string) $posts_manager->get_email_type_from_post_id( $post_id );
+				if ( '' === $email_id || ! isset( $registry[ $email_id ] ) ) {
+					continue;
+				}
+
+				$email = $emails_by_id[ $email_id ] ?? null;
+				if ( ! $email instanceof \WC_Email ) {
+					continue;
+				}
+
+				$result = self::apply_to_post( $email, $post_id );
+
+				if ( is_wp_error( $result ) ) {
+					self::log_apply_error( $result, $post_id, $email_id );
+				}
+			} catch ( \Throwable $e ) {
+				self::get_logger()->error(
+					sprintf(
+						'Email template auto-apply failed for post %d: %s',
+						$post_id,
+						$e->getMessage()
+					),
+					array(
+						'post_id' => $post_id,
+						'context' => 'email_template_auto_applier',
+					)
+				);
+				continue;
+			}//end try
+		}//end foreach
+	}
+
+	/**
+	 * Map a per-post `WP_Error` from {@see self::apply_to_post()} to the right
+	 * log severity and emit it.
+	 *
+	 * `post_modified_since_stamp` is the expected race outcome (merchant edit in
+	 * the AS lag window) and is logged at `info`. Everything else is at `warning`
+	 * or `error` so it surfaces in the default WC log UI.
+	 *
+	 * @param \WP_Error $error    The error returned by apply_to_post.
+	 * @param int       $post_id  Post ID being processed.
+	 * @param string    $email_id Email ID being processed.
+	 */
+	private static function log_apply_error( \WP_Error $error, int $post_id, string $email_id ): void {
+		$message = sprintf(
+			'Email template auto-apply skipped post %d for email "%s": %s',
+			$post_id,
+			$email_id,
+			$error->get_error_message()
+		);
+
+		$context = array(
+			'post_id'  => $post_id,
+			'email_id' => $email_id,
+			'context'  => 'email_template_auto_applier',
+		);
+
+		switch ( $error->get_error_code() ) {
+			case 'post_modified_since_stamp':
+				self::get_logger()->info( $message, $context );
+				return;
+			case 'no_stored_hash':
+			case 'not_sync_enabled':
+				self::get_logger()->warning( $message, $context );
+				return;
+			default:
+				self::get_logger()->error( $message, $context );
+		}
+	}
+
+	/**
+	 * Whether the auto-applier is currently rewriting a post.
+	 *
+	 * Future `save_post` listeners (RSM-143, RSM-145) should consult this flag
+	 * to differentiate merchant edits from system-initiated writes.
+	 *
+	 * @return bool
+	 *
+	 * @since 10.8.0
+	 */
+	public static function is_auto_applying(): bool {
+		return self::$is_auto_applying;
+	}
+
+	/**
+	 * Override the logger implementation. Intended for tests only.
+	 *
+	 * @internal
+	 *
+	 * @param Email_Editor_Logger_Interface|null $logger The logger implementation, or null to restore the default.
+	 */
+	public static function set_logger( ?Email_Editor_Logger_Interface $logger ): void {
+		self::$logger = $logger;
+	}
+
+	/**
+	 * Return the logger instance, lazily creating it the first time.
+	 *
+	 * @return Email_Editor_Logger_Interface
+	 */
+	private static function get_logger(): Email_Editor_Logger_Interface {
+		if ( null === self::$logger ) {
+			self::$logger = new Logger( wc_get_logger() );
+		}
+
+		return self::$logger;
+	}
+
+	/**
+	 * Validate that a string is shaped like a SHA-1 hex digest.
+	 *
+	 * @param string $hash Candidate hash value.
+	 * @return bool True when the value is a 40-character hex string.
+	 */
+	private static function is_sha1_hash( string $hash ): bool {
+		return 40 === strlen( $hash ) && ctype_xdigit( $hash );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
index 717e6c51eb5..02d438eb93d 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
@@ -181,6 +181,19 @@ class WCEmailTemplateDivergenceDetector {
 				continue;
 			}//end try
 		}//end foreach
+
+		/**
+		 * Fires once after the post-upgrade divergence sweep finishes classifying
+		 * every sync-registered email post.
+		 *
+		 * Hooked by {@see \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateAutoApplier::schedule()}
+		 * to enqueue the batched auto-apply job for posts classified as
+		 * `core_updated_uncustomized`. Fires unconditionally — auto-applier
+		 * short-circuits when no candidates exist.
+		 *
+		 * @since 10.8.0
+		 */
+		do_action( 'woocommerce_email_template_divergence_sweep_complete' );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
index fd592a6de1f..691fe2debcb 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
@@ -526,6 +526,52 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Should leave post and sync meta untouched when wp_update_post fails.
+	 */
+	public function test_reset_response_returns_wp_error_when_wp_update_post_fails(): void {
+		$email_type = 'customer_new_account';
+
+		$generator = new WCTransactionalEmailPostsGenerator();
+		$generator->init_default_transactional_emails();
+
+		$post_manager = WCTransactionalEmailPostsManager::get_instance();
+		$post_manager->clear_caches();
+		$post_manager->delete_email_template( $email_type );
+		WCEmailTemplateSyncRegistry::reset_cache();
+
+		$post_id = $generator->generate_email_template_if_not_exists( $email_type );
+		$this->assertIsInt( $post_id );
+
+		$pre_call_content = (string) get_post( $post_id )->post_content;
+		$pre_call_meta    = array(
+			'version'        => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ),
+			'source_hash'    => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ),
+			'last_synced_at' => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true ),
+			'status'         => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+		);
+
+		add_filter( 'wp_insert_post_empty_content', '__return_true' );
+
+		$request = new \WP_REST_Request( 'POST', '/woocommerce-email-editor/v1/emails/' . $post_id . '/reset' );
+		$request->set_param( 'id', $post_id );
+
+		$result = $this->email_api_controller->reset_response( $request );
+
+		remove_filter( 'wp_insert_post_empty_content', '__return_true' );
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertSame( 'woocommerce_email_reset_failed', $result->get_error_code() );
+		$this->assertSame( 500, $result->get_error_data()['status'] );
+
+		clean_post_cache( $post_id );
+		$this->assertSame( $pre_call_content, (string) get_post( $post_id )->post_content, 'post_content must be untouched after wp_update_post failure.' );
+		$this->assertSame( $pre_call_meta['version'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ) );
+		$this->assertSame( $pre_call_meta['source_hash'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ) );
+		$this->assertSame( $pre_call_meta['last_synced_at'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true ) );
+		$this->assertSame( $pre_call_meta['status'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ) );
+	}
+
 	/**
 	 * @testdox Should return 500 when controller has not been initialized.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
new file mode 100644
index 00000000000..10700fd80bf
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
@@ -0,0 +1,1035 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\WCTransactionalEmails;
+
+use Automattic\WooCommerce\Internal\EmailEditor\Integration;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateAutoApplier;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
+
+/**
+ * Tests for the WCEmailTemplateAutoApplier class.
+ */
+class WCEmailTemplateAutoApplierTest extends \WC_Unit_Test_Case {
+	/**
+	 * Absolute path to the fixtures directory shared with the divergence detector tests.
+	 *
+	 * @var string
+	 */
+	private string $fixtures_base;
+
+	/**
+	 * Keys injected into \WC_Emails::$emails during the current test.
+	 *
+	 * @var string[]
+	 */
+	private array $injected_email_keys = array();
+
+	/**
+	 * Transactional email post manager singleton.
+	 *
+	 * @var WCTransactionalEmailPostsManager
+	 */
+	private WCTransactionalEmailPostsManager $posts_manager;
+
+	/**
+	 * Setup test case.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		update_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );
+		update_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION, 'yes' );
+
+		// Reuse the divergence-detector fixture file — same shape, same @version header.
+		$this->fixtures_base = dirname( __DIR__ ) . '/WCTransactionalEmails/fixtures/';
+		$this->posts_manager = WCTransactionalEmailPostsManager::get_instance();
+
+		$this->posts_manager->clear_caches();
+		WCEmailTemplateSyncRegistry::reset_cache();
+
+		// In integration runtime the email editor's Templates_Registry registers
+		// the `wooemailtemplate` slug via register_block_template() during admin
+		// bootstrap. Unit tests skip that bootstrap, so wp_update_post( $wp_error = true )
+		// would reject the slug as `invalid_page_template`. Whitelist it via the
+		// theme_{post_type}_templates filter for the duration of the test.
+		add_filter( 'theme_woo_email_templates', array( $this, 'whitelist_email_page_template' ) );
+		add_filter( 'theme_templates', array( $this, 'whitelist_email_page_template' ) );
+	}
+
+	/**
+	 * Cleanup after test.
+	 */
+	public function tearDown(): void {
+		$this->cleanup_injected_emails();
+
+		remove_filter( 'theme_woo_email_templates', array( $this, 'whitelist_email_page_template' ) );
+		remove_filter( 'theme_templates', array( $this, 'whitelist_email_page_template' ) );
+		remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
+		remove_all_filters( 'woocommerce_email_content_post_data' );
+
+		WCEmailTemplateSyncRegistry::reset_cache();
+		WCEmailTemplateAutoApplier::set_logger( null );
+
+		delete_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION );
+		update_option( 'woocommerce_feature_block_email_editor_enabled', 'no' );
+
+		parent::tearDown();
+	}
+
+	/**
+	 * Filter callback — register the `wooemailtemplate` slug so wp_update_post
+	 * with `$wp_error = true` does not bail with `invalid_page_template`.
+	 *
+	 * @param array $templates Existing page templates keyed by slug.
+	 * @return array
+	 */
+	public function whitelist_email_page_template( $templates ): array {
+		$templates                     = is_array( $templates ) ? $templates : array();
+		$templates['wooemailtemplate'] = 'Woo Email Template';
+		return $templates;
+	}
+
+	/**
+	 * apply_to_post() on a sync-enabled post that still matches its stamp must
+	 * rewrite content to the canonical render and stamp all four sync meta keys.
+	 */
+	public function test_apply_to_post_writes_canonical_content_and_stamps_meta(): void {
+		$email_id = 'wc_test_auto_apply_happy_path';
+		$post_id  = $this->generate_stamped_post( $email_id );
+
+		$emails_by_id = $this->posts_manager->get_emails_by_id();
+		$email        = $emails_by_id[ $email_id ];
+
+		// Simulate a core-template change by mutating the canonical content via the
+		// woocommerce_email_content_post_data filter for the duration of this test —
+		// keeps the stored hash from RSM-137 stamping intact while making the
+		// auto-applier's recomputed canonical hash differ.
+		add_filter(
+			'woocommerce_email_content_post_data',
+			static function ( array $post_data ) use ( $email_id ): array {
+				if ( ( $post_data['post_name'] ?? '' ) === $email_id ) {
+					$post_data['post_content'] = (string) ( $post_data['post_content'] ?? '' ) . "\n<!-- new core release -->";
+				}
+				return $post_data;
+			}
+		);
+
+		$expected_canonical = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+		$expected_hash      = sha1( $expected_canonical );
+		$expected_version   = (string) WCEmailTemplateSyncRegistry::get_email_sync_config( $email_id )['version'];
+
+		$result = WCEmailTemplateAutoApplier::apply_to_post( $email, $post_id );
+
+		$this->assertIsArray( $result, 'Atom must return an array on success.' );
+		$this->assertArrayHasKey( 'content', $result );
+		$this->assertSame( $expected_canonical, $result['content'] );
+		$this->assertSame( $expected_version, $result['version'] );
+		$this->assertSame( $expected_hash, $result['source_hash'] );
+		$this->assertSame( WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC, $result['status'] );
+		$this->assertMatchesRegularExpression( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', (string) $result['synced_at'] );
+
+		$post = get_post( $post_id );
+		$this->assertSame( $expected_canonical, (string) $post->post_content, 'Post content must be the new canonical render.' );
+
+		$this->assertSame( $expected_hash, (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ) );
+		$this->assertSame( $expected_version, (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ) );
+		$this->assertNotSame( '', (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true ) );
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+		);
+	}
+
+	/**
+	 * apply_to_post() with require_uncustomized=true must return a WP_Error and
+	 * leave the post untouched when the merchant has edited it since stamping.
+	 */
+	public function test_apply_to_post_with_require_uncustomized_returns_wp_error_when_post_modified(): void {
+		$email_id = 'wc_test_auto_apply_modified_since_stamp';
+		$post_id  = $this->generate_stamped_post( $email_id );
+
+		$emails_by_id = $this->posts_manager->get_emails_by_id();
+		$email        = $emails_by_id[ $email_id ];
+
+		// Simulate a merchant edit: rewrite post_content directly so its hash no longer
+		// matches the stored stamp, but leave the meta keys in place.
+		wp_update_post(
+			array(
+				'ID'           => $post_id,
+				'post_content' => '<p>Merchant-edited content</p>',
+			)
+		);
+
+		$pre_call_meta = array(
+			'source_hash'    => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ),
+			'version'        => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ),
+			'last_synced_at' => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true ),
+		);
+
+		$result = WCEmailTemplateAutoApplier::apply_to_post( $email, $post_id );
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertSame( 'post_modified_since_stamp', $result->get_error_code() );
+
+		$post = get_post( $post_id );
+		$this->assertSame( '<p>Merchant-edited content</p>', (string) $post->post_content, 'Atom must not rewrite content when hash gate fails.' );
+
+		$this->assertSame( $pre_call_meta['source_hash'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ) );
+		$this->assertSame( $pre_call_meta['version'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ) );
+		$this->assertSame( $pre_call_meta['last_synced_at'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true ) );
+	}
+
+	/**
+	 * apply_to_post() must return WP_Error('no_stored_hash') when the source-hash
+	 * meta is missing, even if the post itself looks valid.
+	 */
+	public function test_apply_to_post_returns_wp_error_when_no_stored_hash(): void {
+		$email_id = 'wc_test_auto_apply_no_stored_hash';
+		$post_id  = $this->generate_stamped_post( $email_id );
+
+		$emails_by_id = $this->posts_manager->get_emails_by_id();
+		$email        = $emails_by_id[ $email_id ];
+
+		delete_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY );
+		$pre_call_content = (string) get_post( $post_id )->post_content;
+
+		$result = WCEmailTemplateAutoApplier::apply_to_post( $email, $post_id );
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertSame( 'no_stored_hash', $result->get_error_code() );
+
+		$this->assertSame( $pre_call_content, (string) get_post( $post_id )->post_content );
+	}
+
+	/**
+	 * apply_to_post() with require_uncustomized=true on a non-sync-enabled email
+	 * must return WP_Error('not_sync_enabled') and not write anything.
+	 */
+	public function test_apply_to_post_for_non_sync_enabled_email_with_require_uncustomized_true(): void {
+		$email_id = 'wc_test_auto_apply_non_sync_enabled_strict';
+
+		// Generate a stamped post, then nuke its registry membership so the email is
+		// no longer sync-enabled at apply time. Using a registry-cache reset keeps the
+		// post itself intact (with all four meta keys) so we can assert no writes.
+		$post_id = $this->generate_stamped_post( $email_id );
+
+		$emails_by_id = $this->posts_manager->get_emails_by_id();
+		$email        = $emails_by_id[ $email_id ];
+
+		// Drop the email out of the block-editor opt-in filter for the rest of the test.
+		remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
+		WCEmailTemplateSyncRegistry::reset_cache();
+
+		$pre_call_content = (string) get_post( $post_id )->post_content;
+		$pre_call_status  = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true );
+
+		$result = WCEmailTemplateAutoApplier::apply_to_post( $email, $post_id );
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertSame( 'not_sync_enabled', $result->get_error_code() );
+
+		$this->assertSame( $pre_call_content, (string) get_post( $post_id )->post_content );
+		$this->assertSame( $pre_call_status, (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ) );
+	}
+
+	/**
+	 * apply_to_post() must return WP_Error('post_not_found') when the post ID
+	 * doesn't resolve to a woo_email post.
+	 */
+	public function test_apply_to_post_returns_wp_error_when_post_not_found(): void {
+		// Register a sync-enabled fixture email but do NOT generate a post for it.
+		$email_id = 'wc_test_auto_apply_post_not_found';
+		$email    = $this->register_fixture_email( $email_id );
+
+		$result = WCEmailTemplateAutoApplier::apply_to_post( $email, 999999999 );
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertSame( 'post_not_found', $result->get_error_code() );
+	}
+
+	/**
+	 * apply_to_post() with require_uncustomized=false must overwrite a modified
+	 * post unconditionally — no hash gate. This is the reset-endpoint contract.
+	 */
+	public function test_apply_to_post_without_require_uncustomized_overwrites_modified_post(): void {
+		$email_id = 'wc_test_auto_apply_reset_overwrites_modified';
+		$post_id  = $this->generate_stamped_post( $email_id );
+
+		$emails_by_id = $this->posts_manager->get_emails_by_id();
+		$email        = $emails_by_id[ $email_id ];
+
+		wp_update_post(
+			array(
+				'ID'           => $post_id,
+				'post_content' => '<p>Merchant-edited content that the reset must overwrite</p>',
+			)
+		);
+
+		$expected_canonical = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+
+		$result = WCEmailTemplateAutoApplier::apply_to_post(
+			$email,
+			$post_id,
+			array( 'require_uncustomized' => false )
+		);
+
+		$this->assertIsArray( $result );
+		$this->assertSame( $expected_canonical, $result['content'] );
+		$this->assertSame( WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC, $result['status'] );
+
+		$this->assertSame( $expected_canonical, (string) get_post( $post_id )->post_content );
+	}
+
+	/**
+	 * apply_to_post() with require_uncustomized=false on a non-sync-enabled email
+	 * must rewrite content but stamp NO meta. Return shape carries null for the
+	 * four sync fields. BC contract from the pre-RSM-139 reset endpoint.
+	 */
+	public function test_apply_to_post_for_non_sync_enabled_email_with_require_uncustomized_false(): void {
+		$email_id = 'wc_test_auto_apply_reset_non_sync_enabled';
+		$email    = $this->register_fixture_email( $email_id );
+
+		// Generate a post via direct post insert (bypass the generator's RSM-137 stamping)
+		// so the post has no sync meta to begin with — closest analogue to a non-sync-enabled
+		// email's persisted state.
+		$post_id = wp_insert_post(
+			array(
+				'post_type'    => \Automattic\WooCommerce\Internal\EmailEditor\Integration::EMAIL_POST_TYPE,
+				'post_status'  => 'publish',
+				'post_name'    => $email_id,
+				'post_title'   => 'Non-sync-enabled fixture',
+				'post_content' => '<p>Initial non-canonical content</p>',
+			)
+		);
+		$this->assertIsInt( $post_id );
+
+		// Drop the email out of the registry so apply_to_post sees null sync_config.
+		remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
+		WCEmailTemplateSyncRegistry::reset_cache();
+
+		$expected_canonical = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+
+		$result = WCEmailTemplateAutoApplier::apply_to_post(
+			$email,
+			$post_id,
+			array( 'require_uncustomized' => false )
+		);
+
+		$this->assertIsArray( $result );
+		$this->assertArrayHasKey( 'content', $result );
+		$this->assertSame( $expected_canonical, $result['content'] );
+		$this->assertNull( $result['version'] );
+		$this->assertNull( $result['source_hash'] );
+		$this->assertNull( $result['synced_at'] );
+		$this->assertNull( $result['status'] );
+
+		$this->assertSame( $expected_canonical, (string) get_post( $post_id )->post_content );
+		$this->assertSame( '', (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ) );
+		$this->assertSame( '', (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ) );
+	}
+
+	/**
+	 * When wp_update_post() fails the atom must short-circuit before the meta
+	 * writes, so neither post_content nor any of the four sync meta keys are
+	 * modified. Matches the pre-RSM-139 reset endpoint contract.
+	 */
+	public function test_apply_to_post_returns_wp_error_when_wp_update_post_fails(): void {
+		$email_id = 'wc_test_auto_apply_rollback';
+		$post_id  = $this->generate_stamped_post( $email_id );
+
+		$emails_by_id = $this->posts_manager->get_emails_by_id();
+		$email        = $emails_by_id[ $email_id ];
+
+		$pre_call_content = (string) get_post( $post_id )->post_content;
+		$pre_call_meta    = array(
+			'source_hash'    => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ),
+			'version'        => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ),
+			'last_synced_at' => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true ),
+			'status'         => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+		);
+
+		// Force wp_update_post to fail by short-circuiting it through the
+		// 'wp_insert_post_empty_content' filter — when this returns true,
+		// wp_update_post returns WP_Error('empty_content').
+		add_filter( 'wp_insert_post_empty_content', '__return_true' );
+
+		try {
+			$result = WCEmailTemplateAutoApplier::apply_to_post( $email, $post_id );
+		} finally {
+			remove_filter( 'wp_insert_post_empty_content', '__return_true' );
+		}
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+
+		clean_post_cache( $post_id );
+		$this->assertSame( $pre_call_content, (string) get_post( $post_id )->post_content );
+		$this->assertSame( $pre_call_meta['source_hash'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ) );
+		$this->assertSame( $pre_call_meta['version'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ) );
+		$this->assertSame( $pre_call_meta['last_synced_at'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true ) );
+		$this->assertSame( $pre_call_meta['status'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ) );
+	}
+
+	/**
+	 * is_auto_applying() must read true while wp_update_post is running inside the
+	 * atom and false again once apply_to_post returns. Verified by hooking save_post
+	 * (which fires from inside wp_update_post) and capturing the flag value there.
+	 */
+	public function test_apply_to_post_sets_is_auto_applying_flag_during_write(): void {
+		$email_id = 'wc_test_auto_apply_reentrancy_flag';
+		$post_id  = $this->generate_stamped_post( $email_id );
+
+		$emails_by_id = $this->posts_manager->get_emails_by_id();
+		$email        = $emails_by_id[ $email_id ];
+
+		$captured_during_save = null;
+		$listener             = static function () use ( &$captured_during_save ): void {
+			$captured_during_save = WCEmailTemplateAutoApplier::is_auto_applying();
+		};
+		add_action( 'save_post', $listener );
+
+		// Drive the auto-applier path via the require_uncustomized=false branch so
+		// we don't have to manufacture a core-template change here.
+		try {
+			WCEmailTemplateAutoApplier::apply_to_post(
+				$email,
+				$post_id,
+				array( 'require_uncustomized' => false )
+			);
+		} finally {
+			remove_action( 'save_post', $listener );
+		}
+
+		$this->assertTrue( $captured_during_save, 'is_auto_applying() must read true inside the write block.' );
+		$this->assertFalse( WCEmailTemplateAutoApplier::is_auto_applying(), 'is_auto_applying() must read false after apply_to_post returns.' );
+	}
+
+	/**
+	 * schedule() must enqueue an async Action Scheduler action under the
+	 * dedicated email-editor group.
+	 */
+	public function test_schedule_enqueues_action_scheduler_job(): void {
+		// Make sure no leftover action exists from a previous test or session.
+		as_unschedule_all_actions(
+			WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK,
+			array(),
+			WCEmailTemplateAutoApplier::AUTO_APPLY_AS_GROUP
+		);
+
+		WCEmailTemplateAutoApplier::schedule();
+
+		$this->assertTrue(
+			(bool) as_has_scheduled_action(
+				WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK,
+				array(),
+				WCEmailTemplateAutoApplier::AUTO_APPLY_AS_GROUP
+			)
+		);
+
+		// Cleanup so subsequent tests start with a clean queue.
+		as_unschedule_all_actions(
+			WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK,
+			array(),
+			WCEmailTemplateAutoApplier::AUTO_APPLY_AS_GROUP
+		);
+	}
+
+	/**
+	 * Calling schedule() twice in the same request (e.g. once from
+	 * woocommerce_updated and once from BACKFILL_COMPLETE_ACTION) must not
+	 * enqueue two pending actions.
+	 */
+	public function test_schedule_does_not_double_enqueue(): void {
+		as_unschedule_all_actions(
+			WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK,
+			array(),
+			WCEmailTemplateAutoApplier::AUTO_APPLY_AS_GROUP
+		);
+
+		WCEmailTemplateAutoApplier::schedule();
+		WCEmailTemplateAutoApplier::schedule();
+
+		$pending = as_get_scheduled_actions(
+			array(
+				'hook'   => WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK,
+				'group'  => WCEmailTemplateAutoApplier::AUTO_APPLY_AS_GROUP,
+				'status' => \ActionScheduler_Store::STATUS_PENDING,
+			),
+			'ids'
+		);
+
+		$this->assertCount( 1, $pending, 'schedule() must guard against double-enqueueing.' );
+
+		as_unschedule_all_actions(
+			WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK,
+			array(),
+			WCEmailTemplateAutoApplier::AUTO_APPLY_AS_GROUP
+		);
+	}
+
+	/**
+	 * run() must apply only to posts whose status meta is core_updated_uncustomized,
+	 * leaving in_sync and core_updated_customized posts untouched.
+	 */
+	public function test_run_applies_to_every_uncustomized_post(): void {
+		// 3 posts, each with a distinct fixture email so each registers in the registry.
+		$uncustomized_post_id = $this->generate_stamped_post( 'wc_test_run_uncustomized' );
+		$customized_post_id   = $this->generate_stamped_post( 'wc_test_run_customized' );
+		$in_sync_post_id      = $this->generate_stamped_post( 'wc_test_run_in_sync' );
+
+		update_post_meta(
+			$uncustomized_post_id,
+			WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+			WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED
+		);
+		update_post_meta(
+			$customized_post_id,
+			WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+			WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED
+		);
+		update_post_meta(
+			$in_sync_post_id,
+			WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+			WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC
+		);
+
+		$content_before_customized = (string) get_post( $customized_post_id )->post_content;
+		$content_before_in_sync    = (string) get_post( $in_sync_post_id )->post_content;
+
+		WCEmailTemplateAutoApplier::run();
+
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+			(string) get_post_meta( $uncustomized_post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+			'Uncustomized post must flip to in_sync after run().'
+		);
+
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED,
+			(string) get_post_meta( $customized_post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+			'Customized post status must be untouched.'
+		);
+		$this->assertSame( $content_before_customized, (string) get_post( $customized_post_id )->post_content );
+
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+			(string) get_post_meta( $in_sync_post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+		);
+		$this->assertSame( $content_before_in_sync, (string) get_post( $in_sync_post_id )->post_content );
+	}
+
+	/**
+	 * One bad post must not break the rest of the batch. The atom failure is
+	 * logged at error severity with context=email_template_auto_applier.
+	 */
+	public function test_run_logs_and_continues_when_one_post_fails(): void {
+		$failing_post_id = $this->generate_stamped_post( 'wc_test_run_failure_isolation_fail' );
+		$good_post_id    = $this->generate_stamped_post( 'wc_test_run_failure_isolation_good' );
+
+		update_post_meta( $failing_post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED );
+		update_post_meta( $good_post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED );
+
+		// Force wp_update_post to fail for the FIRST post only by toggling the filter
+		// on/off based on which post ID is being saved.
+		add_filter(
+			'wp_insert_post_empty_content',
+			static function ( $maybe_empty, $postarr ) use ( $failing_post_id ) {
+				return ( ( (int) ( $postarr['ID'] ?? 0 ) ) === $failing_post_id ) ? true : $maybe_empty;
+			},
+			10,
+			2
+		);
+
+		$captured = array();
+		WCEmailTemplateAutoApplier::set_logger( $this->build_recording_logger( $captured ) );
+
+		try {
+			WCEmailTemplateAutoApplier::run();
+		} finally {
+			remove_all_filters( 'wp_insert_post_empty_content' );
+		}
+
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED,
+			(string) get_post_meta( $failing_post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+			'Failing post status must be untouched (no rewrite happened).'
+		);
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+			(string) get_post_meta( $good_post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+			'Good post must still be applied despite the prior failure.'
+		);
+
+		$error_logs = array_filter(
+			$captured,
+			static fn( array $entry ) => 'error' === $entry['level']
+				&& ( $entry['context']['context'] ?? '' ) === 'email_template_auto_applier'
+		);
+		$this->assertCount( 1, $error_logs, 'Exactly one error log entry must be emitted for the failing post.' );
+	}
+
+	/**
+	 * If the post was modified since stamping (race window between sweep and AS job),
+	 * run() must skip it at info severity, leave content/meta untouched, and not
+	 * roll the status meta back.
+	 */
+	public function test_run_skips_post_with_post_modified_since_stamp_at_info_severity(): void {
+		$post_id = $this->generate_stamped_post( 'wc_test_run_race_safety' );
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED );
+
+		// Simulate merchant edit during the AS lag window.
+		wp_update_post(
+			array(
+				'ID'           => $post_id,
+				'post_content' => '<p>Merchant-edited content during AS lag</p>',
+			)
+		);
+
+		$captured = array();
+		WCEmailTemplateAutoApplier::set_logger( $this->build_recording_logger( $captured ) );
+
+		WCEmailTemplateAutoApplier::run();
+
+		$this->assertSame( '<p>Merchant-edited content during AS lag</p>', (string) get_post( $post_id )->post_content );
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+			'Status must remain core_updated_uncustomized so the next sweep can re-classify.'
+		);
+
+		$info_logs = array_filter(
+			$captured,
+			static fn( array $entry ) => 'info' === $entry['level']
+				&& ( $entry['context']['context'] ?? '' ) === 'email_template_auto_applier'
+		);
+		$this->assertCount( 1, $info_logs, 'Race outcome must be logged at info severity.' );
+
+		$error_logs = array_filter( $captured, static fn( array $entry ) => 'error' === $entry['level'] );
+		$this->assertCount( 0, $error_logs, 'Race outcome must NOT log at error severity.' );
+	}
+
+	/**
+	 * run() with no candidates must be a no-op.
+	 */
+	public function test_run_is_a_no_op_when_no_uncustomized_posts_exist(): void {
+		$post_id = $this->generate_stamped_post( 'wc_test_run_no_op' );
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC );
+
+		$content_before = (string) get_post( $post_id )->post_content;
+
+		$write_count = 0;
+		$counter     = static function ( $check, int $object_id, string $meta_key ) use ( &$write_count ) {
+			unset( $object_id, $meta_key );
+			++$write_count;
+			return $check;
+		};
+		add_filter( 'update_post_metadata', $counter, 10, 3 );
+
+		try {
+			WCEmailTemplateAutoApplier::run();
+		} finally {
+			remove_filter( 'update_post_metadata', $counter, 10 );
+		}
+
+		$this->assertSame( 0, $write_count, 'No meta writes must occur when there are no candidates.' );
+		$this->assertSame( $content_before, (string) get_post( $post_id )->post_content );
+	}
+
+	/**
+	 * Two consecutive run() calls on the same state — the first applies and flips
+	 * status to in_sync, the second writes zero rows. (Acceptance criterion.)
+	 */
+	public function test_run_is_idempotent_across_repeat_invocations(): void {
+		$post_id = $this->generate_stamped_post( 'wc_test_run_idempotency' );
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED );
+
+		WCEmailTemplateAutoApplier::run();
+
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+		);
+
+		$write_count = 0;
+		$counter     = static function ( $check ) use ( &$write_count ) {
+			++$write_count;
+			return $check;
+		};
+		add_filter( 'update_post_metadata', $counter, 10, 1 );
+		add_filter( 'wp_insert_post_data', $counter, 10, 1 );
+
+		try {
+			WCEmailTemplateAutoApplier::run();
+		} finally {
+			remove_filter( 'update_post_metadata', $counter, 10 );
+			remove_filter( 'wp_insert_post_data', $counter, 10 );
+		}
+
+		$this->assertSame( 0, $write_count, 'Second run must write zero rows.' );
+	}
+
+	/**
+	 * run() must short-circuit when BACKFILL_COMPLETE_OPTION is not 'yes', even if
+	 * core_updated_uncustomized posts exist in the DB.
+	 */
+	public function test_run_respects_backfill_complete_option_gate(): void {
+		$post_id = $this->generate_stamped_post( 'wc_test_run_backfill_gate' );
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED );
+
+		update_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION, 'no' );
+
+		$content_before = (string) get_post( $post_id )->post_content;
+
+		WCEmailTemplateAutoApplier::run();
+
+		$this->assertSame( $content_before, (string) get_post( $post_id )->post_content );
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+		);
+	}
+
+	/**
+	 * Posts whose registered email is no longer in the sync registry at run time
+	 * must be skipped silently — no log, no write. (Plugin deactivated mid-cycle.)
+	 */
+	public function test_run_skips_posts_for_deactivated_email_plugins(): void {
+		$post_id = $this->generate_stamped_post( 'wc_test_run_deactivated' );
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED );
+
+		$content_before = (string) get_post( $post_id )->post_content;
+
+		// Drop the email out of the registry.
+		remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
+		WCEmailTemplateSyncRegistry::reset_cache();
+
+		$captured = array();
+		WCEmailTemplateAutoApplier::set_logger( $this->build_recording_logger( $captured ) );
+
+		WCEmailTemplateAutoApplier::run();
+
+		$this->assertSame( $content_before, (string) get_post( $post_id )->post_content );
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+		);
+		$this->assertSame( array(), $captured, 'Deactivated-plugin skip must be silent (no log emission).' );
+	}
+
+	/**
+	 * The candidate query must accept any non-trash post status — generated
+	 * woo_email posts default to `publish` but third-party flows may move them
+	 * to `draft` / `private` / `pending` / `future` and those must not be
+	 * silently skipped.
+	 *
+	 * @dataProvider provider_non_trash_post_statuses
+	 *
+	 * @param string $post_status Post status to stamp on the candidate.
+	 */
+	public function test_run_applies_to_candidate_regardless_of_non_trash_post_status( string $post_status ): void {
+		global $wpdb;
+
+		$post_id = $this->generate_stamped_post( 'wc_test_run_status_' . $post_status );
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED );
+
+		// Flip status via a direct UPDATE: wp_update_post calls map_meta_cap, which
+		// emits a _doing_it_wrong notice in the unit-test bootstrap because the
+		// woo_email post type is not registered there (Integration::initialize() is
+		// not run). The candidate query is the surface under test; status flipping
+		// is incidental.
+		$wpdb->update( $wpdb->posts, array( 'post_status' => $post_status ), array( 'ID' => $post_id ) );
+		clean_post_cache( $post_id );
+
+		WCEmailTemplateAutoApplier::run();
+
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+			"Auto-applier must process candidates whose post_status is {$post_status}."
+		);
+	}
+
+	/**
+	 * Data provider for {@see self::test_run_applies_to_candidate_regardless_of_non_trash_post_status()}.
+	 *
+	 * `pending` is intentionally omitted: WP's `wp_update_post` invokes
+	 * `map_meta_cap('publish_post', ...)` for any write touching a pending post,
+	 * which emits a _doing_it_wrong notice in the unit-test bootstrap because the
+	 * `woo_email` post type is not registered there. Pending isn't a realistic
+	 * status for transactional email templates anyway.
+	 *
+	 * @return array<string, array<int, string>>
+	 */
+	public function provider_non_trash_post_statuses(): array {
+		return array(
+			'draft'   => array( 'draft' ),
+			'private' => array( 'private' ),
+			'future'  => array( 'future' ),
+		);
+	}
+
+	/**
+	 * Trashed posts must NOT be picked up by the candidate query — the WP_Query
+	 * `post_status=any` clause already excludes the trash bucket and we want to
+	 * lock that in.
+	 */
+	public function test_run_skips_trashed_posts(): void {
+		$post_id = $this->generate_stamped_post( 'wc_test_run_trashed' );
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED );
+
+		wp_trash_post( $post_id );
+		clean_post_cache( $post_id );
+
+		$content_before = (string) get_post( $post_id )->post_content;
+
+		WCEmailTemplateAutoApplier::run();
+
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+			'Trashed posts must not have their status flipped by the auto-applier.'
+		);
+		$this->assertSame( $content_before, (string) get_post( $post_id )->post_content );
+	}
+
+	/**
+	 * Firing the divergence-sweep completion action must trigger the auto-applier
+	 * to enqueue an AS job. This locks in the wiring that lives in
+	 * Integration::register_hooks().
+	 */
+	public function test_detector_sweep_complete_action_triggers_schedule(): void {
+		// The unit-test bootstrap does not run Integration::initialize() (which calls
+		// register_hooks()). Force it for this single test so the wiring under test
+		// is actually present on the global hook table.
+		wc_get_container()->get( Integration::class )->initialize();
+
+		as_unschedule_all_actions(
+			WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK,
+			array(),
+			WCEmailTemplateAutoApplier::AUTO_APPLY_AS_GROUP
+		);
+
+		/**
+		 * Trigger the production hook the auto-applier listens to; the assertions
+		 * below confirm `Integration::register_hooks()` routes it to `schedule()`.
+		 *
+		 * @since 10.8.0
+		 */
+		do_action( 'woocommerce_email_template_divergence_sweep_complete' );
+
+		$this->assertTrue(
+			(bool) as_has_scheduled_action(
+				WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK,
+				array(),
+				WCEmailTemplateAutoApplier::AUTO_APPLY_AS_GROUP
+			),
+			'Integration::register_hooks() must wire schedule() to the divergence-sweep completion action.'
+		);
+
+		as_unschedule_all_actions(
+			WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK,
+			array(),
+			WCEmailTemplateAutoApplier::AUTO_APPLY_AS_GROUP
+		);
+	}
+
+	/**
+	 * Build a recording logger that captures every call into a flat array. Each entry
+	 * is shaped: [ 'level' => 'info'|'warning'|'error', 'message' => string, 'context' => array ].
+	 *
+	 * @param array<int, array<string, mixed>> $sink Reference to the array that will receive entries.
+	 * @return \Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger_Interface
+	 */
+	private function build_recording_logger( array &$sink ): \Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger_Interface {
+		return new class( $sink ) implements \Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger_Interface {
+			/** @var array<int, array<string, mixed>> */
+			private array $sink;
+
+			/**
+			 * @param array<int, array<string, mixed>> $sink Reference to the array that will receive entries.
+			 */
+			public function __construct( array &$sink ) {
+				$this->sink = &$sink;
+			}
+
+			/**
+			 * @param string               $message The log message.
+			 * @param array<string, mixed> $context The log context.
+			 */
+			public function emergency( string $message, array $context = array() ): void {
+				$this->capture( 'emergency', $message, $context );
+			}
+
+			/**
+			 * @param string               $message The log message.
+			 * @param array<string, mixed> $context The log context.
+			 */
+			public function alert( string $message, array $context = array() ): void {
+				$this->capture( 'alert', $message, $context );
+			}
+
+			/**
+			 * @param string               $message The log message.
+			 * @param array<string, mixed> $context The log context.
+			 */
+			public function critical( string $message, array $context = array() ): void {
+				$this->capture( 'critical', $message, $context );
+			}
+
+			/**
+			 * @param string               $message The log message.
+			 * @param array<string, mixed> $context The log context.
+			 */
+			public function error( string $message, array $context = array() ): void {
+				$this->capture( 'error', $message, $context );
+			}
+
+			/**
+			 * @param string               $message The log message.
+			 * @param array<string, mixed> $context The log context.
+			 */
+			public function warning( string $message, array $context = array() ): void {
+				$this->capture( 'warning', $message, $context );
+			}
+
+			/**
+			 * @param string               $message The log message.
+			 * @param array<string, mixed> $context The log context.
+			 */
+			public function notice( string $message, array $context = array() ): void {
+				$this->capture( 'notice', $message, $context );
+			}
+
+			/**
+			 * @param string               $message The log message.
+			 * @param array<string, mixed> $context The log context.
+			 */
+			public function info( string $message, array $context = array() ): void {
+				$this->capture( 'info', $message, $context );
+			}
+
+			/**
+			 * @param string               $message The log message.
+			 * @param array<string, mixed> $context The log context.
+			 */
+			public function debug( string $message, array $context = array() ): void {
+				$this->capture( 'debug', $message, $context );
+			}
+
+			/**
+			 * @param string               $level   The log level.
+			 * @param string               $message The log message.
+			 * @param array<string, mixed> $context The log context.
+			 */
+			public function log( string $level, string $message, array $context = array() ): void {
+				$this->capture( $level, $message, $context );
+			}
+
+			/**
+			 * Append a captured log call to the sink.
+			 *
+			 * @param string               $level   The log level.
+			 * @param string               $message The log message.
+			 * @param array<string, mixed> $context The log context.
+			 */
+			private function capture( string $level, string $message, array $context ): void {
+				$this->sink[] = array(
+					'level'   => $level,
+					'message' => $message,
+					'context' => $context,
+				);
+			}
+		};
+	}
+
+	/**
+	 * Build a WC_Email stub backed by the third-party-with-version.php fixture, inject it
+	 * into WC_Emails::$emails, and opt the email ID into the block-editor filter so the
+	 * sync registry picks it up.
+	 *
+	 * @param string $email_id Email ID to assign to the stub.
+	 * @return \WC_Email Registered fixture email instance.
+	 */
+	private function register_fixture_email( string $email_id ): \WC_Email {
+		$stub = $this->getMockBuilder( \WC_Email::class )
+			->disableOriginalConstructor()
+			->getMock();
+		$stub->method( 'get_title' )->willReturn( 'Fixture email for auto-applier tests' );
+		$stub->method( 'get_description' )->willReturn( 'Fixture email used to cover auto-apply scenarios.' );
+		$stub->id             = $email_id;
+		$stub->template_base  = $this->fixtures_base;
+		$stub->template_block = 'block/third-party-with-version.php';
+		$stub->template_plain = null;
+
+		$class_key = 'WC_Test_Email_' . $email_id;
+
+		$emails_container = \WC_Emails::instance();
+		$reflection       = new \ReflectionClass( $emails_container );
+		$property         = $reflection->getProperty( 'emails' );
+		$property->setAccessible( true );
+		$current               = $property->getValue( $emails_container );
+		$current[ $class_key ] = $stub;
+		$property->setValue( $emails_container, $current );
+
+		$this->injected_email_keys[] = $class_key;
+
+		add_filter(
+			'woocommerce_transactional_emails_for_block_editor',
+			static function ( array $emails ) use ( $email_id ): array {
+				if ( ! in_array( $email_id, $emails, true ) ) {
+					$emails[] = $email_id;
+				}
+				return $emails;
+			}
+		);
+
+		WCEmailTemplateSyncRegistry::reset_cache();
+
+		return $stub;
+	}
+
+	/**
+	 * Drive the real generator flow to produce a stamped woo_email post for the given fixture.
+	 *
+	 * @param string $email_id Email ID to generate a post for.
+	 * @return int The generated post ID.
+	 */
+	private function generate_stamped_post( string $email_id ): int {
+		$this->register_fixture_email( $email_id );
+
+		$generator = new WCTransactionalEmailPostsGenerator();
+		$generator->init_default_transactional_emails();
+		$this->posts_manager->delete_email_template( $email_id );
+
+		$post_id = $generator->generate_email_template_if_not_exists( $email_id );
+
+		$this->assertIsInt( $post_id );
+		$this->assertGreaterThan( 0, $post_id );
+
+		return $post_id;
+	}
+
+	/**
+	 * Remove any stubs we injected into WC_Emails::$emails during the test.
+	 */
+	private function cleanup_injected_emails(): void {
+		if ( empty( $this->injected_email_keys ) ) {
+			return;
+		}
+
+		$emails_container = \WC_Emails::instance();
+		$reflection       = new \ReflectionClass( $emails_container );
+		$property         = $reflection->getProperty( 'emails' );
+		$property->setAccessible( true );
+		$current = $property->getValue( $emails_container );
+		foreach ( $this->injected_email_keys as $key ) {
+			unset( $current[ $key ] );
+		}
+		$property->setValue( $emails_container, $current );
+		$this->injected_email_keys = array();
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
index 38f0ff3434b..e6e6b764037 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
@@ -214,6 +214,30 @@ class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * The sweep must fire `woocommerce_email_template_divergence_sweep_complete`
+	 * unconditionally at end of run, so downstream listeners (RSM-139 auto-applier)
+	 * can hook the completion event without inspecting detector internals.
+	 */
+	public function test_run_sweep_fires_completion_action(): void {
+		$email_id = 'wc_test_divergence_completion_action';
+		$this->generate_stamped_post( $email_id );
+
+		$fired    = 0;
+		$listener = static function () use ( &$fired ): void {
+			++$fired;
+		};
+		add_action( 'woocommerce_email_template_divergence_sweep_complete', $listener );
+
+		try {
+			WCEmailTemplateDivergenceDetector::run_sweep();
+		} finally {
+			remove_action( 'woocommerce_email_template_divergence_sweep_complete', $listener );
+		}
+
+		$this->assertSame( 1, $fired, 'Completion action must fire exactly once per sweep.' );
+	}
+
 	/**
 	 * Build a WC_Email stub backed by the third-party-with-version.php fixture, inject it
 	 * into WC_Emails::$emails, and opt the email ID into the block-editor filter so the