'blogname', 'description' => 'blogdescription', 'url' => 'siteurl', 'email' => 'admin_email', 'timezone' => 'timezone_string', 'date_format' => 'date_format', 'time_format' => 'time_format', 'start_of_week' => 'start_of_week', 'language' => 'WPLANG', 'posts_per_page' => 'posts_per_page', 'default_ping_status' => 'default_ping_status', 'default_comment_status' => 'default_comment_status', ); /** * Post fields captured for snapshot-based restore. */ private const POST_FIELDS = array( 'post_title', 'post_content', 'post_excerpt', 'post_status', 'post_name' ); /** * Constructor. * * @param SnapshotRepository $snapshots Snapshot repository. */ public function __construct( private readonly SnapshotRepository $snapshots ) { } /** * Whether an option name may be captured and restored. Option undo is * strictly whitelist-bound: input keys and snapshot rows never widen the * set of options this plugin will read or write (wp.org review finding, * 2026-08). * * @param string $name Option name. */ public static function is_restorable_option( string $name ): bool { return in_array( $name, self::SETTINGS_OPTION_MAP, true ); } /** * Whether an action can be cleanly undone (ADR-9). * * @param ActionDescriptor $action Normalized action. */ public function is_undoable( ActionDescriptor $action ): bool { if ( 'read' === $action->action_cat ) { return false; } if ( in_array( $action->entity_type, array( 'post', 'page', 'media' ), true ) ) { return in_array( $action->action_cat, array( 'create', 'update', 'delete' ), true ); } if ( 'option' === $action->entity_type ) { return 'update' === $action->action_cat || 'create' === $action->action_cat; } // Users, comments, terms, external side effects: not undoable in MVP. return false; } /** * Capture the before-state for a mutating action. * * @param ActionDescriptor $action Normalized action. * @param int $ledger_id Ledger row to link. * @param DateTimeImmutable $now Capture time. * @return Snapshot|null Null when there is nothing to capture. */ public function capture( ActionDescriptor $action, int $ledger_id, DateTimeImmutable $now ): ?Snapshot { if ( ! $this->is_undoable( $action ) ) { return null; } $before_state = $this->before_state( $action ); if ( null === $before_state ) { return null; } return $this->snapshots->create( $ledger_id, 'option' === $action->entity_type ? 'option' : $action->entity_type, $action->entity_id ?? 0, $before_state, $now ); } /** * Compute the canonical before-state hash for the ledger row. * * @param array $before_state Captured state. */ public static function before_hash( array $before_state ): string { return hash( 'sha256', (string) wp_json_encode( $before_state ) ); } /** * Build the before-state array for an action. * * @param ActionDescriptor $action Normalized action. * @return array|null */ private function before_state( ActionDescriptor $action ): ?array { if ( 'option' === $action->entity_type ) { $options = array(); foreach ( array_keys( $action->input ) as $param ) { // Unknown params are dropped, never used as option names: // input is agent-controlled and would otherwise read // arbitrary options into the snapshot store. if ( ! isset( self::SETTINGS_OPTION_MAP[ $param ] ) ) { continue; } $option_name = self::SETTINGS_OPTION_MAP[ $param ]; $options[ $option_name ] = get_option( $option_name ); } if ( array() === $options ) { return null; } return array( 'options' => $options ); } // Post-backed entities. if ( 'create' === $action->action_cat || null === $action->entity_id ) { // Nothing exists yet; undo of create is trashing the result. return array( 'post' => array() ); } $post = get_post( $action->entity_id ); if ( ! $post instanceof \WP_Post ) { return null; } $fields = array(); foreach ( self::POST_FIELDS as $field ) { $fields[ $field ] = $post->{$field}; } $fields['post_modified_gmt'] = $post->post_modified_gmt; return array( 'post' => $fields ); } }