array( 'dual_write' ), 'dual_write' => array( 'backfill', 'verify' ), 'backfill' => array( 'verify' ), 'verify' => array( 'cutover' ), 'cutover' => array( 'cleanup' ), 'cleanup' => array( 'complete' ), 'complete' => array(), // terminal — only rewind to idle. ); /** * Transitions that destroy data on completion (write-only-custom or purge). * These trigger an automatic pre-transition snapshot. */ public const DESTRUCTIVE_TRANSITIONS = array( 'cutover' => array( 'cleanup' ), // cleanup purges wp_*meta. 'cleanup' => array( 'complete' ), // complete = no fallback to wp_*meta. ); /** * "Active" states (writes hit custom tables). Reverting these to idle * potentially loses data and warrants a snapshot. */ public const ACTIVE_STATES = array( 'dual_write', 'backfill', 'verify', 'cutover', 'cleanup', 'complete', ); /** * Check whether a transition is allowed. Returns true on allow, * WP_Error on block. * * @param string $module Module name (must be a known module). * @param string $from Current state. * @param string $to Target state. * @return true|WP_Error */ public static function can_transition( string $module, string $from, string $to ) { // Allow filter-based bypass (CLI --force, integration tests, manual override). $bypass = (bool) apply_filters( 'wpdo/fsm_guard/bypass', false, $module, $from, $to ); if ( $bypass ) { return true; } // No-op transition. if ( $from === $to ) { return true; } // Backward to idle is always allowed (emergency rewind). if ( 'idle' === $to ) { return true; } // Validate against forward graph. $allowed = self::FORWARD_GRAPH[ $from ] ?? array(); if ( ! in_array( $to, $allowed, true ) ) { return new WP_Error( 'wpdo_fsm_invalid_transition', sprintf( /* translators: 1: module, 2: from-state, 3: to-state, 4: list of allowed states */ __( 'Cannot transition module %1$s from %2$s to %3$s. Allowed forward states from %2$s: %4$s. Pass --force or set the wpdo/fsm_guard/bypass filter to override.', '2meet-data-optimizer' ), $module, $from, $to, empty( $allowed ) ? __( '(none — terminal state)', '2meet-data-optimizer' ) : implode( ', ', $allowed ) ), array( 'module' => $module, 'from' => $from, 'to' => $to, 'allowed' => $allowed, ) ); } return true; } /** * Decide whether a transition is destructive (warrants auto-snapshot). * * @param string $from Current state. * @param string $to Target state. * @return bool */ public static function is_destructive( string $from, string $to ): bool { // Cleanup/complete transitions purge data. if ( isset( self::DESTRUCTIVE_TRANSITIONS[ $from ] ) && in_array( $to, self::DESTRUCTIVE_TRANSITIONS[ $from ], true ) ) { return true; } // Reverting an active module to idle abandons custom-table data. if ( 'idle' === $to && in_array( $from, self::ACTIVE_STATES, true ) ) { return true; } return false; } /** * Take an auto-snapshot for the pending FSM transition. * Called from Feature_Flags::set() before update_option. * * @param string $module Module name. * @param string $from Current state. * @param string $to Target state. * @return string|null Snapshot ID on success; null on skip/failure. */ public static function maybe_snapshot( string $module, string $from, string $to ): ?string { if ( ! self::is_destructive( $from, $to ) ) { return null; } // Check if Snapshot_Manager is loaded — defensive (in case unit tests skip include). if ( ! class_exists( 'TMDO_Snapshot_Manager' ) ) { return null; } // Allow filter to suppress (e.g. CLI passed --skip-snapshot for emergency rewind). $skip = (bool) apply_filters( 'wpdo/fsm_guard/skip_snapshot', false, $module, $from, $to ); if ( $skip ) { return null; } $result = TMDO_Snapshot_Manager::create( 'pre_fsm_transition', array(), array( 'notes' => sprintf( '[%s] %s → %s', $module, $from, $to ), 'retention_days' => 90, // Keep FSM-rewind snapshots longer. ) ); return ! empty( $result['ok'] ) ? (string) $result['snapshot_id'] : null; } /** * Convenience: check + snapshot + record entry time. Called from * Feature_Flags::set(). Returns WP_Error to block, true to allow. * * @param string $module Module identifier. * @param string $to Target state. * @return true|WP_Error */ public static function before_transition( string $module, string $to ) { $from = TMDO_Feature_Flags::get( $module ); $check = self::can_transition( $module, $from, $to ); if ( is_wp_error( $check ) ) { return $check; } self::maybe_snapshot( $module, $from, $to ); return true; } /** * Record state entry time after a successful transition. Stored in * wp_options key `wpdo_fsm_state_entered` for verify-gate / wash-period * checks (v2.3.0 M5 will consume this). * * @param string $module Module identifier. * @param string $to New state. * @return void */ public static function record_entry( string $module, string $to ): void { $key = 'wpdo_fsm_state_entered'; $data = get_option( $key, array() ); if ( ! is_array( $data ) ) { $data = array(); } $data[ $module ] = array( 'state' => $to, 'entered_at' => current_time( 'mysql', true ), ); update_option( $key, $data, false ); } /** * Read the entry timestamp for a module's current state. * * @param string $module Module identifier. * @return string|null UTC mysql datetime, or null when unrecorded. */ public static function get_entry_time( string $module ): ?string { $data = get_option( 'wpdo_fsm_state_entered', array() ); return is_array( $data ) && isset( $data[ $module ]['entered_at'] ) ? (string) $data[ $module ]['entered_at'] : null; } }