Commit 00bed1a7bf5 for woocommerce

commit 00bed1a7bf5dc3a36432ac0c1c9873e653469d92
Author: Seun Olorunsola <30554163+triple0t@users.noreply.github.com>
Date:   Thu Apr 23 14:35:45 2026 +0100

    Detect block email template divergence on plugin update (#64328)

    * Detect block email template divergence on plugin update

    Hook WCEmailTemplateDivergenceDetector on woocommerce_updated so each
    plugin upgrade classifies every sync-registered woo_email post into
    in_sync, core_updated_uncustomized, or core_updated_customized and
    stamps _wc_email_template_status accordingly.

    Extract build_filtered_post_data, compute_canonical_post_content, and
    render_block_template_html static helpers on the generator so stamping
    and detection hash the same canonical content by construction.

    Made-with: Cursor

    * Add changelog entry for email template divergence detection

    Made-with: Cursor

    * Fix phpcs findings in email template divergence detector

    Add //end try and //end foreach markers for Squiz.Commenting.LongConditionClosingComment
    on the long run_sweep() block, and move the $post_id unset comment to
    the preceding line to satisfy Squiz.Commenting.PostStatementComment.

    Made-with: Cursor

    * Harden email template divergence detector against bad input

    Validate third-party filter return values on `woocommerce_email_block_template_html`
    and `woocommerce_email_content_post_data` in WCTransactionalEmailPostsGenerator,
    falling back to the unfiltered value when an extension returns a non-string or
    non-array. Prevents a TypeError fatal mid-plugin-update.

    Guard classify_post() in WCEmailTemplateDivergenceDetector against malformed
    stored_source_hash meta via a new is_sha1_hash() helper (40 hex chars). Non-SHA-1
    values now short-circuit to null instead of silently falling through every equality
    check and misreporting posts as core_updated_customized.

    Made-with: Cursor

diff --git a/plugins/woocommerce/changelog/add-email-template-divergence-detection b/plugins/woocommerce/changelog/add-email-template-divergence-detection
new file mode 100644
index 00000000000..d3a4d2aa04d
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-email-template-divergence-detection
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Detect divergence between generated block email posts and their source templates after plugin updates, classifying each post as in-sync, core-updated-uncustomized, or core-updated-customized.
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
index cfa01a187fb..5dca63d20e6 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\WCEmailTemplateDivergenceDetector;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmails;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
 use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\TemplateApiController;
@@ -145,6 +146,7 @@ class Integration {
 		add_action( 'woocommerce_email_editor_send_preview_email_after_wp_mail', array( $this, 'send_preview_email_after_wp_mail' ), 10 );
 		add_filter( 'woocommerce_email_editor_send_preview_email_subject', array( $this, 'update_email_subject_for_send_preview_email' ), 10, 2 );
 		add_action( 'rest_api_init', array( $this->email_api_controller, 'register_routes' ) );
+		add_action( 'woocommerce_updated', array( WCEmailTemplateDivergenceDetector::class, 'run_sweep' ), 20 );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
new file mode 100644
index 00000000000..bde1fbcc986
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
@@ -0,0 +1,284 @@
+<?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;
+
+/**
+ * Detects divergence between generated `woo_email` posts and their source block
+ * email templates after WooCommerce is upgraded.
+ *
+ * For every sync-enabled email (see {@see WCEmailTemplateSyncRegistry}) that has
+ * a generated post carrying the `_wc_email_template_source_hash` stamp written by
+ * {@see WCTransactionalEmailPostsGenerator}, this class recomputes the current
+ * core and current post hashes and classifies the post into one of:
+ *
+ * - `in_sync`                    — core and post still match the stamped baseline.
+ * - `core_updated_uncustomized`  — core changed but the post kept pace (no merchant edits).
+ * - `core_updated_customized`    — core changed and the post diverges (merchant customisations).
+ *
+ * The classification is persisted on the post's `_wc_email_template_status` meta
+ * so downstream UI can surface an accurate status. The sweep is idempotent: runs
+ * with unchanged state write zero rows.
+ *
+ * Hash input parity with the stamping path is guaranteed by construction because
+ * both paths route through {@see WCTransactionalEmailPostsGenerator::compute_canonical_post_content()}.
+ *
+ * @package Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
+ * @since 10.8.0
+ */
+class WCEmailTemplateDivergenceDetector {
+	/**
+	 * Option written by the RSM-149 backfill to signal that every existing `woo_email`
+	 * post has been stamped with the RSM-137 sync meta. The detector refuses to run
+	 * until this flag flips to `yes` — otherwise legacy posts would be evaluated with
+	 * no stored hash and silently skipped, giving a misleadingly quiet sweep.
+	 *
+	 * @var string
+	 */
+	public const BACKFILL_COMPLETE_OPTION = 'woocommerce_email_template_sync_backfill_complete';
+
+	/**
+	 * Post meta key the detector writes.
+	 *
+	 * @var string
+	 */
+	public const STATUS_META_KEY = '_wc_email_template_status';
+
+	/**
+	 * Post meta key written by the generator; required for classification.
+	 *
+	 * @var string
+	 */
+	private const SOURCE_HASH_META_KEY = '_wc_email_template_source_hash';
+
+	/**
+	 * Classification outcomes.
+	 */
+	public const STATUS_IN_SYNC                   = 'in_sync';
+	public const STATUS_CORE_UPDATED_UNCUSTOMIZED = 'core_updated_uncustomized';
+	public const STATUS_CORE_UPDATED_CUSTOMIZED   = 'core_updated_customized';
+
+	/**
+	 * Logger instance. Lazily instantiated on first use; overridable for tests.
+	 *
+	 * @var Email_Editor_Logger_Interface|null
+	 */
+	private static $logger = null;
+
+	/**
+	 * Run the post-upgrade divergence sweep.
+	 *
+	 * Intended to be hooked on `woocommerce_updated`, which fires once per WC
+	 * upgrade inside {@see \WC_Install::check_version()} under a distributed
+	 * install lock — that guarantees the sweep runs at most once per upgrade
+	 * without any additional fence option or cache lock on our side.
+	 *
+	 * Early-returns when the RSM-149 backfill has not yet flagged completion, so
+	 * we never classify a half-populated set of posts.
+	 *
+	 * @return void
+	 *
+	 * @since 10.8.0
+	 */
+	public static function run_sweep(): void {
+		if ( 'yes' !== get_option( self::BACKFILL_COMPLETE_OPTION ) ) {
+			return;
+		}
+
+		$registry = WCEmailTemplateSyncRegistry::get_sync_enabled_emails();
+		if ( empty( $registry ) ) {
+			return;
+		}
+
+		$wc_emails = \WC_Emails::instance();
+		/**
+		 * WooCommerce Transactional Emails instance.
+		 *
+		 * @var \WC_Email[]
+		 */
+		$all_emails       = $wc_emails->get_emails();
+		$posts_manager    = WCTransactionalEmailPostsManager::get_instance();
+		$canonical_emails = self::index_emails_by_id( $all_emails );
+
+		foreach ( $registry as $email_id => $_config ) {
+			try {
+				$email = $canonical_emails[ $email_id ] ?? null;
+				if ( ! $email instanceof \WC_Email ) {
+					// Extension providing the email was deactivated; nothing to classify.
+					continue;
+				}
+
+				$post = $posts_manager->get_email_post( (string) $email_id );
+				if ( ! $post instanceof \WP_Post ) {
+					continue;
+				}
+
+				$stored_source_hash = (string) get_post_meta( (int) $post->ID, self::SOURCE_HASH_META_KEY, true );
+				if ( '' === $stored_source_hash ) {
+					// This should not normally occur post-backfill: the generator always stamps
+					// this meta and RSM-149 is supposed to have backfilled pre-existing posts.
+					// Surface at warning so it's visible in the default WC log UI without
+					// requiring operators to lower the email-editor logging threshold.
+					self::get_logger()->warning(
+						sprintf(
+							'Email template divergence sweep skipped post %d for email "%s": no stored source hash.',
+							(int) $post->ID,
+							(string) $email_id
+						),
+						array(
+							'email_id' => (string) $email_id,
+							'post_id'  => (int) $post->ID,
+							'context'  => 'email_template_divergence_detector',
+						)
+					);
+					continue;
+				}
+
+				$status = self::classify_post(
+					(int) $post->ID,
+					$email,
+					array(
+						'post_content'       => (string) $post->post_content,
+						'stored_source_hash' => $stored_source_hash,
+					)
+				);
+
+				if ( null === $status ) {
+					continue;
+				}
+
+				$existing_status = (string) get_post_meta( (int) $post->ID, self::STATUS_META_KEY, true );
+				if ( $existing_status === $status ) {
+					continue;
+				}
+
+				update_post_meta( (int) $post->ID, self::STATUS_META_KEY, $status );
+			} catch ( \Throwable $e ) {
+				self::get_logger()->error(
+					sprintf(
+						'Email template divergence sweep failed for email "%s": %s',
+						(string) $email_id,
+						$e->getMessage()
+					),
+					array(
+						'email_id' => (string) $email_id,
+						'context'  => 'email_template_divergence_detector',
+					)
+				);
+				continue;
+			}//end try
+		}//end foreach
+	}
+
+	/**
+	 * Classify a single generated `woo_email` post.
+	 *
+	 * Pure function: given the inputs, always returns the same outcome.
+	 *
+	 * Classification hinges on two independent questions:
+	 *   1. Has core moved since we stamped the post? (`currentCoreHash !== storedSourceHash`)
+	 *   2. Has the merchant edited the post since we stamped it? (`currentPostHash !== storedSourceHash`)
+	 *
+	 * Note that "uncustomized" here means the merchant has **not** edited the post, which
+	 * is detected by `currentPostHash === storedSourceHash` — NOT by comparing against the
+	 * new core hash. The latter would only hold after an auto-apply step which this code
+	 * path does not perform.
+	 *
+	 * Returns `null` when the stored baseline is ambiguous — i.e. core has not moved but
+	 * the post has drifted from the stamp. In that case the existing status is preserved
+	 * rather than overwritten with a new guess.
+	 *
+	 * @param int       $post_id The post ID (kept in the signature for context in tests and logs).
+	 * @param \WC_Email $email   The registered email instance.
+	 * @param array     $stamps  Map with keys `post_content` (current persisted content) and
+	 *                           `stored_source_hash` (value of `_wc_email_template_source_hash`).
+	 * @return string|null One of the STATUS_* constants, or null when the status should not be updated.
+	 *
+	 * @since 10.8.0
+	 */
+	public static function classify_post( int $post_id, \WC_Email $email, array $stamps ): ?string {
+		// $post_id is surfaced in the signature for future instrumentation and log context; no current use.
+		unset( $post_id );
+
+		$current_core_hash  = sha1( WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email ) );
+		$current_post_hash  = sha1( (string) ( $stamps['post_content'] ?? '' ) );
+		$stored_source_hash = (string) ( $stamps['stored_source_hash'] ?? '' );
+
+		if ( ! self::is_sha1_hash( $stored_source_hash ) ) {
+			return null;
+		}
+
+		// Core has not moved since stamping. If the post also matches the stamp we're in sync;
+		// otherwise the merchant drifted without a core update — ambiguous, leave prior status.
+		if ( $current_core_hash === $stored_source_hash ) {
+			return $current_post_hash === $stored_source_hash ? self::STATUS_IN_SYNC : null;
+		}
+
+		// Core has moved. Did the merchant edit the post since we stamped it?
+		return $current_post_hash === $stored_source_hash
+			? self::STATUS_CORE_UPDATED_UNCUSTOMIZED
+			: self::STATUS_CORE_UPDATED_CUSTOMIZED;
+	}
+
+	/**
+	 * 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.
+	 *
+	 * `_wc_email_template_source_hash` is produced by `sha1()` (40 hex chars), but as
+	 * persisted post meta it is theoretically reachable from DB migrations, direct
+	 * `update_post_meta` calls, or misbehaving extensions. Any non-SHA-1 value would
+	 * otherwise be compared byte-for-byte against real hashes and always report
+	 * `core_updated_customized`, so we short-circuit instead.
+	 *
+	 * @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 );
+	}
+
+	/**
+	 * Build an `email_id => WC_Email` lookup from the list returned by
+	 * `WC_Emails::get_emails()` (which is keyed by class name).
+	 *
+	 * @param array<string, \WC_Email> $all_emails Registered WC_Email instances keyed by class.
+	 * @return array<string, \WC_Email>
+	 */
+	private static function index_emails_by_id( array $all_emails ): array {
+		$indexed = array();
+		foreach ( $all_emails as $email ) {
+			if ( $email instanceof \WC_Email && is_string( $email->id ) && '' !== $email->id ) {
+				$indexed[ $email->id ] = $email;
+			}
+		}
+
+		return $indexed;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
index d3685e8a22d..2c822bd28f2 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
@@ -165,6 +165,24 @@ class WCTransactionalEmailPostsGenerator {
 	 * @return string The email template.
 	 */
 	public function get_email_template( $email ) {
+		return self::render_block_template_html( $email );
+	}
+
+	/**
+	 * Render the block template HTML for a given email.
+	 *
+	 * Resolves the block template (honouring theme overrides), falls back to the
+	 * default block content on failure, and applies the
+	 * `woocommerce_email_block_template_html` filter. Stateless so both the
+	 * generator (via {@see self::get_email_template()}) and the divergence
+	 * detector observe an identical rendering pipeline.
+	 *
+	 * @param \WC_Email $email The email object.
+	 * @return string The rendered template HTML.
+	 *
+	 * @since 10.8.0
+	 */
+	public static function render_block_template_html( $email ): string {
 		$template_name = self::resolve_block_template_name( $email );

 		try {
@@ -204,9 +222,9 @@ class WCTransactionalEmailPostsGenerator {
 		 * @param \WC_Email $email The email object.
 		 * @since 10.7.0
 		 */
-		$template_html = apply_filters( 'woocommerce_email_block_template_html', $template_html, $email );
+		$filtered_template_html = apply_filters( 'woocommerce_email_block_template_html', $template_html, $email );

-		return $template_html;
+		return is_string( $filtered_template_html ) ? $filtered_template_html : $template_html;
 	}

 	/**
@@ -304,23 +322,28 @@ class WCTransactionalEmailPostsGenerator {
 	}

 	/**
-	 * Generate a single email template.
+	 * Build the `wp_insert_post()` payload for a given email and apply the
+	 * `woocommerce_email_content_post_data` filter.
 	 *
-	 * This function generates a single email template post and sets its postmeta association.
+	 * Extracted so the generator and the divergence detector observe the exact
+	 * same pre-insert post payload, guaranteeing by construction that the hash
+	 * stamped in {@see self::generate_single_template()} and the hash recomputed
+	 * in `WCEmailTemplateDivergenceDetector` hash identical input.
 	 *
-	 * @param string    $email_type    The email type.
-	 * @param \WC_Email $email_data The transactional email data.
-	 * @return int The post ID of the generated template.
-	 * @throws \Exception When post creation fails.
+	 * @param string    $email_type The email type identifier (e.g. `customer_processing_order`).
+	 * @param \WC_Email $email      The transactional email instance.
+	 * @return array The post data array after the `woocommerce_email_content_post_data` filter runs.
+	 *
+	 * @since 10.8.0
 	 */
-	private function generate_single_template( $email_type, $email_data ) {
+	public static function build_filtered_post_data( string $email_type, $email ): array {
 		$post_data = array(
 			'post_type'    => Integration::EMAIL_POST_TYPE,
 			'post_status'  => 'publish',
 			'post_name'    => $email_type,
-			'post_title'   => $email_data->get_title(),
-			'post_excerpt' => $email_data->get_description(),
-			'post_content' => $this->get_email_template( $email_data ),
+			'post_title'   => $email->get_title(),
+			'post_excerpt' => $email->get_description(),
+			'post_content' => self::render_block_template_html( $email ),
 			'meta_input'   => array(
 				'_wp_page_template' => ( new WooEmailTemplate() )->get_slug(),
 			),
@@ -335,9 +358,45 @@ class WCTransactionalEmailPostsGenerator {
 		 * @since 10.5.0
 		 * @param array     $post_data  The post data array to be used for wp_insert_post().
 		 * @param string    $email_type The email type identifier (e.g., 'customer_processing_order').
-		 * @param \WC_Email $email_data The WooCommerce email object.
+		 * @param \WC_Email $email      The WooCommerce email object.
 		 */
-		$post_data = apply_filters( 'woocommerce_email_content_post_data', $post_data, $email_type, $email_data );
+		$filtered_post_data = apply_filters( 'woocommerce_email_content_post_data', $post_data, $email_type, $email );
+
+		return is_array( $filtered_post_data ) ? $filtered_post_data : $post_data;
+	}
+
+	/**
+	 * Compute the canonical `post_content` for a given email.
+	 *
+	 * Returns the `post_content` value that the generator would persist for this
+	 * email after the `woocommerce_email_content_post_data` filter runs, i.e.
+	 * the exact string whose sha1 is stamped into `_wc_email_template_source_hash`.
+	 *
+	 * Callers can hash the return value to obtain `currentCoreHash` for
+	 * divergence detection.
+	 *
+	 * @param \WC_Email $email The transactional email instance.
+	 * @return string The canonical post content.
+	 *
+	 * @since 10.8.0
+	 */
+	public static function compute_canonical_post_content( $email ): string {
+		$post_data = self::build_filtered_post_data( (string) $email->id, $email );
+		return (string) ( $post_data['post_content'] ?? '' );
+	}
+
+	/**
+	 * Generate a single email template.
+	 *
+	 * This function generates a single email template post and sets its postmeta association.
+	 *
+	 * @param string    $email_type    The email type.
+	 * @param \WC_Email $email_data The transactional email data.
+	 * @return int The post ID of the generated template.
+	 * @throws \Exception When post creation fails.
+	 */
+	private function generate_single_template( $email_type, $email_data ) {
+		$post_data = self::build_filtered_post_data( (string) $email_type, $email_data );

 		// Sync meta stamp for emails participating in template update propagation.
 		$sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( (string) $email_data->id );
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
new file mode 100644
index 00000000000..38f0ff3434b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
@@ -0,0 +1,312 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\WCTransactionalEmails;
+
+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 WCEmailTemplateDivergenceDetector class.
+ */
+class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
+	/**
+	 * Absolute path to the fixtures directory.
+	 *
+	 * @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' );
+
+		$this->fixtures_base = __DIR__ . '/fixtures/';
+		$this->posts_manager = WCTransactionalEmailPostsManager::get_instance();
+
+		// Singleton caches survive test transaction rollback and would otherwise leak
+		// stale post_id <-> email_type mappings into subsequent tests.
+		$this->posts_manager->clear_caches();
+		WCEmailTemplateSyncRegistry::reset_cache();
+	}
+
+	/**
+	 * Cleanup after test.
+	 */
+	public function tearDown(): void {
+		$this->cleanup_injected_emails();
+
+		remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
+		remove_all_filters( 'woocommerce_email_content_post_data' );
+
+		WCEmailTemplateSyncRegistry::reset_cache();
+		WCEmailTemplateDivergenceDetector::set_logger( null );
+
+		delete_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION );
+		update_option( 'woocommerce_feature_block_email_editor_enabled', 'no' );
+
+		parent::tearDown();
+	}
+
+	/**
+	 * classify_post() returns each of the three ladder states when fed the corresponding inputs.
+	 *
+	 * This is the pure unit-level coverage of the classifier; integration coverage is
+	 * provided by the remaining tests. Kept as a single method rather than a data provider
+	 * because all three scenarios derive from the same fixture-email setup and benefit
+	 * from a shared `currentCoreHash`.
+	 *
+	 * Scenarios exercise the two independent classification axes:
+	 *   - has core moved since stamping? (compare currentCoreHash vs. storedSourceHash)
+	 *   - has the merchant edited the post since stamping? (compare currentPostHash vs. storedSourceHash)
+	 */
+	public function test_classification_ladder_covers_all_three_outcomes(): void {
+		$email             = $this->register_fixture_email( 'wc_test_divergence_ladder' );
+		$canonical_content = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+		$current_core_hash = sha1( $canonical_content );
+
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+			WCEmailTemplateDivergenceDetector::classify_post(
+				1,
+				$email,
+				array(
+					'post_content'       => $canonical_content,
+					'stored_source_hash' => $current_core_hash,
+				)
+			),
+			'Core unchanged and post matches stamp must be in_sync.'
+		);
+
+		// Core has moved (stored !== current core) and merchant has not edited the post
+		// (post_content still matches the stored stamp) → safe to auto-apply new core.
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED,
+			WCEmailTemplateDivergenceDetector::classify_post(
+				1,
+				$email,
+				array(
+					'post_content'       => 'stamped-content-from-older-version',
+					'stored_source_hash' => sha1( 'stamped-content-from-older-version' ),
+				)
+			),
+			'Core moved but post still matches stamp must be core_updated_uncustomized.'
+		);
+
+		// Core has moved AND merchant edited the post (post_content no longer matches
+		// the stored stamp) → merchant customisations would be overwritten by auto-apply.
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED,
+			WCEmailTemplateDivergenceDetector::classify_post(
+				1,
+				$email,
+				array(
+					'post_content'       => 'merchant-edited content that diverges from the stamp',
+					'stored_source_hash' => sha1( 'stamped-content-from-older-version' ),
+				)
+			),
+			'Core moved and merchant edited post must be core_updated_customized.'
+		);
+	}
+
+	/**
+	 * Running the sweep twice on unchanged state must write the status meta at most once.
+	 *
+	 * Guards the equality-gated upsert in run_sweep(): the second sweep observes the status
+	 * meta written by the first run and short-circuits, so no second write reaches the DB.
+	 */
+	public function test_second_sweep_on_unchanged_state_writes_zero_rows(): void {
+		$email_id = 'wc_test_divergence_idempotency';
+		$post_id  = $this->generate_stamped_post( $email_id );
+
+		$write_count = 0;
+		$counter     = static function ( $check, int $object_id, string $meta_key ) use ( &$write_count, $post_id ) {
+			if ( $object_id === $post_id && WCEmailTemplateDivergenceDetector::STATUS_META_KEY === $meta_key ) {
+				++$write_count;
+			}
+			return $check;
+		};
+		add_filter( 'update_post_metadata', $counter, 10, 3 );
+
+		WCEmailTemplateDivergenceDetector::run_sweep();
+		$writes_after_first_sweep = $write_count;
+
+		WCEmailTemplateDivergenceDetector::run_sweep();
+		$writes_after_second_sweep = $write_count;
+
+		remove_filter( 'update_post_metadata', $counter, 10 );
+
+		$this->assertSame( 1, $writes_after_first_sweep, 'First sweep should write the status meta exactly once.' );
+		$this->assertSame( 1, $writes_after_second_sweep, 'Second sweep on unchanged state must be a no-op.' );
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+		);
+	}
+
+	/**
+	 * Posts lacking _wc_email_template_source_hash must be skipped without writing status.
+	 */
+	public function test_post_with_missing_source_hash_is_skipped(): void {
+		$email_id = 'wc_test_divergence_missing_hash';
+		$post_id  = $this->generate_stamped_post( $email_id );
+
+		// Simulate a legacy (pre-RSM-137) post by removing the source-hash stamp.
+		delete_post_meta( $post_id, '_wc_email_template_source_hash' );
+
+		WCEmailTemplateDivergenceDetector::run_sweep();
+
+		$this->assertSame(
+			'',
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+			'Legacy posts without a stored source hash must not be classified.'
+		);
+	}
+
+	/**
+	 * When the stamping path captures post-filter content and the same filter stays active
+	 * during the sweep, the detector must classify the post as in_sync by construction.
+	 *
+	 * This is the cross-issue contract between RSM-137 (stamp) and RSM-138 (detect):
+	 * both sides route through {@see WCTransactionalEmailPostsGenerator::compute_canonical_post_content()}
+	 * so any filter mutation that shifts post_content shifts both hashes identically.
+	 */
+	public function test_contract_equivalence_under_woocommerce_email_content_post_data_filter(): void {
+		add_filter(
+			'woocommerce_email_content_post_data',
+			static function ( array $post_data ): array {
+				$post_data['post_content'] = (string) ( $post_data['post_content'] ?? '' ) . "\n<!-- filter mutation -->";
+				return $post_data;
+			}
+		);
+
+		$email_id = 'wc_test_divergence_filter_contract';
+		$post_id  = $this->generate_stamped_post( $email_id );
+
+		WCEmailTemplateDivergenceDetector::run_sweep();
+
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+			'Stamping hash and detection hash must agree when the content-post-data filter is active.'
+		);
+	}
+
+	/**
+	 * 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. Resets the registry cache so subsequent reads see the
+	 * fixture.
+	 *
+	 * @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 divergence tests' );
+		$stub->method( 'get_description' )->willReturn( 'Fixture email used to cover divergence 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.
+	 *
+	 * Using the live generator ensures RSM-137's stamping logic actually runs — the detector
+	 * then observes precisely what the generator would persist in production.
+	 *
+	 * @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 );
+
+		// Sanity check: RSM-137 stamped all three sync meta keys.
+		$this->assertNotSame( '', (string) get_post_meta( $post_id, '_wc_email_template_source_hash', true ) );
+		$this->assertNotSame( '', (string) get_post_meta( $post_id, '_wc_email_template_version', true ) );
+		$this->assertNotSame( '', (string) get_post_meta( $post_id, '_wc_email_last_synced_at', true ) );
+
+		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();
+	}
+}