Commit 4bfa96c8de7 for woocommerce

commit 4bfa96c8de7e5ab40eb70a46759963aa479e11f1
Author: Seun Olorunsola <30554163+triple0t@users.noreply.github.com>
Date:   Tue May 12 11:03:24 2026 +0100

    [Email Editor] Add three-way diff for change-summary and selective apply (#64716)

    * Stamp _wc_email_template_last_core_render meta on every system write

    Add a new public const LAST_CORE_RENDER_META_KEY to the divergence
    detector and stamp it on every code path that mutates post_content for
    a sync-eligible woo_email post: generator (initial stamp), auto-applier
    (wholesale write — also covers the reset endpoint via shared atom),
    selective applier (after merge — always with current canonical, not the
    merged content), and the RSM-149 backfill (seeds for legacy posts).

    This is Phase 1 of the three-way diff transition for the change-summary
    service. The meta is collected silently — no behavior change yet. Phase
    2 will add a `diff_records_three_way()` to the change-summary that uses
    this meta as the `base` reference, enabling deterministic per-block
    attribution and removing the inversion-guard heuristic that today bails
    to "see release notes" on heavily-customized posts.

    Backwards-compatible: posts without the meta will continue to use the
    existing 2-way diff once Phase 2 lands.

    Test coverage:
    - Generator: stamps on initial post creation, equals compute_canonical_post_content.
    - Auto-applier: stamps with new canonical (post-filter), not stale value.
    - Selective applier: stamps current canonical, not merged content.
    - Backfill: seeds for pre-existing posts.

    * Three-way change-summary diff: branch summarize() on base meta

    Add `WCEmailTemplateChangeSummary::diff_records_three_way()` and branch
    `summarize()` on `_wc_email_template_last_core_render` meta availability:

    - When the meta is set (every post touched by a sync-eligible writer
      since Phase 1 landed), run two LCS passes (yours-vs-base, core-vs-base)
      and classify each block by the four-case matrix (yours_changed ×
      core_changed). The inversion-guard heuristic is suppressed on this
      path — three-way is deterministic on any post.
    - When the meta is absent (legacy fallback for not-yet-touched posts),
      keep the existing two-way diff and the inversion guard.

    Cache key includes a fourth component (sha1 of base_render) so cache
    invalidates whenever base shifts.

    The new diff also surfaces an `auto_resolvable` boolean flag on
    `copy_changes` entries (true when only core changed relative to base —
    no merchant edit on that block, so the drawer can default to use_core
    without surfacing a conflict). Callers ignore unknown fields, so this
    is forward-compatible.

    Bug 04 (parallel additions on yours and core falsely paired into one
    copy_change) is structurally fixed under three-way: each side's
    addition is unmatched against base, classified independently, and lands
    in `added_blocks` and `removed_blocks` respectively.

    Bug 03's empty-drawer-on-heavily-customized-post is also addressed for
    posts with base meta — the inversion guard never fires and the diff
    produces actionable entries.

    Test coverage:
    - 6 unit tests against `diff_records_three_way` (4-case matrix +
      yours-only / core-only additions + bug 04 regression).
    - summarize() branching: 3-way path returns no entry for yours-only
      edits; 2-way fallback still surfaces yours-vs-core copy_changes
      when meta is missing.
    - Cache busts when base_render changes.

    * Selective applier: consume three-way change-summary payload

    When the post has `_wc_email_template_last_core_render` meta, the
    selective applier's `merge()` now consumes the change-summary payload
    directly instead of relying solely on its own LCS for matched-pair
    classification. This eliminates the dual-implementation drift between
    the diff and the apply path.

    Concretely, in three-way mode:

    - Matched pairs whose paths the summary classified as separate
      add+remove (yours-only addition, core-only addition) are REJECTED
      in Pass 1 so they don't get tracked as matched. Pass 2 then inserts
      core's added paragraph; Pass 3 leaves yours' added paragraph in
      place. Both survive — the bug 04 fingerprint is structurally fixed.
    - Matched pairs whose paths are NOT in the summary's `copy_changes`
      (yours-only edits, both-unchanged) are silently skipped in Pass 1
      even when a `use_core` decision was passed. Yours-only edits are
      not conflicts; the summary already determined that core hasn't
      moved relative to base on this block.

    Backwards-compatible: the legacy two-way path is preserved when
    `precomputed_summary` is null (i.e. the post has no `last_core_render`
    meta). Every existing applier test continues to pass.

    Test coverage:
    - Bug 04 regression: parallel additions on yours and core both
      survive the apply when base meta is set.
    - Yours-only edit: even with `decision: 'use_core'` requested, the
      three-way path preserves yours because the summary doesn't classify
      the path as a conflict.

    * Three-way diff: document the new path and add bug regression tests

    Class docblocks on WCEmailTemplateChangeSummary and
    WCEmailTemplateSelectiveApplier updated to document the three-way diff
    path and the base-meta gating that selects between three-way and the
    legacy two-way fallback.

    Two new regression tests on WCEmailTemplateChangeSummary cover the
    bugs that motivated the three-way switch:

    - Bug 03: heavily-customized posts (>= 5 yours-only additions) no
      longer fall back to "Template updated — see release notes." when
      the post has last_core_render meta. The inversion guard never
      fires on the three-way path; merchants get per-block detail.
    - Bug 04: parallel additions on yours and core (both adding a
      paragraph at the end) no longer collapse into a single
      Paragraph copy_change. Each side's addition is classified
      independently — yours' lands in removed_blocks, core's lands in
      added_blocks.

    Both tests exercise the public summarize() surface, complementing the
    unit tests against diff_records_three_way() that landed in the previous
    commit.

    OPEN-QUESTIONS Q1 (2-way vs 3-way diff) is now resolved in the
    research workspace at RSM-138-143-research/OPEN-QUESTIONS.md.

    * Address PR review feedback

    - Consolidate 4 phase changelog entries into a single 64716 entry.
    - Fix `@since 10.10.0` references to `10.9.0` (next core release).
    - CR-1: in `diff_records_three_way()`, hoist the `$occurrence_index`
      increment so it advances for every matched pair, not only emitted
      conflicts. Mirrors 2-way `diff_records()` so "Paragraph N of M" stays
      correct when earlier same-name blocks aren't a conflict.
    - CR-2: in `diff_records_three_way()`, route structural wrappers
      (`core/group`, `core/columns`, `core/column`, `core/row`) into
      `structural_changes` with `kind: 'nest'` instead of leaking them into
      `added_blocks`/`removed_blocks`. The selective applier already skips
      structural blocks at merge time, so the prior code surfaced phantom
      "Added Group block" entries the apply would never apply.
    - CR-3: snapshot the prior `_wc_email_template_last_core_render` in the
      pre-apply snapshot and restore it in `undo()`. Without this, an undone
      apply left the base stamped at the post-apply canonical and the next
      `summarize()` would see the restored content as a yours-only edit,
      silently swallowing the pending core update.

    Three new regression tests cover the CR fixes:

    - `test_three_way_occurrence_counts_every_matched_pair`
    - `test_three_way_routes_structural_wrappers_to_structural_changes`
    - `test_undo_restores_prior_last_core_render`

    * Address Patrick's review on the three-way diff PR

    - Document `auto_resolvable` in the `summarize()` payload contract; it was
      emitted by the implementation but missing from the docblock.
    - Note in `diff_records_three_way()` that structural relocations are
      intentionally not surfaced; selective apply preserves the merchant's
      structure either way.
    - Flag the attr-only comparison limitation inline. With `auto_resolvable: true`
      the drawer can silently overwrite an attr-only merchant edit; follow-up to
      extend the comparison to a stable hash of `attrs`.
    - Pin the no-reorder claim with a regression test that exercises the 2-way
      reorder pass (via reflection on private `diff_records()`) and asserts the
      three-way path stays silent on the same fixture.
    - Hoist the duplicate `_wc_email_template_last_core_render` read in
      `summarize()` so the cache key and diff branch share one read.
    - Drop the wrong RSM-1181 reference in the backfill seed comment; keep the
      rationale inline.

    * Address Patrick's PR review: drawer auto_resolvable + undo snapshot widening

    Two follow-ups from the post-merge review on PR #64716.

    Drawer auto_resolvable partition (Obs 1):
    - `copy_changes` with `auto_resolvable: true` now render under AUTO-RESOLVED
      with an "Apply core" tag instead of CONFLICT with manual radios. The PHP
      signal was being emitted but the JS consumer never partitioned on it, so
      merchants were forced to make pointless decisions.
    - Banner's `hasConflicts` gate now only counts copy_changes with
      `auto_resolvable !== true`, so the Apply button isn't blocked when the
      only deltas are auto-resolvable.

    Selective applier undo widening (Obs 3):
    - Pre-apply snapshot now captures `version`, `source_hash`, and
      `last_synced_at` alongside `content` and `last_core_render`. Undo restores
      all four. Without this, the stuck version meta defeats the editor banner
      and email-list "Review update" indicator gates (both require
      `templateVersion < currentVersion`), hiding the pending update on every
      surface even though `summarize()` still reports it.
    - New helper `restore_meta_from_snapshot()` no-ops on keys missing from
      older snapshot formats, so snapshots written before the widening don't
      wipe existing meta with empty strings.

    Includes `test_undo_restores_full_meta_tuple_not_just_content_and_base` as a
    regression test for the snapshot widening.

    * Selective applier: reach IN_SYNC when merge aligns with canonical modulo whitespace

    `is_aligned_with_canonical` was a raw `===` against `compute_canonical_post_content()`.
    Canonical comes from the PHP template (literal `\n` between blocks, leading/trailing
    newlines, `<div> ##WOO_CONTENT## </div>` spaces); merged comes from `serialize_blocks()`,
    which can't reproduce that. The two are semantically equal but never byte-equal, so the
    fully-resolved scenario could never reach STATUS_IN_SYNC — posts got pinned at
    core_updated_customized and the banner / email-list indicator never cleared.

    Option B: compare with a whitespace-normalized form, and when aligned, persist canonical
    verbatim so source_hash, classify_post, and downstream byte comparisons hold without
    further normalization. classify_post itself is left untouched.

diff --git a/plugins/woocommerce/changelog/64716-rsm-three-way-diff b/plugins/woocommerce/changelog/64716-rsm-three-way-diff
new file mode 100644
index 00000000000..4a4539f332c
--- /dev/null
+++ b/plugins/woocommerce/changelog/64716-rsm-three-way-diff
@@ -0,0 +1,4 @@
+Significance: patch
+Type: update
+
+Add three-way diff for block email template change-summary and selective apply when the new `_wc_email_template_last_core_render` post meta is present.
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-change-summary.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-change-summary.ts
index 26c980f1ca4..59b365fe834 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-change-summary.ts
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-change-summary.ts
@@ -33,6 +33,12 @@ export interface ChangeSummaryCopyChange {
 	total: number;
 	/** Post-side index path of the conflicting block. */
 	path: Array< number | string >;
+	/**
+	 * Three-way path only. `true` = merchant unchanged, core changed
+	 * (drawer can apply silently). `false` = both changed (true conflict).
+	 * Absent on two-way fallback.
+	 */
+	auto_resolvable?: boolean;
 }

 /**
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-update-banner.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-update-banner.ts
index 006e675bd74..694f728ce14 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-update-banner.ts
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-update-banner.ts
@@ -339,7 +339,11 @@ export function useUpdateBanner(): UseUpdateBannerResult {
 	const summary: ChangeSummary | null = finalShouldRender
 		? effectiveSummary
 		: null;
-	const hasConflicts = summary !== null && summary.copy_changes.length > 0;
+	// Only `auto_resolvable !== true` counts as a true conflict that blocks
+	// Apply. `undefined` is the two-way fallback (no base) and stays gated.
+	const hasConflicts =
+		summary !== null &&
+		summary.copy_changes.some( ( cc ) => cc.auto_resolvable !== true );

 	// `@wordpress/data`'s typed dispatch surface isn't exhaustive for
 	// custom stores; cast loosely to grab our integration-store actions.
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/review-drawer.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/review-drawer.tsx
index 768d2735b74..db8f83d66cc 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/review-drawer.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/review-drawer.tsx
@@ -225,11 +225,18 @@ const AutoResolvedItem = ( {
 	</div>
 );

-const AutoResolvedGroup = ( { summary }: { summary: ChangeSummary } ) => {
+const AutoResolvedGroup = ( {
+	summary,
+	autoResolvedCopyChanges,
+}: {
+	summary: ChangeSummary;
+	autoResolvedCopyChanges: ChangeSummaryCopyChange[];
+} ) => {
 	const total =
 		summary.added_blocks.length +
 		summary.removed_blocks.length +
-		summary.structural_changes.length;
+		summary.structural_changes.length +
+		autoResolvedCopyChanges.length;

 	if ( total === 0 ) {
 		return null;
@@ -259,6 +266,31 @@ const AutoResolvedGroup = ( { summary }: { summary: ChangeSummary } ) => {
 				{ heading }
 			</h3>

+			{ autoResolvedCopyChanges.map( ( entry ) => {
+				const title =
+					entry.total > 1
+						? sprintf(
+								/* translators: 1: block name; 2: occurrence; 3: total. */
+								__( '%1$s %2$d of %3$d', 'woocommerce' ),
+								entry.block,
+								entry.occurrence,
+								entry.total
+						  )
+						: entry.block;
+				return (
+					<AutoResolvedItem
+						key={ `copy-${ pathKey( entry.path ) }-${
+							entry.occurrence ?? 0
+						}` }
+						title={ title }
+						sub={ __(
+							'Core updated this text. Your version was unchanged, so the update will apply.',
+							'woocommerce'
+						) }
+						tag="apply_core"
+					/>
+				);
+			} ) }
 			{ summary.added_blocks.map( ( entry ) => (
 				<AutoResolvedItem
 					key={ `added-${ pathKey( entry.path ) }` }
@@ -542,11 +574,18 @@ export const ReviewDrawer = ( {
 						{ summary && ! summary.is_fallback && (
 							<>
 								<ConflictsGroup
-									conflicts={ summary.copy_changes }
+									conflicts={ summary.copy_changes.filter(
+										( cc ) => ! cc.auto_resolvable
+									) }
 									choices={ choices }
 									onChoose={ setChoice }
 								/>
-								<AutoResolvedGroup summary={ summary } />
+								<AutoResolvedGroup
+									summary={ summary }
+									autoResolvedCopyChanges={ summary.copy_changes.filter(
+										( cc ) => cc.auto_resolvable === true
+									) }
+								/>
 							</>
 						) }
 					</div>
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php
index 600bee39057..baad8dae4c1 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php
@@ -191,6 +191,7 @@ class WCEmailTemplateAutoApplier {
 				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::LAST_CORE_RENDER_META_KEY, $canonical );

 				// Status comes from the classifier so all writers stay consistent.
 				// In this path we always write canonical, so the classifier returns
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php
index a2fe23f95ea..eefb3135ad0 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php
@@ -11,16 +11,33 @@ use Automattic\WooCommerce\Internal\EmailEditor\Logger;
  * Produces a localized summary of differences between a merchant's `woo_email`
  * post content and the current canonical core render of the same email.
  *
- * Algorithm: DFS-flatten both block trees → normalize known namespace aliases
- * (e.g. `woo/email-content` → `woocommerce/email-content`) → run an LCS over
- * the resulting block-name sequences → classify the residue as added /
- * removed / copy / structural. Result is cached in a transient keyed on the
- * post ID, the post and core content hashes, and the active locale, so any
- * merchant edit or core bump invalidates automatically.
+ * Two diff modes, selected per-call by post-meta presence:
+ *
+ * - **Three-way (since 10.9.0)** — when the post has
+ *   {@see WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY} meta
+ *   (every post touched by a sync-eligible writer: generator, auto-applier,
+ *   selective applier, reset endpoint, RSM-149 backfill), the summary runs
+ *   two LCS passes (yours-vs-base, core-vs-base) and classifies each block
+ *   by what changed relative to base. The diff is deterministic on any
+ *   post; the inversion guard does not fire on this path.
+ * - **Two-way (legacy fallback)** — when the meta is absent (legacy posts
+ *   not yet touched since the meta was introduced), the summary falls back
+ *   to the original `lcs_matches(core, post)` algorithm. The summary-
+ *   inversion guard (>= 5 unmatched && 0 copy && post >= 1.5x core) is
+ *   active in this path only — it bails to "Template updated — see release
+ *   notes." when it can't reliably attribute changes.
+ *
+ * Both paths normalize known namespace aliases (e.g. `woo/email-content` →
+ * `woocommerce/email-content`) and produce the same payload shape (added /
+ * removed / copy / structural) so consumers don't need a mode switch.
+ *
+ * Result is cached in a transient keyed on the post ID, the post + core +
+ * base content hashes, and the active locale; any merchant edit, core bump,
+ * or base shift invalidates automatically.
  *
  * Hash input parity with {@see WCTransactionalEmailPostsGenerator::compute_canonical_post_content()}
- * is guaranteed by construction — both sides route through the same canonical
- * render, identical to the divergence detector.
+ * is guaranteed by construction — both sides route through the same
+ * canonical render, identical to the divergence detector.
  *
  * @package Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
  * @since 10.9.0
@@ -102,8 +119,9 @@ class WCEmailTemplateChangeSummary {
 	 * - `source_hash_to`      — `string`     — sha1 of the canonical core content for this email type. Mirrors the post's `_wc_email_template_source_hash` meta. Empty string in fallback / no-config paths where the core content can't be computed.
 	 * - `added_blocks`        — `array<int, array{name:string, label:string, path:array<int|string>}>` — blocks that would be added to the post by applying (in core, not in post). `name` is the post-alias-normalized block name (e.g. `core/heading`); `label` is its humanized form for display; `path` is the core-side index path.
 	 * - `removed_blocks`      — `array<int, array{name:string, label:string, path:array<int|string>}>` — blocks that would be removed from the post by applying (in post, not in core). Same field semantics as `added_blocks`; `path` is the post-side index path.
-	 * - `copy_changes`        — `array<int, array{block:string, before:string, after:string, occurrence:int, total:int, path:array<int|string>}>`.
+	 * - `copy_changes`        — `array<int, array{block:string, before:string, after:string, occurrence:int, total:int, path:array<int|string>, auto_resolvable?:bool}>`.
 	 *                           `before` is the merchant's current text; `after` is the canonical core text. `path` is the post-side index path.
+	 *                           `auto_resolvable` is emitted only on the three-way path: `true` when only core changed since base (safe to auto-apply), `false` when both sides changed (true conflict). Absent on two-way fallback payloads.
 	 * - `structural_changes`  — `array<int, array{kind:string, description:string, path?:array<int|string>}>` — `path` is omitted for `kind: 'reorder'` entries.
 	 * - `summary_lines`       — `string[]`   — pre-localized one-liners ready to render.
 	 * - `is_fallback`         — `bool`       — true when the diff could not be produced.
@@ -167,7 +185,10 @@ class WCEmailTemplateChangeSummary {
 		$post_hash = sha1( $post_content );
 		$core_hash = sha1( $core_content );

-		$cache_key = self::cache_key( $post_id, $post_hash, $core_hash, self::current_locale() );
+		$base_render = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, true );
+		$base_hash   = '' !== $base_render ? sha1( $base_render ) : '';
+
+		$cache_key = self::cache_key( $post_id, $post_hash, $core_hash, $base_hash, self::current_locale() );
 		$cached    = get_transient( $cache_key );
 		if ( is_array( $cached ) ) {
 			$cached['cache_hit'] = true;
@@ -196,25 +217,37 @@ class WCEmailTemplateChangeSummary {
 			return self::fallback_payload( $version_from, $version_to );
 		}

-		$structured = self::diff_records( $core_records, $post_records );
-
-		// Summary-inversion guard: a heavily one-sided expansion on the post
-		// side looks like merchant work attributed to core. Without a stored
-		// old-core hash to disambiguate, fall back to the release-notes line.
-		// Under the "yours → core" convention, post-side unmatched blocks land
-		// in `removed_blocks` (would be removed by applying), so that's the
-		// signal we count here.
-		$post_total = count( $post_records );
-		$core_total = count( $core_records );
-		if (
-			0 === count( $structured['added_blocks'] )
-			&& 0 === count( $structured['copy_changes'] )
-			&& count( $structured['removed_blocks'] ) >= self::INVERSION_GUARD_THRESHOLD
-			&& $post_total >= ( self::INVERSION_GUARD_RATIO * $core_total )
-		) {
-			$payload = self::fallback_payload( $version_from, $version_to );
-			self::write_cache( $cache_key, $payload );
-			return $payload;
+		// Branch on base meta availability. When the post has been touched by a
+		// sync-eligible writer (generator, auto-applier, selective applier, reset,
+		// backfill), it has `last_core_render` and we run the three-way diff: a
+		// strict per-block (yours-vs-base, core-vs-base) comparison. The
+		// inversion-guard heuristic is not needed in this branch — three-way is
+		// deterministic on any post. Posts without the meta fall through to the
+		// legacy two-way path, which keeps the inversion guard for safety.
+		if ( '' !== $base_render ) {
+			$base_records = self::flatten_blocks( parse_blocks( $base_render ) );
+			$structured   = self::diff_records_three_way( $core_records, $base_records, $post_records );
+		} else {
+			$structured = self::diff_records( $core_records, $post_records );
+
+			// Summary-inversion guard: a heavily one-sided expansion on the post
+			// side looks like merchant work attributed to core. Without a stored
+			// old-core render to disambiguate, fall back to the release-notes line.
+			// Under the "yours → core" convention, post-side unmatched blocks land
+			// in `removed_blocks` (would be removed by applying), so that's the
+			// signal we count here.
+			$post_total = count( $post_records );
+			$core_total = count( $core_records );
+			if (
+				0 === count( $structured['added_blocks'] )
+				&& 0 === count( $structured['copy_changes'] )
+				&& count( $structured['removed_blocks'] ) >= self::INVERSION_GUARD_THRESHOLD
+				&& $post_total >= ( self::INVERSION_GUARD_RATIO * $core_total )
+			) {
+				$payload = self::fallback_payload( $version_from, $version_to );
+				self::write_cache( $cache_key, $payload );
+				return $payload;
+			}
 		}

 		$summary_lines = self::to_summary_lines( $structured );
@@ -336,13 +369,14 @@ class WCEmailTemplateChangeSummary {
 	 * @param int    $post_id   The `woo_email` post ID.
 	 * @param string $post_hash sha1 of the persisted post content.
 	 * @param string $core_hash sha1 of the canonical core render.
+	 * @param string $base_hash sha1 of `_wc_email_template_last_core_render` if set; empty string otherwise. Including this in the key invalidates the cache when base shifts.
 	 * @param string $locale    Active locale.
 	 */
-	private static function cache_key( int $post_id, string $post_hash, string $core_hash, string $locale ): string {
+	private static function cache_key( int $post_id, string $post_hash, string $core_hash, string $base_hash, string $locale ): string {
 		return sprintf(
 			'wc_email_change_summary_%d_%s',
 			$post_id,
-			md5( $post_hash . '|' . $core_hash . '|' . $locale )
+			md5( $post_hash . '|' . $core_hash . '|' . $base_hash . '|' . $locale )
 		);
 	}

@@ -626,6 +660,210 @@ class WCEmailTemplateChangeSummary {
 		);
 	}

+	/**
+	 * Compute a three-way block-level diff between core, base, and yours.
+	 *
+	 * Two LCS passes (yours-vs-base, core-vs-base) build a tripartite alignment
+	 * keyed on base. For each base block, we then know whether yours and/or core
+	 * has changed it relative to base — yielding a four-case classification:
+	 *
+	 * - !yours_changed && !core_changed → no entry (block unchanged on both sides)
+	 * - !yours_changed &&  core_changed → copy_change (auto-resolvable to use_core)
+	 * -  yours_changed && !core_changed → no entry (merchant edit; preserve silently)
+	 * -  yours_changed &&  core_changed → copy_change (true conflict)
+	 *
+	 * Additions (in yours OR core but not in base) and removals (in base but not in
+	 * one side) are handled in dedicated passes after the matched-pair classification.
+	 * Yours-only adds become `removed_blocks` ("would be removed by wholesale apply,
+	 * preserved by selective apply"). Core-only adds become `added_blocks`. Yours-removed
+	 * but core-kept becomes a `merchant_removed` structural entry — apply does NOT re-add.
+	 *
+	 * Compared to {@see self::diff_records()}, this method removes the inversion-guard
+	 * heuristic entirely: with a known base, the diff is deterministic on any post,
+	 * including heavily-customized ones. The reorder pass is also dropped: the LCS
+	 * tail-pairing bug it compensated for cannot fire under three-way attribution.
+	 *
+	 * Structural relocations (a matched pair whose `parent_name` differs between
+	 * core and post) are intentionally not surfaced here, unlike the 2-way path's
+	 * `Moved %1$s into %2$s` entry. Selective apply preserves the merchant's
+	 * structure either way, so the move is something the merchant cannot act on.
+	 *
+	 * @internal
+	 *
+	 * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $core_records Core side (current canonical).
+	 * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $base_records Base side (canonical at last system write).
+	 * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $post_records Post side (merchant's current post_content).
+	 *
+	 * @return array{added_blocks:array<int, array<string, mixed>>, removed_blocks:array<int, array<string, mixed>>, copy_changes:array<int, array<string, mixed>>, structural_changes:array<int, array<string, mixed>>}
+	 *
+	 * @since 10.9.0
+	 */
+	public static function diff_records_three_way( array $core_records, array $base_records, array $post_records ): array {
+		$core_to_base = self::lcs_matches( $core_records, $base_records );
+		$post_to_base = self::lcs_matches( $post_records, $base_records );
+
+		// Invert into base-keyed lookups so a single iteration over base records
+		// can decide each block's fate against both sides.
+		$base_to_core = array();
+		foreach ( $core_to_base as $pair ) {
+			$base_to_core[ $pair[1] ] = $pair[0];
+		}
+		$base_to_post = array();
+		foreach ( $post_to_base as $pair ) {
+			$base_to_post[ $pair[1] ] = $pair[0];
+		}
+
+		$matched_core_indices = array();
+		$matched_post_indices = array();
+
+		$copy_changes       = array();
+		$added_blocks       = array();
+		$removed_blocks     = array();
+		$structural_changes = array();
+
+		$core_name_counts = array_count_values( array_map( static fn( array $r ): string => $r['name'], $core_records ) );
+		$occurrence_index = array();
+
+		// Pass 1: classify each base-anchored block by what changed relative to base.
+		foreach ( $base_records as $b_idx => $base ) {
+			$core_idx = $base_to_core[ $b_idx ] ?? null;
+			$post_idx = $base_to_post[ $b_idx ] ?? null;
+
+			if ( null !== $core_idx ) {
+				$matched_core_indices[ $core_idx ] = true;
+			}
+			if ( null !== $post_idx ) {
+				$matched_post_indices[ $post_idx ] = true;
+			}
+
+			if ( null === $core_idx && null === $post_idx ) {
+				// Both sides removed it — no-op.
+				continue;
+			}
+
+			if ( null === $core_idx ) {
+				// Core removed it; yours kept it. Preserve on apply.
+				$removed_blocks[] = array(
+					'name'  => $post_records[ $post_idx ]['name'],
+					'label' => self::block_label( $post_records[ $post_idx ]['name'] ),
+					'path'  => $post_records[ $post_idx ]['path'],
+				);
+				continue;
+			}
+
+			if ( null === $post_idx ) {
+				// Yours removed it; core kept it. Don't re-add — respect merchant intent.
+				$structural_changes[] = array(
+					'kind'        => 'merchant_removed',
+					'description' => sprintf(
+						/* translators: %s: block name */
+						__( 'You removed %s; core still has it.', 'woocommerce' ),
+						self::block_label( $core_records[ $core_idx ]['name'] )
+					),
+					'path'        => $base['path'],
+				);
+				continue;
+			}
+
+			// Both sides have the block — increment occurrence ordinal for every
+			// matched pair, regardless of whether a copy_change is emitted, so
+			// "Paragraph N of M" reflects the block's true ordinal across the run.
+			// Mirrors the 2-way `diff_records()` placement of the counter.
+			$core                      = $core_records[ $core_idx ];
+			$post                      = $post_records[ $post_idx ];
+			$name                      = $core['name'];
+			$occurrence_index[ $name ] = ( $occurrence_index[ $name ] ?? 0 ) + 1;
+
+			// Known limitation: comparison is `inner_text` only; block `attrs` (colors,
+			// alignment, etc.) don't register as changes. With `auto_resolvable: true`
+			// the drawer can silently overwrite an attr-only merchant edit. Follow-up
+			// to extend the comparison to a stable hash of `attrs`.
+			$yours_changed = ( $base['inner_text'] !== $post['inner_text'] );
+			$core_changed  = ( $base['inner_text'] !== $core['inner_text'] );
+
+			if ( ! $yours_changed && ! $core_changed ) {
+				continue;
+			}
+			if ( ! $core_changed ) {
+				// Yours edited, core didn't — merchant-only edit, preserve silently.
+				continue;
+			}
+
+			// Core changed (with or without yours also changing).
+			$copy_changes[] = array(
+				'block'           => self::block_label( $name ),
+				'before'          => self::truncate_text( $post['inner_text'] ),
+				'after'           => self::truncate_text( $core['inner_text'] ),
+				'occurrence'      => $occurrence_index[ $name ],
+				'total'           => (int) ( $core_name_counts[ $name ] ?? 1 ),
+				'path'            => $post['path'],
+				'auto_resolvable' => ! $yours_changed,
+			);
+		}//end foreach
+
+		// Pass 2: unmatched core records → added_blocks. Structural wrappers
+		// (`core/group`, `core/columns`, `core/column`, `core/row`) route to
+		// `structural_changes` instead — the selective applier skips them at
+		// merge time, and surfacing them as `added_blocks` would advertise an
+		// "Added Group block" the apply will never apply. Mirrors the 2-way
+		// `diff_records()` handling of structural wrappers.
+		foreach ( $core_records as $c_idx => $rec ) {
+			if ( isset( $matched_core_indices[ $c_idx ] ) ) {
+				continue;
+			}
+			if ( isset( self::STRUCTURAL_BLOCK_NAMES[ $rec['name'] ] ) ) {
+				$structural_changes[] = array(
+					'kind'        => 'nest',
+					'description' => sprintf(
+						/* translators: %s: block name */
+						__( 'Added %s wrapper', 'woocommerce' ),
+						self::block_label( $rec['name'] )
+					),
+					'path'        => $rec['path'],
+				);
+				continue;
+			}
+			$added_blocks[] = array(
+				'name'  => $rec['name'],
+				'label' => self::block_label( $rec['name'] ),
+				'path'  => $rec['path'],
+			);
+		}
+
+		// Pass 3: unmatched post records → removed_blocks (yours-only additions, preserved by default).
+		// Same structural-wrapper handling as Pass 2 — yours-only structural blocks land in
+		// `structural_changes` rather than `removed_blocks`.
+		foreach ( $post_records as $p_idx => $rec ) {
+			if ( isset( $matched_post_indices[ $p_idx ] ) ) {
+				continue;
+			}
+			if ( isset( self::STRUCTURAL_BLOCK_NAMES[ $rec['name'] ] ) ) {
+				$structural_changes[] = array(
+					'kind'        => 'nest',
+					'description' => sprintf(
+						/* translators: %s: block name */
+						__( 'Removed %s wrapper', 'woocommerce' ),
+						self::block_label( $rec['name'] )
+					),
+					'path'        => $rec['path'],
+				);
+				continue;
+			}
+			$removed_blocks[] = array(
+				'name'  => $rec['name'],
+				'label' => self::block_label( $rec['name'] ),
+				'path'  => $rec['path'],
+			);
+		}
+
+		return array(
+			'added_blocks'       => $added_blocks,
+			'removed_blocks'     => $removed_blocks,
+			'copy_changes'       => $copy_changes,
+			'structural_changes' => $structural_changes,
+		);
+	}
+
 	/**
 	 * Drop entries at the given indices and return a re-indexed list. Used by
 	 * the reorder pass to remove paired entries from added/removed without
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
index 5504eaedf0c..1a66d5117ca 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
@@ -119,6 +119,25 @@ class WCEmailTemplateDivergenceDetector {
 	 */
 	public const BACKFILLED_META_KEY = '_wc_email_backfilled';

+	/**
+	 * Post meta key for the canonical core render at the moment of the last
+	 * system write. Used as the `base` reference in three-way diff
+	 * comparisons (yours-vs-base, core-vs-base) so the engine can attribute
+	 * each block's change to either the merchant or to core, eliminating the
+	 * inversion guard's need to fall back to "see release notes" on
+	 * heavily-customized posts.
+	 *
+	 * Written by every code path that mutates `post_content` for a sync-
+	 * eligible `woo_email` post: the generator (initial stamp), the auto-
+	 * applier and reset endpoint (wholesale writes align yours with core),
+	 * the selective applier (records the new canonical the merchant just
+	 * synced against), and the RSM-149 backfill (seeds for legacy posts).
+	 *
+	 * @var string
+	 * @since 10.9.0
+	 */
+	public const LAST_CORE_RENDER_META_KEY = '_wc_email_template_last_core_render';
+
 	/**
 	 * Classification outcomes.
 	 */
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplier.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplier.php
index 7e124ecafc3..6623e4e248b 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplier.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplier.php
@@ -36,18 +36,33 @@ use Automattic\WooCommerce\Internal\EmailEditor\Logger;
  * subsequent apply overwrites the snapshot. {@see self::undo()} restores from
  * the snapshot when the supplied `revision_id` matches.
  *
+ * Three-way payload consumption (since 10.9.0): when the post has
+ * {@see WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY} meta,
+ * `apply_selectively()` passes the change-summary's payload through to
+ * `merge()`, which uses it to gate matched-pair classification:
+ *
+ * - LCS pairs whose paths the summary classified as separate add+remove are
+ *   rejected by Pass 1 (preventing false yours+core pairings on parallel
+ *   additions); Pass 2/3 then handle them as two independent adds.
+ * - Matched pairs whose paths are NOT in `copy_changes` are silently
+ *   preserved (yours-only edits aren't conflicts; the `use_core` decision
+ *   is ignored on those paths).
+ *
+ * Posts without the meta keep the legacy two-way behavior — `merge()` runs
+ * its own LCS and treats every text-divergent matched pair as a candidate
+ * for `use_core`.
+ *
  * @package Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
  * @since   10.9.0
  */
 class WCEmailTemplateSelectiveApplier {
 	/**
 	 * Post meta key for the single-step pre-apply snapshot. Stores an array
-	 * with `revision_id`, `content`, and `snapshot_at` (UTC `Y-m-d H:i:s`).
-	 * The snapshot does **not** record the prior status — on undo the status
-	 * is recomputed via
-	 * {@see WCEmailTemplateDivergenceDetector::reclassify()} so it reflects
-	 * the world as it stands at undo time (core may have shipped a release
-	 * since the apply).
+	 * with `revision_id`, `content`, `last_core_render`, `version`,
+	 * `source_hash`, `last_synced_at`, and `snapshot_at` (UTC `Y-m-d H:i:s`).
+	 * The status is **not** recorded — on undo it is recomputed via
+	 * {@see WCEmailTemplateDivergenceDetector::reclassify()} against current
+	 * state.
 	 *
 	 * @var string
 	 */
@@ -172,16 +187,48 @@ class WCEmailTemplateSelectiveApplier {
 			);
 		}//end try

-		$merged_result      = self::merge( $post_content, $core_content, $choices );
+		// When the post has `last_core_render` meta, the change-summary already classified
+		// each block via three-way attribution (yours-vs-base, core-vs-base) and the merge
+		// can consume that payload directly — gating use_core decisions to "real" conflicts
+		// only and rejecting LCS pairs that the summary classified as separate add+remove.
+		$base_render_for_merge = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, true );
+		$summary_for_merge     = '' !== $base_render_for_merge ? $summary : null;
+
+		$merged_result      = self::merge( $post_content, $core_content, $choices, $summary_for_merge );
 		$merged_content     = $merged_result['content'];
 		$structural_skipped = $merged_result['structural_skipped'];
 		$aliases_migrated   = $merged_result['aliases_migrated'];

+		// Alignment is decided on whitespace-normalized content: serialize_blocks()
+		// can't reproduce the PHP template's literal whitespace byte-for-byte, so a
+		// strict === would never match even when the trees are semantically equal.
+		// When aligned, persist canonical verbatim so source_hash, classify_post,
+		// and downstream byte comparisons hold without further normalization.
+		$is_aligned_with_canonical = (
+			self::normalize_for_comparison( $merged_content )
+			=== self::normalize_for_comparison( $core_content )
+		);
+		if ( $is_aligned_with_canonical ) {
+			$merged_content = $core_content;
+		}
+
+		// Snapshot every meta apply is about to overwrite. Restoring only a
+		// subset would leave the banner / email-list gates reading stale
+		// post-apply `_wc_email_template_version`, hiding the pending update.
+		$prior_last_core_render = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, true );
+		$prior_version          = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true );
+		$prior_source_hash      = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true );
+		$prior_last_synced_at   = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true );
+
 		$revision_id = wp_generate_uuid4();
 		$snapshot    = array(
-			'revision_id' => $revision_id,
-			'content'     => $post_content,
-			'snapshot_at' => gmdate( 'Y-m-d H:i:s' ),
+			'revision_id'      => $revision_id,
+			'content'          => $post_content,
+			'last_core_render' => $prior_last_core_render,
+			'version'          => $prior_version,
+			'source_hash'      => $prior_source_hash,
+			'last_synced_at'   => $prior_last_synced_at,
+			'snapshot_at'      => gmdate( 'Y-m-d H:i:s' ),
 		);
 		update_post_meta( $post_id, self::SNAPSHOT_META_KEY, $snapshot );

@@ -203,21 +250,19 @@ class WCEmailTemplateSelectiveApplier {
 			$saved_post = get_post( $post_id );
 			$saved_body = $saved_post instanceof \WP_Post ? (string) $saved_post->post_content : $merged_content;

-			// When merged content diverges from canonical (any keep_yours or
-			// preserved removed-block), stamp sha1(canonical) and hard-stamp
-			// STATUS_CORE_UPDATED_CUSTOMIZED so the auto-applier (which only
-			// acts on STATUS_CORE_UPDATED_UNCUSTOMIZED) can't silently overwrite
-			// the merchant's choice on the next core bump.
-			$is_aligned_with_canonical = ( $merged_content === $core_content );
-			$source_hash               = $is_aligned_with_canonical
+			$source_hash = $is_aligned_with_canonical
 				? sha1( $saved_body )
 				: sha1( $core_content );
-			$synced_at                 = gmdate( 'Y-m-d H:i:s' );
-			$version_to                = (string) $sync_config['version'];
+			$synced_at   = gmdate( 'Y-m-d H:i:s' );
+			$version_to  = (string) $sync_config['version'];

 			update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, $version_to );
 			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 );
+			// Three-way diff base reference: "what core looked like the last time we synced."
+			// Always stamps the current canonical (NOT the merged content) — selective apply IS
+			// a sync against the new canonical even if the merchant kept some yours-blocks.
+			update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, $core_content );

 			if ( $is_aligned_with_canonical ) {
 				WCEmailTemplateDivergenceDetector::reclassify( $post_id );
@@ -309,6 +354,33 @@ class WCEmailTemplateSelectiveApplier {
 				return $updated;
 			}

+			// Restore every meta apply stamped. `restore_meta_from_snapshot`
+			// no-ops on keys missing from older snapshot formats.
+			self::restore_meta_from_snapshot(
+				$post_id,
+				$snapshot,
+				'last_core_render',
+				WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY
+			);
+			self::restore_meta_from_snapshot(
+				$post_id,
+				$snapshot,
+				'version',
+				WCEmailTemplateDivergenceDetector::VERSION_META_KEY
+			);
+			self::restore_meta_from_snapshot(
+				$post_id,
+				$snapshot,
+				'source_hash',
+				WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY
+			);
+			self::restore_meta_from_snapshot(
+				$post_id,
+				$snapshot,
+				'last_synced_at',
+				WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY
+			);
+
 			// The snapshot's prior_status was correct at snapshot time, but
 			// the world may have moved since (core released, canonical
 			// changed). Ask the classifier for the truth against current
@@ -349,17 +421,56 @@ class WCEmailTemplateSelectiveApplier {
 		self::$logger = $logger;
 	}

+	/**
+	 * Restore one post meta from a snapshot entry. Empty prior values delete
+	 * the meta rather than writing an empty string; missing keys (older
+	 * snapshot format) no-op.
+	 *
+	 * @param int                  $post_id       The post ID.
+	 * @param array<string, mixed> $snapshot      The snapshot array stored in SNAPSHOT_META_KEY.
+	 * @param string               $snapshot_key  The key inside the snapshot array.
+	 * @param string               $post_meta_key The post meta key to write back to.
+	 */
+	private static function restore_meta_from_snapshot( int $post_id, array $snapshot, string $snapshot_key, string $post_meta_key ): void {
+		if ( ! array_key_exists( $snapshot_key, $snapshot ) ) {
+			return;
+		}
+		$value = (string) $snapshot[ $snapshot_key ];
+		if ( '' !== $value ) {
+			update_post_meta( $post_id, $post_meta_key, $value );
+		} else {
+			delete_post_meta( $post_id, $post_meta_key );
+		}
+	}
+
 	/**
 	 * Compute the merged block tree, starting from the merchant's post and
 	 * layering on core's changes per the v1 algorithm.
 	 *
-	 * @param string                                                     $post_content Merchant's current `post_content`.
-	 * @param string                                                     $core_content Canonical core render.
-	 * @param array<int, array{path:array<int|string>, decision:string}> $choices      Per-conflict choices.
+	 * When `$precomputed_summary` is provided (the caller's `last_core_render`
+	 * meta was set, so the change-summary ran three-way attribution), the merge
+	 * defers to the summary's classification:
+	 *
+	 * - Matched pairs whose path is in `removed_blocks` (yours-only) or
+	 *   `added_blocks` (core-only) are REJECTED — the summary correctly
+	 *   identified them as separate adds; the local LCS may have falsely
+	 *   paired them by name. The reject lets Pass 2 / Pass 3 handle them.
+	 * - Matched pairs not in `copy_changes` are silent — Pass 1 skips them
+	 *   even if a `use_core` decision was passed (yours-only edit, no
+	 *   conflict to resolve).
+	 *
+	 * Without `$precomputed_summary` (legacy two-way fallback), the existing
+	 * behavior is preserved: every matched pair with differing inner_text is
+	 * eligible for `use_core`, and the local LCS drives matched-set tracking.
+	 *
+	 * @param string                                                     $post_content        Merchant's current `post_content`.
+	 * @param string                                                     $core_content        Canonical core render.
+	 * @param array<int, array{path:array<int|string>, decision:string}> $choices             Per-conflict choices.
+	 * @param array<string, mixed>|null                                  $precomputed_summary Optional three-way summary payload from {@see WCEmailTemplateChangeSummary::summarize()}; pass `null` to use the legacy two-way merge.
 	 *
 	 * @return array{content:string, structural_skipped:bool, aliases_migrated:string[]}
 	 */
-	private static function merge( string $post_content, string $core_content, array $choices ): array {
+	private static function merge( string $post_content, string $core_content, array $choices, ?array $precomputed_summary = null ): array {
 		$post_blocks = parse_blocks( $post_content );
 		$core_blocks = parse_blocks( $core_content );

@@ -387,6 +498,30 @@ class WCEmailTemplateSelectiveApplier {
 			$choice_map[ self::path_key( $choice['path'] ) ] = $decision;
 		}

+		// Three-way overrides derived from the precomputed summary. `null`
+		// signals the legacy two-way path (no gating).
+		$copy_change_paths = null;
+		$added_path_keys   = array();
+		$removed_path_keys = array();
+		if ( null !== $precomputed_summary ) {
+			$copy_change_paths = array();
+			foreach ( $precomputed_summary['copy_changes'] ?? array() as $cc ) {
+				if ( isset( $cc['path'] ) && is_array( $cc['path'] ) ) {
+					$copy_change_paths[ self::path_key( $cc['path'] ) ] = true;
+				}
+			}
+			foreach ( $precomputed_summary['added_blocks'] ?? array() as $ab ) {
+				if ( isset( $ab['path'] ) && is_array( $ab['path'] ) ) {
+					$added_path_keys[ self::path_key( $ab['path'] ) ] = true;
+				}
+			}
+			foreach ( $precomputed_summary['removed_blocks'] ?? array() as $rb ) {
+				if ( isset( $rb['path'] ) && is_array( $rb['path'] ) ) {
+					$removed_path_keys[ self::path_key( $rb['path'] ) ] = true;
+				}
+			}
+		}
+
 		// Pass 1: matched pairs. Apply use_core decisions on copy changes;
 		// detect parent-name diffs (structural punted, but we still flag
 		// `structural_skipped` so the caller can surface it).
@@ -394,11 +529,22 @@ class WCEmailTemplateSelectiveApplier {
 		$matched_core_set   = array();
 		$matched_post_set   = array();
 		foreach ( $matches as $pair ) {
-			$matched_core_set[ $pair[0] ] = true;
-			$matched_post_set[ $pair[1] ] = true;
-
 			$core_rec = $core_records[ $pair[0] ];
 			$post_rec = $post_records[ $pair[1] ];
+			$core_key = self::path_key( $core_rec['path'] );
+			$post_key = self::path_key( $post_rec['path'] );
+
+			// Three-way reject: applier's LCS paired these but the summary
+			// classified them as separate add+remove. Don't track as matched
+			// (so Pass 2 / Pass 3 will handle them) and don't apply.
+			if ( null !== $precomputed_summary
+				&& ( isset( $added_path_keys[ $core_key ] ) || isset( $removed_path_keys[ $post_key ] ) )
+			) {
+				continue;
+			}
+
+			$matched_core_set[ $pair[0] ] = true;
+			$matched_post_set[ $pair[1] ] = true;

 			if ( $core_rec['parent_name'] !== $post_rec['parent_name'] ) {
 				$structural_skipped = true;
@@ -408,7 +554,14 @@ class WCEmailTemplateSelectiveApplier {
 				continue;
 			}

-			$decision = $choice_map[ self::path_key( $post_rec['path'] ) ] ?? 'keep_yours';
+			// Three-way gate: only paths the summary surfaced as `copy_changes`
+			// are eligible for `use_core`. Yours-only edits are silently
+			// preserved — they aren't conflicts.
+			if ( null !== $copy_change_paths && ! isset( $copy_change_paths[ $post_key ] ) ) {
+				continue;
+			}
+
+			$decision = $choice_map[ $post_key ] ?? 'keep_yours';
 			if ( 'use_core' !== $decision ) {
 				continue;
 			}
@@ -695,6 +848,21 @@ class WCEmailTemplateSelectiveApplier {
 		);
 	}

+	/**
+	 * Whitespace-normalize block markup for semantic comparison. Trims and
+	 * collapses runs of whitespace adjacent to tag boundaries — covers the
+	 * leading/trailing newlines and the spaces inside `<div> ##WOO_CONTENT## </div>`
+	 * that `serialize_blocks()` can't reproduce from a hand-authored PHP template.
+	 *
+	 * @param string $content Block markup.
+	 */
+	private static function normalize_for_comparison( string $content ): string {
+		$content = trim( $content );
+		$content = (string) preg_replace( '/>\s+/', '>', $content );
+		$content = (string) preg_replace( '/\s+</', '<', $content );
+		return $content;
+	}
+
 	/**
 	 * Stable string key for a path array, used as the choice-map key.
 	 *
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncBackfill.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncBackfill.php
index d31633689b5..6f505264a6b 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncBackfill.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncBackfill.php
@@ -301,6 +301,10 @@ class WCEmailTemplateSyncBackfill {
 		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, $current_core_hash );
 		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, gmdate( 'Y-m-d H:i:s' ) );
 		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, $status_for_stamp );
+		// Seed the three-way base with the current canonical render. We don't know the
+		// original generation-time render for pre-existing posts; the alternative — empty
+		// meta, falling back to 2-way's inversion-guard heuristic — is strictly worse.
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, $canonical_post_content );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
index 9ed7d696260..54dbf137d4b 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
@@ -408,8 +408,9 @@ class WCTransactionalEmailPostsGenerator {
 			if ( ! isset( $post_data['meta_input'] ) || ! is_array( $post_data['meta_input'] ) ) {
 				$post_data['meta_input'] = array();
 			}
-			$post_data['meta_input'][ WCEmailTemplateDivergenceDetector::VERSION_META_KEY ]        = (string) $sync_config['version'];
-			$post_data['meta_input'][ WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY ] = gmdate( 'Y-m-d H:i:s' );
+			$post_data['meta_input'][ WCEmailTemplateDivergenceDetector::VERSION_META_KEY ]          = (string) $sync_config['version'];
+			$post_data['meta_input'][ WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY ]   = gmdate( 'Y-m-d H:i:s' );
+			$post_data['meta_input'][ WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY ] = (string) ( $post_data['post_content'] ?? '' );
 		}

 		$post_id = wp_insert_post( $post_data, true );
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
index 7ce8cb81bff..820a2e28542 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
@@ -145,6 +145,46 @@ class WCEmailTemplateAutoApplierTest extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Should stamp _wc_email_template_last_core_render with the new canonical post_content after apply_to_post.
+	 */
+	public function test_apply_to_post_stamps_last_core_render(): void {
+		$email_id = 'wc_test_auto_apply_last_core_render';
+		$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 so the auto-apply has something to write.
+		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 );
+
+		$result = WCEmailTemplateAutoApplier::apply_to_post( $email, $post_id );
+		$this->assertIsArray( $result, 'Atom must return an array on success.' );
+
+		$stored_render = (string) get_post_meta(
+			$post_id,
+			WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY,
+			true
+		);
+
+		$this->assertNotSame( '', $stored_render, 'last_core_render must be populated after auto-apply.' );
+		$this->assertSame(
+			$expected_canonical,
+			$stored_render,
+			'last_core_render must equal the new canonical post_content (post-filter).'
+		);
+	}
+
 	/**
 	 * @testdox Should stamp STATUS_IN_SYNC via the classifier after a successful apply_to_post.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummaryTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummaryTest.php
index 5a0031572cb..0587dbfe1a9 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummaryTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummaryTest.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\WCTransactionalEmail

 use Automattic\WooCommerce\Internal\EmailEditor\Integration;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateChangeSummary;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;

@@ -550,6 +551,590 @@ class WCEmailTemplateChangeSummaryTest extends \WC_Unit_Test_Case {
 		$this->assertFalse( $third['cache_hit'], 'After content mutation, the new key should miss the cache.' );
 	}

+	/**
+	 * @testdox Three-way diff: a yours-only edit (yours changed, core unchanged) yields no diff entry — it is a merchant edit, not a conflict.
+	 */
+	public function test_three_way_yours_only_edit_yields_no_entry(): void {
+		$single_paragraph = static fn( string $text ): array => array(
+			array(
+				'name'       => 'core/paragraph',
+				'inner_text' => $text,
+			),
+		);
+
+		$base = self::records( $single_paragraph( 'Hi.' ) );
+		$core = self::records( $single_paragraph( 'Hi.' ) );
+		$post = self::records( $single_paragraph( 'Hi friend.' ) );
+
+		$diff = WCEmailTemplateChangeSummary::diff_records_three_way( $core, $base, $post );
+
+		$this->assertSame( array(), $diff['copy_changes'], 'No copy_change should fire when only yours moved.' );
+		$this->assertSame( array(), $diff['added_blocks'] );
+		$this->assertSame( array(), $diff['removed_blocks'] );
+	}
+
+	/**
+	 * @testdox Three-way diff: a core-only edit (core changed, yours unchanged) classifies as a copy_change.
+	 */
+	public function test_three_way_core_only_edit_classifies_as_copy_change(): void {
+		$single_paragraph = static fn( string $text ): array => array(
+			array(
+				'name'       => 'core/paragraph',
+				'inner_text' => $text,
+			),
+		);
+
+		$base = self::records( $single_paragraph( 'Hi.' ) );
+		$core = self::records( $single_paragraph( 'Hello.' ) );
+		$post = self::records( $single_paragraph( 'Hi.' ) );
+
+		$diff = WCEmailTemplateChangeSummary::diff_records_three_way( $core, $base, $post );
+
+		$this->assertCount( 1, $diff['copy_changes'] );
+		$this->assertSame( 'Hi.', $diff['copy_changes'][0]['before'] );
+		$this->assertSame( 'Hello.', $diff['copy_changes'][0]['after'] );
+	}
+
+	/**
+	 * @testdox Three-way diff: when both yours and core edited, classifies as a conflict copy_change.
+	 */
+	public function test_three_way_both_edited_classifies_as_conflict(): void {
+		$single_paragraph = static fn( string $text ): array => array(
+			array(
+				'name'       => 'core/paragraph',
+				'inner_text' => $text,
+			),
+		);
+
+		$base = self::records( $single_paragraph( 'Hi.' ) );
+		$core = self::records( $single_paragraph( 'Hello.' ) );
+		$post = self::records( $single_paragraph( 'Hey there.' ) );
+
+		$diff = WCEmailTemplateChangeSummary::diff_records_three_way( $core, $base, $post );
+
+		$this->assertCount( 1, $diff['copy_changes'] );
+		$this->assertSame( 'Hey there.', $diff['copy_changes'][0]['before'] );
+		$this->assertSame( 'Hello.', $diff['copy_changes'][0]['after'] );
+	}
+
+	/**
+	 * @testdox Three-way diff: a yours-only added block classifies as a removed_block (preserved on apply).
+	 */
+	public function test_three_way_yours_only_addition_classifies_as_removed_block(): void {
+		$heading_only = array(
+			array(
+				'name'       => 'core/heading',
+				'inner_text' => 'H',
+			),
+		);
+
+		$base = self::records( $heading_only );
+		$core = self::records( $heading_only );
+		$post = self::records(
+			array(
+				array(
+					'name'       => 'core/heading',
+					'inner_text' => 'H',
+				),
+				array(
+					'name'       => 'core/paragraph',
+					'inner_text' => 'Merchant note.',
+				),
+			)
+		);
+
+		$diff = WCEmailTemplateChangeSummary::diff_records_three_way( $core, $base, $post );
+
+		$this->assertCount( 1, $diff['removed_blocks'] );
+		$this->assertSame( 'core/paragraph', $diff['removed_blocks'][0]['name'] );
+	}
+
+	/**
+	 * @testdox Three-way diff: a core-only added block classifies as an added_block (auto-applied).
+	 */
+	public function test_three_way_core_only_addition_classifies_as_added_block(): void {
+		$heading_only = array(
+			array(
+				'name'       => 'core/heading',
+				'inner_text' => 'H',
+			),
+		);
+
+		$base = self::records( $heading_only );
+		$core = self::records(
+			array(
+				array(
+					'name'       => 'core/heading',
+					'inner_text' => 'H',
+				),
+				array(
+					'name'       => 'core/paragraph',
+					'inner_text' => 'Core PS.',
+				),
+			)
+		);
+		$post = self::records( $heading_only );
+
+		$diff = WCEmailTemplateChangeSummary::diff_records_three_way( $core, $base, $post );
+
+		$this->assertCount( 1, $diff['added_blocks'] );
+		$this->assertSame( 'core/paragraph', $diff['added_blocks'][0]['name'] );
+	}
+
+	/**
+	 * @testdox Bug 04 regression: parallel additions on yours and core classify as separate add+remove, not as a single copy_change.
+	 */
+	public function test_three_way_parallel_additions_classify_separately(): void {
+		$base = self::records(
+			array(
+				array(
+					'name'       => 'core/heading',
+					'inner_text' => 'H',
+				),
+			)
+		);
+		$core = self::records(
+			array(
+				array(
+					'name'       => 'core/heading',
+					'inner_text' => 'H',
+				),
+				array(
+					'name'       => 'core/paragraph',
+					'inner_text' => 'PS from core.',
+				),
+			)
+		);
+		$post = self::records(
+			array(
+				array(
+					'name'       => 'core/heading',
+					'inner_text' => 'H',
+				),
+				array(
+					'name'       => 'core/paragraph',
+					'inner_text' => 'Reach out anytime.',
+				),
+			)
+		);
+
+		$diff = WCEmailTemplateChangeSummary::diff_records_three_way( $core, $base, $post );
+
+		// Bug 04 fingerprint: the 2-way LCS would have paired these as one copy_change. The 3-way
+		// diff identifies them against base as two independent additions on different sides.
+		$this->assertCount( 0, $diff['copy_changes'], 'Parallel additions must not collapse into a single copy_change.' );
+		$this->assertCount( 1, $diff['added_blocks'] );
+		$this->assertCount( 1, $diff['removed_blocks'] );
+		$this->assertSame( 'core/paragraph', $diff['added_blocks'][0]['name'] );
+		$this->assertSame( 'core/paragraph', $diff['removed_blocks'][0]['name'] );
+	}
+
+	/**
+	 * @testdox summarize() takes the three-way path when last_core_render meta is set and yours-only edits don't surface as copy_changes.
+	 */
+	public function test_summarize_uses_three_way_when_base_meta_present(): void {
+		$email_id = 'cs_three_way_yours_only_edit';
+		$this->register_fixture_email( $email_id );
+
+		$base_and_core = "<!-- wp:heading -->\n<h2>H</h2>\n<!-- /wp:heading -->\n\n"
+			. "<!-- wp:paragraph -->\n<p>Hi.</p>\n<!-- /wp:paragraph -->";
+		$post_content  = "<!-- wp:heading -->\n<h2>H</h2>\n<!-- /wp:heading -->\n\n"
+			. "<!-- wp:paragraph -->\n<p>Hi friend.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $base_and_core );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, $base_and_core );
+
+		$payload = WCEmailTemplateChangeSummary::summarize( $post_id );
+
+		$this->assertFalse( $payload['is_fallback'] );
+		$this->assertSame(
+			array(),
+			$payload['copy_changes'],
+			'A yours-only edit (yours diverges from base, core unchanged) should not produce a copy_change in 3-way mode.'
+		);
+		$this->assertSame( array(), $payload['added_blocks'] );
+		$this->assertSame( array(), $payload['removed_blocks'] );
+	}
+
+	/**
+	 * @testdox summarize() cache invalidates when last_core_render meta changes.
+	 */
+	public function test_summarize_cache_busts_when_base_render_changes(): void {
+		$email_id = 'cs_three_way_cache_bust';
+		$this->register_fixture_email( $email_id );
+
+		$core_content = "<!-- wp:paragraph -->\n<p>Core.</p>\n<!-- /wp:paragraph -->";
+		$post_content = "<!-- wp:paragraph -->\n<p>Yours.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $core_content );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		update_post_meta(
+			$post_id,
+			WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY,
+			"<!-- wp:paragraph -->\n<p>Base A.</p>\n<!-- /wp:paragraph -->"
+		);
+		$first = WCEmailTemplateChangeSummary::summarize( $post_id );
+		$this->assertFalse( $first['cache_hit'], 'First call must compute fresh.' );
+
+		// Same call again, base unchanged → cache hit.
+		$second = WCEmailTemplateChangeSummary::summarize( $post_id );
+		$this->assertTrue( $second['cache_hit'], 'Second call with unchanged base should hit cache.' );
+
+		// Mutate the base — must miss the cache.
+		update_post_meta(
+			$post_id,
+			WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY,
+			"<!-- wp:paragraph -->\n<p>Base B.</p>\n<!-- /wp:paragraph -->"
+		);
+		$third = WCEmailTemplateChangeSummary::summarize( $post_id );
+		$this->assertFalse( $third['cache_hit'], 'Changing base_render must bust the cache.' );
+	}
+
+	/**
+	 * @testdox summarize() falls back to the two-way path when last_core_render meta is missing.
+	 */
+	public function test_summarize_falls_back_to_two_way_when_base_meta_missing(): void {
+		$email_id = 'cs_three_way_fallback';
+		$this->register_fixture_email( $email_id );
+
+		$core_content = "<!-- wp:paragraph -->\n<p>Original.</p>\n<!-- /wp:paragraph -->";
+		$post_content = "<!-- wp:paragraph -->\n<p>Edited.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $core_content );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		// No last_core_render meta on this post — expect the existing 2-way classification.
+		$payload = WCEmailTemplateChangeSummary::summarize( $post_id );
+
+		$this->assertFalse( $payload['is_fallback'] );
+		$this->assertCount(
+			1,
+			$payload['copy_changes'],
+			'Two-way fallback must still surface yours-vs-core text divergence as a copy_change.'
+		);
+		$this->assertSame( 'Edited.', $payload['copy_changes'][0]['before'] );
+		$this->assertSame( 'Original.', $payload['copy_changes'][0]['after'] );
+	}
+
+	/**
+	 * @testdox Bug 03 regression: three-way diff does not fall back to the release-notes copy on a heavily-customized post when last_core_render meta is present.
+	 *
+	 * The inversion-guard heuristic (>= 5 unmatched && 0 copy && post >= 1.5x core) fires under
+	 * the 2-way fallback for the same post, hiding actionable diffs behind "see release notes".
+	 * With base meta set, the 3-way path is deterministic — the guard isn't reached and the
+	 * merchant gets per-block detail.
+	 */
+	public function test_three_way_does_not_fall_back_on_heavily_customized_post(): void {
+		$email_id = 'cs_three_way_heavily_customized';
+		$this->register_fixture_email( $email_id );
+
+		$base_and_core = "<!-- wp:heading -->\n<h2>Hi</h2>\n<!-- /wp:heading -->\n\n"
+			. "<!-- wp:paragraph -->\n<p>Hello.</p>\n<!-- /wp:paragraph -->";
+
+		// Merchant added 6 unrelated blocks (above the inversion-guard threshold of 5);
+		// nothing on the core side was edited or removed.
+		$post_content = "<!-- wp:heading -->\n<h2>Hi</h2>\n<!-- /wp:heading -->\n\n"
+			. "<!-- wp:paragraph -->\n<p>Hello.</p>\n<!-- /wp:paragraph -->\n\n"
+			. "<!-- wp:image --><figure></figure><!-- /wp:image -->\n\n"
+			. "<!-- wp:image --><figure></figure><!-- /wp:image -->\n\n"
+			. "<!-- wp:image --><figure></figure><!-- /wp:image -->\n\n"
+			. "<!-- wp:gallery --><figure></figure><!-- /wp:gallery -->\n\n"
+			. "<!-- wp:list --><ul></ul><!-- /wp:list -->\n\n"
+			. '<!-- wp:separator --><hr/><!-- /wp:separator -->';
+
+		$this->use_canonical_content( $email_id, $base_and_core );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, $base_and_core );
+
+		$result = WCEmailTemplateChangeSummary::summarize( $post_id );
+
+		$this->assertFalse(
+			$result['is_fallback'],
+			'Three-way must not fall back to the release-notes line on a heavily-customized post.'
+		);
+		$this->assertGreaterThanOrEqual(
+			6,
+			count( $result['removed_blocks'] ),
+			'All six yours-only additions should appear as removed_blocks (preserved on apply).'
+		);
+	}
+
+	/**
+	 * @testdox Bug 04 regression: parallel additions on yours and core via summarize() classify as separate add+remove, not as a single copy_change.
+	 */
+	public function test_three_way_parallel_additions_via_summarize(): void {
+		$email_id = 'cs_three_way_parallel_summarize';
+		$this->register_fixture_email( $email_id );
+
+		$base_render = "<!-- wp:heading -->\n<h2>Hi</h2>\n<!-- /wp:heading -->";
+
+		$core_content = "<!-- wp:heading -->\n<h2>Hi</h2>\n<!-- /wp:heading -->\n\n"
+			. "<!-- wp:paragraph -->\n<p>PS from core.</p>\n<!-- /wp:paragraph -->";
+
+		$post_content = "<!-- wp:heading -->\n<h2>Hi</h2>\n<!-- /wp:heading -->\n\n"
+			. "<!-- wp:paragraph -->\n<p>Reach out anytime.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $core_content );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, $base_render );
+
+		$result = WCEmailTemplateChangeSummary::summarize( $post_id );
+
+		$this->assertFalse( $result['is_fallback'] );
+
+		$copy_paragraphs = array_values(
+			array_filter(
+				$result['copy_changes'],
+				static fn ( array $cc ): bool => 'Paragraph' === ( $cc['block'] ?? '' )
+			)
+		);
+		$this->assertSame(
+			array(),
+			$copy_paragraphs,
+			'Parallel additions must not collapse into a Paragraph copy_change in 3-way mode.'
+		);
+		$this->assertCount( 1, $result['added_blocks'], 'Core PS should appear in added_blocks.' );
+		$this->assertCount( 1, $result['removed_blocks'], 'Yours\' note should appear in removed_blocks.' );
+	}
+
+	/**
+	 * @testdox Three-way diff: occurrence ordinal counts every matched pair, not just emitted conflicts (CodeRabbit feedback on PR 64716).
+	 *
+	 * Earlier same-name blocks that aren't emitted (yours-only edit, both unchanged) must still
+	 * advance the occurrence counter so a later conflict's ordinal reflects the block's true
+	 * position in the document. Mirrors the 2-way `diff_records()` placement of the counter.
+	 */
+	public function test_three_way_occurrence_counts_every_matched_pair(): void {
+		// Three Paragraph blocks. First yours-only edit (no entry). Second core-only edit
+		// (copy_change). Third both unchanged (no entry). The conflict's "occurrence" should
+		// be 2, NOT 1 — the second-of-three Paragraph in the document.
+		$base = self::records(
+			array(
+				array(
+					'name'       => 'core/paragraph',
+					'inner_text' => 'P1.',
+				),
+				array(
+					'name'       => 'core/paragraph',
+					'inner_text' => 'P2.',
+				),
+				array(
+					'name'       => 'core/paragraph',
+					'inner_text' => 'P3.',
+				),
+			)
+		);
+		$core = self::records(
+			array(
+				array(
+					'name'       => 'core/paragraph',
+					'inner_text' => 'P1.',
+				),
+				array(
+					'name'       => 'core/paragraph',
+					'inner_text' => 'P2 changed by core.',
+				),
+				array(
+					'name'       => 'core/paragraph',
+					'inner_text' => 'P3.',
+				),
+			)
+		);
+		$post = self::records(
+			array(
+				array(
+					'name'       => 'core/paragraph',
+					'inner_text' => 'P1 edited by yours.',
+				),
+				array(
+					'name'       => 'core/paragraph',
+					'inner_text' => 'P2.',
+				),
+				array(
+					'name'       => 'core/paragraph',
+					'inner_text' => 'P3.',
+				),
+			)
+		);
+
+		$diff = WCEmailTemplateChangeSummary::diff_records_three_way( $core, $base, $post );
+
+		$this->assertCount( 1, $diff['copy_changes'] );
+		$this->assertSame(
+			2,
+			$diff['copy_changes'][0]['occurrence'],
+			'The conflict on the second Paragraph should be labeled occurrence 2 of 3, not 1 of 3.'
+		);
+		$this->assertSame( 3, $diff['copy_changes'][0]['total'] );
+	}
+
+	/**
+	 * @testdox Three-way diff: structural wrappers route to structural_changes, not added_blocks/removed_blocks (CodeRabbit feedback on PR 64716).
+	 *
+	 * The selective applier skips structural blocks (`core/group`, `core/columns`, etc.) at
+	 * merge time, so surfacing them in `added_blocks` would advertise an "Added Group block"
+	 * the apply will never apply. Mirrors 2-way `diff_records()` behavior.
+	 */
+	public function test_three_way_routes_structural_wrappers_to_structural_changes(): void {
+		$base = self::records(
+			array(
+				array(
+					'name'       => 'core/heading',
+					'inner_text' => 'H',
+				),
+			)
+		);
+		// Core adds a group wrapper; yours adds a columns wrapper.
+		$core = self::records(
+			array(
+				array(
+					'name'       => 'core/heading',
+					'inner_text' => 'H',
+				),
+				array(
+					'name'       => 'core/group',
+					'inner_text' => '',
+				),
+			)
+		);
+		$post = self::records(
+			array(
+				array(
+					'name'       => 'core/heading',
+					'inner_text' => 'H',
+				),
+				array(
+					'name'       => 'core/columns',
+					'inner_text' => '',
+				),
+			)
+		);
+
+		$diff = WCEmailTemplateChangeSummary::diff_records_three_way( $core, $base, $post );
+
+		$structural_names = array_map( static fn( array $e ): string => (string) ( $e['kind'] ?? '' ), $diff['structural_changes'] );
+		$this->assertContains( 'nest', $structural_names, 'Structural wrappers must route to structural_changes with kind "nest".' );
+		$this->assertCount(
+			2,
+			$diff['structural_changes'],
+			'Both yours-only and core-only structural wrappers should produce structural_changes entries.'
+		);
+		$this->assertSame( array(), $diff['added_blocks'], 'core/group must not appear in added_blocks.' );
+		$this->assertSame( array(), $diff['removed_blocks'], 'core/columns must not appear in removed_blocks.' );
+	}
+
+	/**
+	 * @testdox Three-way diff: no reorder structural change is emitted for blocks reordered relative to base.
+	 *
+	 * Pins the docblock claim on `diff_records_three_way()` that the 2-way reorder pass
+	 * is structurally unreachable under three-way attribution. The fixture uses three
+	 * differently-named blocks so the 2-way LCS (which pairs by `name`) can leave
+	 * same-named entries unmatched. Core keeps `[Heading, Paragraph, Image]`; post
+	 * moves Image to the front: `[Image, Heading, Paragraph]`. Two-way LCS picks the
+	 * length-2 alignment `Heading+Paragraph`, leaves `Image` unmatched on both sides,
+	 * and the reorder pass collapses them into a `Reordered Image` entry. Three-way
+	 * attributes each block via base indices instead — the unmatched post-side `Image`
+	 * becomes a `removed_blocks` entry and the unmatched core-side `Image` becomes a
+	 * `merchant_removed` structural entry, neither of which is a `reorder`.
+	 */
+	public function test_three_way_does_not_emit_reorder_for_blocks_reordered_relative_to_base(): void {
+		$base = self::records(
+			array(
+				array(
+					'name'       => 'core/heading',
+					'inner_text' => 'H',
+				),
+				array(
+					'name'       => 'core/paragraph',
+					'inner_text' => 'P.',
+				),
+				array(
+					'name'       => 'core/image',
+					'inner_text' => '',
+				),
+			)
+		);
+		$core = self::records(
+			array(
+				array(
+					'name'       => 'core/heading',
+					'inner_text' => 'H',
+				),
+				array(
+					'name'       => 'core/paragraph',
+					'inner_text' => 'P.',
+				),
+				array(
+					'name'       => 'core/image',
+					'inner_text' => '',
+				),
+			)
+		);
+		$post = self::records(
+			array(
+				array(
+					'name'       => 'core/image',
+					'inner_text' => '',
+				),
+				array(
+					'name'       => 'core/heading',
+					'inner_text' => 'H',
+				),
+				array(
+					'name'       => 'core/paragraph',
+					'inner_text' => 'P.',
+				),
+			)
+		);
+
+		// Sanity check: the same fixture under the 2-way path emits a `reorder` entry.
+		// If this assertion ever stops holding, the fixture has stopped exercising the
+		// reorder pass and the three-way assertion below has become a tautology.
+		// `diff_records()` is private; reflection here keeps the production API tight.
+		$two_way_method = new \ReflectionMethod( WCEmailTemplateChangeSummary::class, 'diff_records' );
+		$two_way_method->setAccessible( true );
+		$two_way       = $two_way_method->invoke( null, $core, $post );
+		$two_way_kinds = array_map( static fn( array $e ): string => (string) ( $e['kind'] ?? '' ), $two_way['structural_changes'] );
+		$this->assertContains(
+			'reorder',
+			$two_way_kinds,
+			'Fixture must trigger the 2-way reorder pass; otherwise the three-way assertion is meaningless.'
+		);
+
+		$three_way       = WCEmailTemplateChangeSummary::diff_records_three_way( $core, $base, $post );
+		$three_way_kinds = array_map( static fn( array $e ): string => (string) ( $e['kind'] ?? '' ), $three_way['structural_changes'] );
+		$this->assertNotContains(
+			'reorder',
+			$three_way_kinds,
+			'Three-way must not emit a reorder structural entry — base-anchored matching makes it unreachable.'
+		);
+	}
+
+	/**
+	 * Build a list of flatten_blocks-shaped records from a simple list of name + inner_text pairs.
+	 * Each record gets a top-level path (`[$idx]`) and a null parent_name.
+	 *
+	 * @param array<int, array{name:string, inner_text:string}> $simple Simple record specs.
+	 * @return array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}>
+	 */
+	private static function records( array $simple ): array {
+		$out = array();
+		foreach ( $simple as $i => $r ) {
+			$out[] = array(
+				'path'        => array( $i ),
+				'parent_name' => null,
+				'name'        => $r['name'],
+				'inner_text'  => $r['inner_text'],
+			);
+		}
+		return $out;
+	}
+
 	/**
 	 * Register a fixture email and wire up the sync registry to pick it up.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplierTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplierTest.php
index 864c9eb71e8..d819ad6bba4 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplierTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplierTest.php
@@ -322,6 +322,119 @@ class WCEmailTemplateSelectiveApplierTest extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Bug 04 regression: parallel additions on yours and core both survive the apply when last_core_render meta is set.
+	 *
+	 * Without 3-way, the applier's LCS pairs yours' added paragraph with core's added paragraph
+	 * (name-only match) and they end up in copy_changes; default keep_yours preserves yours' text
+	 * and core's added paragraph never gets inserted. With 3-way (base meta present), the change-
+	 * summary correctly classifies them as separate add+remove and the applier's merge inserts
+	 * core's addition while preserving yours.
+	 */
+	public function test_apply_selectively_three_way_keeps_parallel_additions_separate(): void {
+		$email_id = 'sa_three_way_parallel_additions';
+		$this->register_fixture_email( $email_id );
+
+		$base_render = "<!-- wp:heading -->\n<h2>H</h2>\n<!-- /wp:heading -->";
+
+		$core_content = "<!-- wp:heading -->\n<h2>H</h2>\n<!-- /wp:heading -->\n\n"
+			. "<!-- wp:paragraph -->\n<p>PS from core.</p>\n<!-- /wp:paragraph -->";
+
+		$post_content = "<!-- wp:heading -->\n<h2>H</h2>\n<!-- /wp:heading -->\n\n"
+			. "<!-- wp:paragraph -->\n<p>Reach out anytime.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $core_content );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		update_post_meta(
+			$post_id,
+			WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY,
+			$base_render
+		);
+
+		$result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+		$this->assertIsArray( $result );
+		$this->assertSame( 'applied', $result['status'] );
+		$this->assertStringContainsString( 'Reach out anytime.', $result['merged_content'], 'Yours\' added paragraph must be preserved.' );
+		$this->assertStringContainsString( 'PS from core.', $result['merged_content'], 'Core\'s added paragraph must be inserted.' );
+	}
+
+	/**
+	 * @testdox Three-way: a yours-only edit is preserved even when use_core is requested explicitly — the summary doesn't classify it as a conflict.
+	 */
+	public function test_apply_selectively_three_way_ignores_use_core_for_yours_only_edit(): void {
+		$email_id = 'sa_three_way_yours_only_edit';
+		$this->register_fixture_email( $email_id );
+
+		$base_and_core = "<!-- wp:paragraph -->\n<p>Hi.</p>\n<!-- /wp:paragraph -->";
+		$post_content  = "<!-- wp:paragraph -->\n<p>Hi friend.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $base_and_core );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+		update_post_meta(
+			$post_id,
+			WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY,
+			$base_and_core
+		);
+
+		$result = WCEmailTemplateSelectiveApplier::apply_selectively(
+			$post_id,
+			array(
+				array(
+					'path'     => array( 0 ),
+					'decision' => 'use_core',
+				),
+			)
+		);
+
+		$this->assertIsArray( $result );
+		$this->assertStringContainsString(
+			'Hi friend.',
+			$result['merged_content'],
+			'Three-way must preserve yours-only edits even when use_core is explicitly requested — they are not conflicts.'
+		);
+		$this->assertStringNotContainsString(
+			'Hi.</p>',
+			$result['merged_content'],
+			'Yours\' edit must not have been replaced with core\'s text.'
+		);
+	}
+
+	/**
+	 * @testdox Should stamp _wc_email_template_last_core_render with current canonical (not merged content) after apply.
+	 *
+	 * Per the three-way diff design: base = "what core looked like the last time we synced".
+	 * Selective apply IS a sync against the new canonical even if the merchant kept some
+	 * yours-blocks, so base advances to the current canonical regardless of merge result.
+	 */
+	public function test_apply_selectively_stamps_last_core_render_with_canonical(): void {
+		$email_id = 'sa_last_core_render';
+		$this->register_fixture_email( $email_id );
+
+		$core_content = "<!-- wp:paragraph -->\n<p>Core.</p>\n<!-- /wp:paragraph -->";
+		$post_content = "<!-- wp:paragraph -->\n<p>Merchant.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $core_content );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		$result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+		$this->assertIsArray( $result );
+		$this->assertSame( 'applied', $result['status'] );
+
+		$stored_render = (string) get_post_meta(
+			$post_id,
+			WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY,
+			true
+		);
+
+		$this->assertSame(
+			$core_content,
+			$stored_render,
+			'last_core_render after selective apply should equal the current canonical core content, not the merged content.'
+		);
+	}
+
 	/**
 	 * @testdox Should stamp source_hash from the post_content WordPress actually persisted on a use_core apply.
 	 *
@@ -410,6 +523,59 @@ class WCEmailTemplateSelectiveApplierTest extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Should stamp STATUS_IN_SYNC when use_core resolves the only conflict and merged differs from canonical only in tag-boundary whitespace.
+	 *
+	 * Regression: prior to Option B, `is_aligned_with_canonical` was a raw
+	 * `===` against `compute_canonical_post_content()`. Canonical comes from
+	 * the PHP template (literal `\n` between blocks, leading/trailing
+	 * whitespace), while merged comes from `serialize_blocks()`. The two are
+	 * semantically equal but never byte-equal, so the fully-resolved scenario
+	 * could never reach STATUS_IN_SYNC — posts got pinned at CUSTOMIZED
+	 * forever, and the banner / email-list indicator never cleared.
+	 */
+	public function test_apply_selectively_use_core_reaches_in_sync_when_merged_matches_canonical_modulo_whitespace(): void {
+		$email_id = 'sa_use_core_aligned_in_sync';
+		$this->register_fixture_email( $email_id );
+
+		// Canonical mimics what `wc_get_template_html()` produces: leading and
+		// trailing newlines that `serialize_blocks()` will not reproduce.
+		$canonical = "\n<!-- wp:paragraph -->\n<p>Canonical copy.</p>\n<!-- /wp:paragraph -->\n";
+		$post_html = "<!-- wp:paragraph -->\n<p>Merchant copy.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $canonical );
+		$post_id = $this->create_woo_email_post( $email_id, $post_html );
+
+		$result = WCEmailTemplateSelectiveApplier::apply_selectively(
+			$post_id,
+			array(
+				array(
+					'path'     => array( 0 ),
+					'decision' => 'use_core',
+				),
+			)
+		);
+		$this->assertIsArray( $result );
+
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+			'Fully-resolved apply must reach STATUS_IN_SYNC even when serialize_blocks output differs from canonical only in tag-boundary whitespace.'
+		);
+
+		$persisted = get_post( $post_id );
+		$this->assertInstanceOf( \WP_Post::class, $persisted );
+		$this->assertSame(
+			$canonical,
+			(string) $persisted->post_content,
+			'Aligned merged must be persisted as canonical verbatim so source_hash and classify_post hold without normalization.'
+		);
+		$this->assertSame(
+			sha1( $canonical ),
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true )
+		);
+	}
+
 	/**
 	 * @testdox Should restore post_content and consume the snapshot meta on undo.
 	 *
@@ -462,6 +628,142 @@ class WCEmailTemplateSelectiveApplierTest extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Undo restores the prior _wc_email_template_last_core_render alongside post_content (CodeRabbit feedback on PR 64716).
+	 *
+	 * Without this, the restored content would diff against the post-apply base and the
+	 * pending core update would silently disappear from the drawer.
+	 */
+	public function test_undo_restores_prior_last_core_render(): void {
+		$email_id = 'undo_restores_three_way_base';
+		$this->register_fixture_email( $email_id );
+
+		$old_canonical = "<!-- wp:paragraph -->\n<p>Old core.</p>\n<!-- /wp:paragraph -->";
+		$post_content  = "<!-- wp:paragraph -->\n<p>Yours.</p>\n<!-- /wp:paragraph -->";
+		$new_canonical = "<!-- wp:paragraph -->\n<p>New core.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $new_canonical );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		// Pre-apply: post is on the OLD canonical as its base reference.
+		update_post_meta(
+			$post_id,
+			WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY,
+			$old_canonical
+		);
+
+		$apply_result = WCEmailTemplateSelectiveApplier::apply_selectively(
+			$post_id,
+			array(
+				array(
+					'path'     => array( 0 ),
+					'decision' => 'use_core',
+				),
+			)
+		);
+		$this->assertIsArray( $apply_result );
+
+		// Sanity: apply advanced the base reference to the new canonical.
+		$this->assertSame(
+			$new_canonical,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, true )
+		);
+
+		$undo_result = WCEmailTemplateSelectiveApplier::undo( $post_id, $apply_result['revision_id'] );
+		$this->assertIsArray( $undo_result );
+		$this->assertSame( 'restored', $undo_result['status'] );
+
+		// Post-apply: undo must have rolled back the base to the old canonical so summarize()
+		// recognizes the pending core update on the next read.
+		$this->assertSame(
+			$old_canonical,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, true ),
+			'Undo must restore the pre-apply last_core_render so the pending core update stays surfaced.'
+		);
+	}
+
+	/**
+	 * @testdox Undo restores version, source_hash, and last_synced_at alongside last_core_render and content.
+	 *
+	 * Without full meta restoration the post lands in an inconsistent state after undo: the
+	 * `_wc_email_template_version` meta stays at the post-apply value, which defeats the
+	 * editor-banner and email-list indicator gates (both require
+	 * `templateVersion < currentVersion`) and hides the pending update from the merchant on
+	 * every surface despite `summarize()` still reporting it.
+	 */
+	public function test_undo_restores_full_meta_tuple_not_just_content_and_base(): void {
+		$email_id = 'undo_restores_full_meta_tuple';
+		$this->register_fixture_email( $email_id );
+
+		$old_canonical = "<!-- wp:paragraph -->\n<p>Old core.</p>\n<!-- /wp:paragraph -->";
+		$new_canonical = "<!-- wp:paragraph -->\n<p>New core.</p>\n<!-- /wp:paragraph -->";
+		$post_content  = "<!-- wp:paragraph -->\n<p>Old core.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $new_canonical );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		// Stage the post on a stale baseline. The fixture template's `@version`
+		// (parsed by the registry) is 1.2.3, so apply stamps post version to
+		// 1.2.3 regardless. A pre-apply value of `0.9.0` proves restoration
+		// without needing to bump the registry mid-test.
+		$pre_apply_version        = '0.9.0';
+		$pre_apply_source_hash    = sha1( $old_canonical );
+		$pre_apply_last_synced_at = '2026-04-01 00:00:00';
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, $old_canonical );
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, $pre_apply_version );
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, $pre_apply_source_hash );
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, $pre_apply_last_synced_at );
+
+		$apply_result = WCEmailTemplateSelectiveApplier::apply_selectively(
+			$post_id,
+			array(
+				array(
+					'path'     => array( 0 ),
+					'decision' => 'use_core',
+				),
+			)
+		);
+		$this->assertIsArray( $apply_result );
+
+		// Sanity: apply advanced every stamped meta.
+		$this->assertNotSame(
+			$pre_apply_source_hash,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ),
+			'Sanity: apply should have advanced source_hash.'
+		);
+		$this->assertNotSame(
+			$pre_apply_last_synced_at,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true ),
+			'Sanity: apply should have advanced last_synced_at.'
+		);
+
+		$undo_result = WCEmailTemplateSelectiveApplier::undo( $post_id, $apply_result['revision_id'] );
+		$this->assertIsArray( $undo_result );
+		$this->assertSame( 'restored', $undo_result['status'] );
+
+		// All four metas must be restored to their pre-apply values.
+		$this->assertSame(
+			$old_canonical,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, true ),
+			'Undo must restore last_core_render.'
+		);
+		$this->assertSame(
+			$pre_apply_version,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ),
+			'Undo must restore version — the indicator gate depends on it.'
+		);
+		$this->assertSame(
+			$pre_apply_source_hash,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ),
+			'Undo must restore source_hash so classify_post sees the pre-apply baseline.'
+		);
+		$this->assertSame(
+			$pre_apply_last_synced_at,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true ),
+			'Undo must restore last_synced_at.'
+		);
+	}
+
 	/**
 	 * @testdox Should reclassify after undo rather than restore the stored prior_status verbatim.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncBackfillTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncBackfillTest.php
index bdda5009e24..c9003f5f0ef 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncBackfillTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncBackfillTest.php
@@ -111,6 +111,32 @@ class WCEmailTemplateSyncBackfillTest extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Should seed _wc_email_template_last_core_render meta with the current canonical render during backfill.
+	 */
+	public function test_backfill_seeds_last_core_render(): void {
+		$email_id = 'wc_test_backfill_last_core_render';
+		$email    = $this->register_fixture_email( $email_id );
+
+		$canonical = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+
+		$post_id = $this->create_unstamped_post( $email_id, $canonical, true );
+
+		WCEmailTemplateSyncBackfill::run();
+
+		$stored_render = (string) get_post_meta(
+			$post_id,
+			WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY,
+			true
+		);
+
+		$this->assertSame(
+			$canonical,
+			$stored_render,
+			'Backfill must seed last_core_render with the current canonical render.'
+		);
+	}
+
 	/**
 	 * Case B: content diverges from canonical but the post has never been edited.
 	 * Expectation: post_content rewritten to canonical, status = in_sync.
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGeneratorTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGeneratorTest.php
index ea2494353c3..18138884f35 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGeneratorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGeneratorTest.php
@@ -4,6 +4,7 @@ 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\WCTransactionalEmails;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
@@ -198,6 +199,48 @@ class WCTransactionalEmailPostsGeneratorTest extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Should stamp _wc_email_template_last_core_render meta with the canonical post_content at generation time.
+	 */
+	public function test_generation_stamps_last_core_render_meta(): void {
+		$email_type = 'customer_on_hold_order';
+
+		$this->email_generator->init_default_transactional_emails();
+		$this->template_manager->delete_email_template( $email_type );
+
+		$post_id = $this->email_generator->generate_email_template_if_not_exists( $email_type );
+
+		$this->assertIsInt( $post_id );
+		$this->assertGreaterThan( 0, $post_id );
+
+		$stored_render = (string) get_post_meta(
+			$post_id,
+			WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY,
+			true
+		);
+
+		$this->assertNotSame(
+			'',
+			$stored_render,
+			'_wc_email_template_last_core_render should be populated at generation time.'
+		);
+
+		$email = null;
+		foreach ( \WC_Emails::instance()->get_emails() as $candidate ) {
+			if ( $candidate instanceof \WC_Email && $candidate->id === $email_type ) {
+				$email = $candidate;
+				break;
+			}
+		}
+		$this->assertInstanceOf( \WC_Email::class, $email );
+
+		$this->assertSame(
+			WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email ),
+			$stored_render,
+			'last_core_render must equal compute_canonical_post_content() output.'
+		);
+	}
+
 	/**
 	 * Emails that are opted in for the block editor but whose templates lack a parseable @version
 	 * header are absent from the sync registry and must not be stamped.