diff --git a/2meet-data-optimizer.php b/2meet-data-optimizer.php
index d6d1a18..a7fcf5d 100644
--- a/2meet-data-optimizer.php
+++ b/2meet-data-optimizer.php
@@ -234,6 +234,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
// ── v2.0.0 Engine + Adapters + Modules ───────────────────────────────────
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
+require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
@@ -292,6 +293,8 @@ if ( is_admin() ) {
TMDO_Deprecated_Addons_Notice::register();
require_once TMDO_PATH . 'admin/class-tmdo-missing-addons-notice.php';
TMDO_Missing_Addons_Notice::register();
+ require_once TMDO_PATH . 'admin/class-tmdo-promotion-guard-notice.php';
+ TMDO_Promotion_Guard_Notice::register();
}
// ── WP-CLI ─────────────────────────────────────────────────────────────────
diff --git a/admin/class-tmdo-promotion-guard-notice.php b/admin/class-tmdo-promotion-guard-notice.php
new file mode 100644
index 0000000..c5e6cb9
--- /dev/null
+++ b/admin/class-tmdo-promotion-guard-notice.php
@@ -0,0 +1,63 @@
+ $integrations ) {
+ $lines[] = sprintf( '%s(%s):wp tmdo bridge-restore-native %s --demote', $entity_type, implode( ' / ', $integrations ), $entity_type );
+ }
+ printf(
+ '
',
+ esc_html__( '2meet Data Optimizer:', '2meet-data-optimizer' ),
+ esc_html(
+ sprintf(
+ /* translators: %s: "entity (integrations): command" list */
+ __( '以下實體處於 aeav_only,但這些外掛會直接讀取原生 meta,可能讀到過期或遺失的資料。請先備份,再把扁平資料寫回原生並降級:%s', '2meet-data-optimizer' ),
+ implode( ';', $lines )
+ )
+ )
+ );
+ }
+}
diff --git a/includes/engine/class-tmdo-mode-manager.php b/includes/engine/class-tmdo-mode-manager.php
index 5fed59d..fa79a90 100644
--- a/includes/engine/class-tmdo-mode-manager.php
+++ b/includes/engine/class-tmdo-mode-manager.php
@@ -140,6 +140,11 @@ final class TMDO_Mode_Manager {
);
}
+ $guarded = TMDO_Promotion_Guard::check_promotion( $entity_type, $new_mode );
+ if ( $guarded ) {
+ return $guarded;
+ }
+
$all = self::all();
$all[ $entity_type ] = $new_mode;
diff --git a/includes/engine/class-tmdo-promotion-guard.php b/includes/engine/class-tmdo-promotion-guard.php
new file mode 100644
index 0000000..4af8dac
--- /dev/null
+++ b/includes/engine/class-tmdo-promotion-guard.php
@@ -0,0 +1,140 @@
+ array( 'post', 'user', 'term', 'comment' ),
+ 'WooCommerce' => array( 'post', 'user' ),
+ );
+
+ /**
+ * Check a mode change. Only promotion to aeav_only is guarded.
+ *
+ * @param string $entity_type Entity type.
+ * @param string $new_mode Target mode.
+ * @return WP_Error|null Error when refused.
+ */
+ public static function check_promotion( string $entity_type, string $new_mode ): ?WP_Error {
+ if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $new_mode ) {
+ return null;
+ }
+ return self::refuse( $entity_type, TMDO_Mode_Manager::MODE_AEAV_ONLY );
+ }
+
+ /**
+ * Check a cleanup of the entity's native managed meta rows.
+ *
+ * @param string $entity_type Entity type.
+ * @return WP_Error|null Error when refused.
+ */
+ public static function check_cleanup( string $entity_type ): ?WP_Error {
+ return self::refuse( $entity_type, 'cleanup' );
+ }
+
+ /**
+ * Guarded entity types that are already in aeav_only (promoted before the
+ * guard existed). Native meta may be stale there.
+ *
+ * @return array Entity type → integration names.
+ */
+ public static function violations(): array {
+ $out = array();
+ foreach ( TMDO_Mode_Manager::all() as $type => $mode ) {
+ if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) {
+ continue;
+ }
+ $integrations = self::integrations_for( $type );
+ if ( ! empty( $integrations ) ) {
+ $out[ $type ] = $integrations;
+ }
+ }
+ return $out;
+ }
+
+ /**
+ * Active integrations that read this entity type natively.
+ *
+ * @param string $entity_type Entity type.
+ * @return string[]
+ */
+ private static function integrations_for( string $entity_type ): array {
+ $active = array(
+ 'HivePress' => TMDO_Compatibility::is_hivepress_active(),
+ 'WooCommerce' => TMDO_Compatibility::is_woocommerce_active(),
+ );
+
+ $names = array();
+ foreach ( self::GUARDED as $name => $types ) {
+ if ( $active[ $name ] && in_array( $entity_type, $types, true ) ) {
+ $names[] = $name;
+ }
+ }
+ return $names;
+ }
+
+ /**
+ * Build the refusal, unless no integration applies or a filter allows it.
+ *
+ * @param string $entity_type Entity type.
+ * @param string $action 'aeav_only' or 'cleanup'.
+ * @return WP_Error|null
+ */
+ private static function refuse( string $entity_type, string $action ): ?WP_Error {
+ $integrations = self::integrations_for( $entity_type );
+ if ( empty( $integrations ) ) {
+ return null;
+ }
+
+ /**
+ * Allow a guarded aeav_only promotion or cleanup anyway.
+ *
+ * @param bool $allow Default false.
+ * @param string $entity_type Entity type.
+ * @param string $action 'aeav_only' or 'cleanup'.
+ * @param string[] $integrations Integrations that read this entity natively.
+ */
+ if ( apply_filters( 'tmdo_promotion_guard_allow', false, $entity_type, $action, $integrations ) ) {
+ return null;
+ }
+
+ return new WP_Error(
+ 'promotion_guarded',
+ sprintf(
+ /* translators: 1: integration names, 2: entity type, 3: aeav_only or cleanup */
+ __( '%1$s 直接讀取原生 meta,%2$s 不能執行 %3$s:這類站台以原生 meta 為唯一資料來源(搜尋索引模式)。', '2meet-data-optimizer' ),
+ implode( ' / ', $integrations ),
+ $entity_type,
+ $action
+ )
+ );
+ }
+}
diff --git a/includes/migration/class-tmdo-post-migration.php b/includes/migration/class-tmdo-post-migration.php
index 67b54f8..26caf95 100644
--- a/includes/migration/class-tmdo-post-migration.php
+++ b/includes/migration/class-tmdo-post-migration.php
@@ -228,6 +228,10 @@ final class TMDO_Post_Migration {
'Refusing post cleanup — mode is ' . esc_html( $mode ) . ', must be aeav_only'
);
}
+ $guarded = TMDO_Promotion_Guard::check_cleanup( self::ENTITY_TYPE );
+ if ( $guarded ) {
+ throw new RuntimeException( esc_html( $guarded->get_error_message() ) );
+ }
global $wpdb;
$keys = self::get_managed_keys();
diff --git a/includes/migration/phases/class-tmdo-phase-cleanup.php b/includes/migration/phases/class-tmdo-phase-cleanup.php
index 28b59e1..2c2a0a6 100644
--- a/includes/migration/phases/class-tmdo-phase-cleanup.php
+++ b/includes/migration/phases/class-tmdo-phase-cleanup.php
@@ -46,6 +46,10 @@ class TMDO_Phase_Cleanup extends TMDO_Migration_Phase_Base {
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) {
throw new \RuntimeException( "Refusing cleanup — mode is {$mode}, must be aeav_only" );
}
+ $guarded = TMDO_Promotion_Guard::check_cleanup( $this->entity_type );
+ if ( $guarded ) {
+ throw new \RuntimeException( $guarded->get_error_message() );
+ }
if ( ! empty( $job['options']['auto_backup'] ) && empty( $job['backup_path'] ) ) {
throw new \RuntimeException( 'Refusing cleanup — auto_backup requested but no backup_path on record' );
}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 54161d5..2ab62a7 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -613,6 +613,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
// Engine (v2.0.0).
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
+require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php
index c19554b..9578e92 100644
--- a/tests/integration/bootstrap.php
+++ b/tests/integration/bootstrap.php
@@ -576,6 +576,7 @@ require_once TMDO_PATH . 'includes/safety/class-tmdo-fsm-guard.php';
require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
+require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
diff --git a/tests/unit/PromotionGuardTest.php b/tests/unit/PromotionGuardTest.php
new file mode 100644
index 0000000..7cf830e
--- /dev/null
+++ b/tests/unit/PromotionGuardTest.php
@@ -0,0 +1,163 @@
+ true );
+ TMDO_Mode_Manager::reset_cache();
+ }
+
+ private function modes( array $modes ): void {
+ $GLOBALS['_wp_options']['wpdo_bridge_modes'] = $modes;
+ TMDO_Mode_Manager::reset_cache();
+ }
+
+ public function test_without_integrations_aeav_only_is_allowed(): void {
+ $this->modes( array( 'post' => 'shadow_read' ) );
+
+ $this->assertTrue( TMDO_Mode_Manager::set( 'post', 'aeav_only' ) );
+ $this->assertNull( TMDO_Promotion_Guard::check_cleanup( 'post' ) );
+ $this->assertSame( array(), TMDO_Promotion_Guard::violations() );
+ }
+
+ /**
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_hivepress_refuses_aeav_only_for_every_entity(): void {
+ eval( 'namespace HivePress; class Core {}' );
+
+ foreach ( array( 'post', 'user', 'term', 'comment' ) as $type ) {
+ $this->modes( array( $type => 'shadow_read' ) );
+
+ $result = TMDO_Mode_Manager::set( $type, 'aeav_only' );
+
+ $this->assertInstanceOf( WP_Error::class, $result, $type );
+ $this->assertSame( 'promotion_guarded', $result->get_error_code(), $type );
+ $this->assertStringContainsString( 'HivePress', $result->get_error_message(), $type );
+ $this->assertSame( 'shadow_read', TMDO_Mode_Manager::get( $type ), "{$type} mode must not change" );
+ $this->assertSame( array( $type => 'shadow_read' ), $GLOBALS['_wp_options']['wpdo_bridge_modes'], "{$type} option must not change" );
+ }
+ }
+
+ /**
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_woocommerce_guards_post_and_user_only(): void {
+ eval( 'class WooCommerce {}' );
+
+ $this->modes( array( 'post' => 'shadow_read', 'user' => 'shadow_read', 'term' => 'shadow_read' ) );
+
+ $this->assertInstanceOf( WP_Error::class, TMDO_Mode_Manager::set( 'post', 'aeav_only' ) );
+ $this->assertInstanceOf( WP_Error::class, TMDO_Mode_Manager::set( 'user', 'aeav_only' ) );
+ $this->assertTrue( TMDO_Mode_Manager::set( 'term', 'aeav_only' ) );
+ }
+
+ /**
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_guard_allows_lower_modes_and_demotion(): void {
+ eval( 'namespace HivePress; class Core {}' );
+
+ $this->modes( array( 'post' => 'disabled' ) );
+ $this->assertTrue( TMDO_Mode_Manager::set( 'post', 'dual_write' ) );
+ $this->assertTrue( TMDO_Mode_Manager::set( 'post', 'shadow_read' ) );
+
+ $this->modes( array( 'post' => 'aeav_only' ) );
+ $this->assertTrue( TMDO_Mode_Manager::set( 'post', 'dual_write' ), 'the way out of aeav_only stays open' );
+ }
+
+ /**
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_filter_can_allow_a_guarded_promotion(): void {
+ eval( 'namespace HivePress; class Core {}' );
+ add_filter(
+ 'tmdo_promotion_guard_allow',
+ static function ( $allow, $entity_type, $action, $integrations ) {
+ return 'user' === $entity_type && 'aeav_only' === $action && array( 'HivePress' ) === $integrations;
+ },
+ 10,
+ 4
+ );
+
+ $this->modes( array( 'user' => 'shadow_read', 'post' => 'shadow_read' ) );
+
+ $this->assertTrue( TMDO_Mode_Manager::set( 'user', 'aeav_only' ) );
+ $this->assertInstanceOf( WP_Error::class, TMDO_Mode_Manager::set( 'post', 'aeav_only' ) );
+ }
+
+ /**
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_cleanup_is_refused_even_in_aeav_only(): void {
+ eval( 'namespace HivePress; class Core {}' );
+ $this->modes( array( 'post' => 'aeav_only', 'user' => 'aeav_only' ) );
+
+ $error = TMDO_Promotion_Guard::check_cleanup( 'post' );
+ $this->assertInstanceOf( WP_Error::class, $error );
+
+ try {
+ TMDO_Post_Migration::cleanup();
+ $this->fail( 'post cleanup must be refused' );
+ } catch ( RuntimeException $e ) {
+ $this->assertStringContainsString( 'HivePress', $e->getMessage() );
+ }
+
+ $job = array( 'log' => array(), 'options' => array() );
+ try {
+ ( new TMDO_Phase_Cleanup( 'user' ) )->execute( $job );
+ $this->fail( 'user cleanup phase must be refused' );
+ } catch ( RuntimeException $e ) {
+ $this->assertStringContainsString( 'HivePress', $e->getMessage() );
+ }
+ }
+
+ /**
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_sites_already_in_aeav_only_are_reported(): void {
+ eval( 'class WooCommerce {}' );
+ $this->modes( array( 'post' => 'aeav_only', 'term' => 'aeav_only' ) );
+
+ $this->assertSame( array( 'post' => array( 'WooCommerce' ) ), TMDO_Promotion_Guard::violations() );
+
+ ob_start();
+ TMDO_Promotion_Guard_Notice::render();
+ $html = (string) ob_get_clean();
+ $this->assertStringContainsString( 'post', $html );
+ $this->assertStringContainsString( 'bridge-restore-native post', $html );
+ }
+
+ public function test_notice_is_silent_without_violations(): void {
+ $this->modes( array( 'post' => 'aeav_only' ) );
+
+ ob_start();
+ TMDO_Promotion_Guard_Notice::render();
+ $this->assertSame( '', ob_get_clean() );
+ }
+}