|WP_Error */ public function undo( int $ledger_id, DateTimeImmutable $now ) { $entry = $this->ledger->find( $ledger_id ); if ( null === $entry ) { return new WP_Error( 'adaf_not_found', __( 'Ledger entry not found.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 404 ) ); } if ( 'allowed' !== $entry->status ) { return new WP_Error( 'adaf_not_undoable', __( 'Only executed actions can be undone.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 409 ) ); } $action = new ActionDescriptor( $entry->source, $entry->action_cat, $entry->entity_type, $entry->entity_id, $entry->ability_name, $entry->input ?? array() ); if ( ! $this->service->is_undoable( $action ) ) { return new WP_Error( 'adaf_not_undoable', __( 'This action cannot be undone automatically.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 409, 'undoable' => false, ) ); } $snapshot = $this->snapshots->find_by_ledger( $ledger_id ); $result = $this->revert( $entry, $snapshot ); if ( $result instanceof WP_Error ) { return $result; } $this->ledger->update_status( $ledger_id, 'undone' ); $this->writer->append( array( 'ts' => $now, 'agent_id' => $entry->agent_id, 'wp_user_id' => get_current_user_id(), 'source' => 'undo', 'action_cat' => $entry->action_cat, 'entity_type' => $entry->entity_type, 'entity_id' => $entry->entity_id, 'ability_name' => $entry->ability_name, 'status' => 'allowed', 'input' => array( 'undo_of' => $ledger_id ), 'result' => $result, ) ); return $result; } /** * Dispatch the revert by entity and category. * * @param LedgerEntry $entry Original ledger entry. * @param Snapshot|null $snapshot Linked before-state, if captured. * @return array|WP_Error */ private function revert( LedgerEntry $entry, ?Snapshot $snapshot ) { if ( 'option' === $entry->entity_type ) { return $this->revert_options( $snapshot ); } // Post-backed entities from here on. if ( 'delete' === $entry->action_cat && null !== $entry->entity_id ) { $restored = wp_untrash_post( $entry->entity_id ); if ( ! $restored instanceof \WP_Post ) { return new WP_Error( 'adaf_undo_failed', __( 'The entity could not be restored from trash.', 'agentic-daisy-ai-agent-firewall' ) ); } return array( 'untrashed' => $entry->entity_id ); } if ( 'create' === $entry->action_cat ) { $created_id = $this->created_entity_id( $entry ); if ( null === $created_id ) { return new WP_Error( 'adaf_undo_failed', __( 'The created entity could not be identified.', 'agentic-daisy-ai-agent-firewall' ) ); } if ( false === wp_trash_post( $created_id ) ) { return new WP_Error( 'adaf_undo_failed', __( 'The created entity could not be trashed.', 'agentic-daisy-ai-agent-firewall' ) ); } return array( 'trashed' => $created_id ); } // Update: prefer the revision WordPress recorded of the pre-action // state; snapshot restore as fallback (ADR-9). if ( null !== $entry->entity_id ) { $reverted = $this->revert_post_update( $entry->entity_id, $snapshot ); if ( null !== $reverted ) { return $reverted; } } return new WP_Error( 'adaf_undo_failed', __( 'No revision or snapshot is available to restore.', 'agentic-daisy-ai-agent-firewall' ) ); } /** * Restore option values from the snapshot. * * @param Snapshot|null $snapshot Before-state. * @return array|WP_Error */ private function revert_options( ?Snapshot $snapshot ) { if ( null === $snapshot || ! isset( $snapshot->before_state['options'] ) || ! is_array( $snapshot->before_state['options'] ) ) { return new WP_Error( 'adaf_undo_failed', __( 'No snapshot is available to restore option values.', 'agentic-daisy-ai-agent-firewall' ) ); } $restored = array(); foreach ( $snapshot->before_state['options'] as $name => $value ) { // Re-validate against the whitelist at write time: stored rows // are not trusted to bound what undo may update. if ( ! SnapshotService::is_restorable_option( (string) $name ) ) { continue; } update_option( (string) $name, $value ); $restored[] = (string) $name; } if ( array() === $restored ) { return new WP_Error( 'adaf_undo_failed', __( 'The snapshot contains no restorable option values.', 'agentic-daisy-ai-agent-firewall' ) ); } return array( 'restored_options' => $restored ); } /** * The revision recording what a ledgered update did (AF-12.11). * * Answers "what did this change actually do" with an id WordPress * already knows how to render: `revision.php?revision=` is a diff * screen every administrator has seen. Reporting only - nothing is * restored, and the ledger row is untouched. * * Which revision, precisely, was measured rather than assumed. WordPress * writes a revision of a post as it stands AFTER an update, so the one * to link carries the agent's own content, not the state before it - and * because `revision.php` diffs a revision against its predecessor, * opening it shows exactly what the agent changed. It is found by time: * the earliest revision written at or after the action. Anything older * belongs to somebody else's edit. * * (This is a different question from the one undo asks. Undo hunts for a * revision matching the captured before-state so it can restore it; that * revision only exists when the post had been edited before. The two * lookups are deliberately separate because they want different rows.) * * @param int $ledger_id Ledger row. * @return int|null Revision id, or null when none was written. */ public function change_revision_id( int $ledger_id ): ?int { $entry = $this->ledger->find( $ledger_id ); if ( null === $entry || 'update' !== $entry->action_cat || null === $entry->entity_id ) { return null; } // WordPress runs on UTC, so the ledger timestamp and post_date_gmt // are directly comparable. $acted_at = $entry->ts->format( 'Y-m-d H:i:s' ); $best = null; foreach ( wp_get_post_revisions( $entry->entity_id ) as $revision ) { if ( ! $revision instanceof \WP_Post || $revision->post_date_gmt < $acted_at ) { continue; } if ( null === $best || $revision->post_date_gmt < $best->post_date_gmt ) { $best = $revision; continue; } // Revision timestamps have one-second resolution, so an edit made // in the same second as the action ties with it. Ids are // monotonic and the agent's write came last, so the higher id is // the later write and the better answer. if ( $revision->post_date_gmt === $best->post_date_gmt && $revision->ID > $best->ID ) { $best = $revision; } } return null === $best ? null : (int) $best->ID; } /** * The revision whose content matches a captured before-state. * * Null whenever there is nothing to match against or nothing matches: * revisions can be disabled with `WP_POST_REVISIONS`, trimmed to a limit, * or already restored. Absence is ordinary, not an error. * * @param int $post_id Target post. * @param array|null $before Captured pre-action fields. */ private function match_revision( int $post_id, ?array $before ): ?int { if ( null === $before ) { return null; } foreach ( wp_get_post_revisions( $post_id ) as $revision ) { if ( ! $revision instanceof \WP_Post ) { continue; } $matches_title = ! isset( $before['post_title'] ) || $revision->post_title === $before['post_title']; $matches_content = ! isset( $before['post_content'] ) || $revision->post_content === $before['post_content']; if ( $matches_title && $matches_content ) { return (int) $revision->ID; } } return null; } /** * Revert a post update via matching revision, else snapshot fields. * * @param int $post_id Target post. * @param Snapshot|null $snapshot Before-state. * @return array|null Null when nothing was restorable. */ private function revert_post_update( int $post_id, ?Snapshot $snapshot ) { $before = null !== $snapshot && isset( $snapshot->before_state['post'] ) && is_array( $snapshot->before_state['post'] ) ? $snapshot->before_state['post'] : null; // Native mechanism first: the revision recording the pre-action state. $revision_id = $this->match_revision( $post_id, $before ); if ( null !== $revision_id ) { $restored = wp_restore_post_revision( $revision_id ); if ( null !== $restored && false !== $restored ) { return array( 'restored_revision' => $revision_id ); } } // Fallback: overwrite with the snapshot fields. if ( null !== $before && array() !== $before ) { $fields = array_intersect_key( $before, array_flip( array( 'post_title', 'post_content', 'post_excerpt', 'post_status', 'post_name' ) ) ); if ( array() !== $fields ) { $fields['ID'] = $post_id; $updated = wp_update_post( $fields, true ); if ( ! $updated instanceof WP_Error ) { return array( 'restored_snapshot' => true ); } } } return null; } /** * Resolve the entity id a create action produced (from its result). * * @param LedgerEntry $entry Ledger entry. */ private function created_entity_id( LedgerEntry $entry ): ?int { $result = $entry->result ?? array(); foreach ( array( 'created', 'id', 'post_id' ) as $key ) { if ( isset( $result[ $key ] ) && is_numeric( $result[ $key ] ) ) { return (int) $result[ $key ]; } } if ( isset( $result['result'] ) && is_array( $result['result'] ) ) { foreach ( array( 'created', 'id', 'post_id' ) as $key ) { if ( isset( $result['result'][ $key ] ) && is_numeric( $result['result'][ $key ] ) ) { return (int) $result['result'][ $key ]; } } } return null; } }