*/ private const STATES = ['DRAFT', 'READY_FOR_REVIEW', 'APPROVED', 'APPLIED', 'VERIFIED', 'REJECTED', 'STALE', 'FAILED', 'RESTORED']; /** @return array */ public static function create(int $post_id, string $proposed_value, string $evidence_reference, string $evidence_observed_at, int $actor_id): array { $post = self::allowed_post($post_id); if (!$post instanceof WP_Post) { return self::failure('excluded_target', 'Only a published post or page can be reviewed in this slice.'); } $proposed_value = self::normalize_value($proposed_value); if ($proposed_value === '') { return self::failure('invalid_value', 'The proposed Agentic Bridge answer intent is required.'); } if ($evidence_reference === '' || !self::evidence_is_fresh($evidence_observed_at)) { return self::failure('stale_evidence', 'The evidence reference is missing or no longer fresh.'); } $current = self::read_value($post_id); $proposal = [ 'id' => wp_generate_uuid4(), 'site_id' => get_current_blog_id(), 'object_type' => 'post', 'object_id' => $post_id, 'field' => self::META_KEY, 'action' => 'update_answer_intent', 'evidence_reference' => sanitize_text_field($evidence_reference), 'evidence_observed_at' => $evidence_observed_at, 'evidence_fresh_until' => gmdate('c', strtotime($evidence_observed_at . ' UTC') + self::EVIDENCE_MAX_AGE), 'current_value_hash' => self::hash($current), 'current_value' => $current, 'optimistic_version' => self::hash($current), 'proposed_value' => $proposed_value, 'reason' => 'Evidence-backed Agentic Bridge answer intent review.', 'risk' => 'low', 'state' => 'DRAFT', 'created_by' => $actor_id, 'created_at' => gmdate('c'), 'updated_at' => gmdate('c'), 'expires_at' => gmdate('c', time() + self::EVIDENCE_MAX_AGE), 'approval' => null, 'snapshot' => null, 'applied_value_hash' => '', 'verification' => null, ]; self::save($proposal); self::event($proposal['id'], 'proposal_created', $actor_id, 'DRAFT'); return self::transition($proposal['id'], 'READY_FOR_REVIEW', $actor_id); } /** @return array */ public static function approve(string $proposal_id, int $actor_id): array { if (!current_user_can(self::CAPABILITY)) { return self::failure('denied', 'You do not have permission to approve this change.'); } $proposal = self::find($proposal_id); if ($proposal === null || !self::site_matches($proposal)) { return self::failure('not_found', 'The review change was not found.'); } if (($proposal['state'] ?? '') === 'APPROVED') { return ['ok' => true, 'proposal' => $proposal, 'idempotent' => true]; } if (($proposal['state'] ?? '') !== 'READY_FOR_REVIEW') { return self::failure('invalid_state', 'Only a proposed change that is ready for review can be approved.'); } if (!self::fresh($proposal)) { return self::mark_stale($proposal, $actor_id, 'Evidence expired before approval.'); } $proposal['state'] = 'APPROVED'; $proposal['approval'] = ['user_id' => $actor_id, 'approved_at' => gmdate('c'), 'explicit' => true]; $proposal['updated_at'] = gmdate('c'); self::save($proposal); self::event($proposal_id, 'approved', $actor_id, 'APPROVED'); return ['ok' => true, 'proposal' => $proposal]; } /** @return array */ public static function reject(string $proposal_id, int $actor_id): array { if (!current_user_can(self::CAPABILITY)) { return self::failure('denied', 'You do not have permission to decline this change.'); } $proposal = self::find($proposal_id); if ($proposal === null || !self::site_matches($proposal)) { return self::failure('not_found', 'The review change was not found.'); } if (($proposal['state'] ?? '') === 'REJECTED') { return ['ok' => true, 'proposal' => $proposal, 'idempotent' => true]; } if (($proposal['state'] ?? '') !== 'READY_FOR_REVIEW') { return self::failure('invalid_state', 'Only a proposed change that is ready for review can be declined.'); } $proposal['state'] = 'REJECTED'; $proposal['approval'] = ['user_id' => $actor_id, 'rejected_at' => gmdate('c'), 'explicit' => true, 'decision' => 'rejected']; $proposal['updated_at'] = gmdate('c'); self::save($proposal); self::event($proposal_id, 'rejected', $actor_id, 'REJECTED'); return ['ok' => true, 'proposal' => $proposal]; } /** @return array */ public static function apply(string $proposal_id, int $actor_id): array { $proposal = self::find($proposal_id); if ($proposal === null || !self::site_matches($proposal)) { return self::failure('not_found', 'The review change was not found.'); } if (($proposal['state'] ?? '') === 'APPLIED' || ($proposal['state'] ?? '') === 'VERIFIED') { return ['ok' => true, 'proposal' => $proposal, 'idempotent' => true]; } if (($proposal['state'] ?? '') !== 'APPROVED') { return self::failure('invalid_state', 'Explicit approval is required before applying this change.'); } if (!self::fresh($proposal)) { return self::mark_stale($proposal, $actor_id, 'Evidence expired before apply.'); } $post_id = (int) ($proposal['object_id'] ?? 0); if (!self::allowed_post($post_id) instanceof WP_Post) { return self::fail($proposal, $actor_id, 'The target is no longer allowed.'); } $current = self::read_value($post_id); if (!hash_equals((string) $proposal['optimistic_version'], self::hash($current))) { return self::mark_stale($proposal, $actor_id, 'The current value changed before apply.'); } $proposal['snapshot'] = ['value' => $current, 'hash' => self::hash($current), 'captured_at' => gmdate('c')]; update_post_meta($post_id, self::META_KEY, (string) $proposal['proposed_value']); $proposal['applied_value_hash'] = self::hash(self::read_value($post_id)); $proposal['state'] = 'APPLIED'; $proposal['updated_at'] = gmdate('c'); self::save($proposal); self::event($proposal_id, 'applied', $actor_id, 'APPLIED'); return ['ok' => true, 'proposal' => $proposal]; } /** @return array */ public static function verify(string $proposal_id, int $actor_id): array { $proposal = self::find($proposal_id); if ($proposal === null || !self::site_matches($proposal)) { return self::failure('not_found', 'The review change was not found.'); } if (($proposal['state'] ?? '') === 'VERIFIED') { return ['ok' => true, 'proposal' => $proposal, 'idempotent' => true]; } if (($proposal['state'] ?? '') !== 'APPLIED') { return self::failure('invalid_state', 'Apply the approved change before verifying it.'); } $actual = self::read_value((int) $proposal['object_id']); $passed = hash_equals((string) $proposal['applied_value_hash'], self::hash($actual)) && $actual === (string) $proposal['proposed_value']; $proposal['verification'] = ['verified_at' => gmdate('c'), 'passed' => $passed, 'actual_hash' => self::hash($actual)]; $proposal['state'] = $passed ? 'VERIFIED' : 'FAILED'; $proposal['updated_at'] = gmdate('c'); self::save($proposal); self::event($proposal_id, $passed ? 'verified' : 'verification_failed', $actor_id, $proposal['state']); return ['ok' => $passed, 'proposal' => $proposal]; } /** @return array */ public static function restore(string $proposal_id, int $actor_id): array { $proposal = self::find($proposal_id); if ($proposal === null || !self::site_matches($proposal)) { return self::failure('not_found', 'The review change was not found.'); } if (($proposal['state'] ?? '') === 'RESTORED') { return ['ok' => true, 'proposal' => $proposal, 'idempotent' => true]; } if (!in_array((string) ($proposal['state'] ?? ''), ['APPLIED', 'VERIFIED'], true) || !is_array($proposal['snapshot'] ?? null)) { return self::failure('invalid_state', 'This change is not available to restore.'); } $current = self::read_value((int) $proposal['object_id']); if (!hash_equals((string) $proposal['applied_value_hash'], self::hash($current))) { return self::failure('restore_refused', 'Restore was refused because this value changed after Agentic Bridge applied it.'); } update_post_meta((int) $proposal['object_id'], self::META_KEY, (string) $proposal['snapshot']['value']); $proposal['state'] = 'RESTORED'; $proposal['updated_at'] = gmdate('c'); self::save($proposal); self::event($proposal_id, 'restored', $actor_id, 'RESTORED'); return ['ok' => true, 'proposal' => $proposal]; } /** @return array|null */ public static function find(string $proposal_id): ?array { $all = self::ledger()['proposals'] ?? []; return isset($all[$proposal_id]) && is_array($all[$proposal_id]) ? $all[$proposal_id] : null; } /** @return list> */ public static function events(): array { $events = self::ledger()['events'] ?? []; return is_array($events) ? array_values($events) : []; } /** @return list> */ public static function all(): array { $proposals = self::ledger()['proposals'] ?? []; return is_array($proposals) ? array_values(array_filter($proposals, static fn(mixed $p): bool => is_array($p) && self::site_matches($p))) : []; } /** Keep stored evidence references internal while giving administrators plain source language. */ public static function evidence_source_label(string $reference): string { $reference = trim($reference); if ($reference === '' || str_starts_with($reference, 'local-fixture:')) { return __('Local WordPress evidence', 'agentic-bridge'); } if (str_starts_with($reference, 'readiness:')) { return __('Latest local readiness scan', 'agentic-bridge'); } if (str_starts_with($reference, 'manual:')) { return __('Manually recorded local evidence', 'agentic-bridge'); } return $reference; } public static function display_time(string $value): string { $timestamp = strtotime($value); if ($timestamp === false) return ''; return function_exists('wp_date') ? wp_date(get_option('date_format') . ' ' . get_option('time_format'), $timestamp) : gmdate('M j, Y g:i a', $timestamp); } /** * Read-only activity for the Review changes screen. Event records stay append-only; * presentation joins only the scoped proposal fields already stored locally. * * @return array{items:list>,total:int,has_more:bool} */ public static function activity(): array { $proposals = []; foreach (self::all() as $proposal) { $proposals[(string) ($proposal['id'] ?? '')] = $proposal; } $items = []; foreach (self::events() as $event) { if (!is_array($event)) { continue; } $proposal = $proposals[(string) ($event['proposal_id'] ?? '')] ?? null; if (!is_array($proposal)) { continue; } $items[] = [ 'action' => self::activity_action_label((string) ($event['event'] ?? '')), 'result' => self::activity_result_label((string) ($event['event'] ?? ''), (string) ($event['state'] ?? '')), /* translators: %d: WordPress user ID for the administrator. */ 'actor' => sprintf(__('Administrator (user #%d)', 'agentic-bridge'), (int) ($event['actor_id'] ?? 0)), 'at' => (string) ($event['at'] ?? ''), 'object_id' => (int) ($proposal['object_id'] ?? 0), 'evidence_reference' => self::evidence_source_label((string) ($proposal['evidence_reference'] ?? '')), 'evidence_observed_at' => (string) ($proposal['evidence_observed_at'] ?? ''), 'evidence_freshness' => self::fresh($proposal) ? __('Current when recorded', 'agentic-bridge') : __('No longer current', 'agentic-bridge'), 'state' => (string) ($event['state'] ?? ''), ]; } usort($items, static fn(array $left, array $right): int => strcmp((string) ($right['at'] ?? ''), (string) ($left['at'] ?? ''))); $total = count($items); return [ 'items' => array_slice($items, 0, self::ACTIVITY_LIMIT), 'total' => $total, 'has_more' => $total > self::ACTIVITY_LIMIT, ]; } /** @return array */ private static function ledger(): array { $value = get_option(self::OPTION_KEY, ['proposals' => [], 'events' => []]); return is_array($value) ? $value : ['proposals' => [], 'events' => []]; } /** @param array $proposal */ private static function save(array $proposal): void { $ledger = self::ledger(); $ledger['proposals'][(string) $proposal['id']] = $proposal; update_option(self::OPTION_KEY, $ledger, false); } private static function event(string $proposal_id, string $event, int $actor_id, string $state): void { $ledger = self::ledger(); $ledger['events'][] = ['proposal_id' => $proposal_id, 'event' => $event, 'actor_id' => $actor_id, 'state' => $state, 'at' => gmdate('c')]; update_option(self::OPTION_KEY, $ledger, false); } private static function activity_action_label(string $event): string { return match ($event) { 'proposal_created' => __('Change proposed', 'agentic-bridge'), 'ready_for_review' => __('Ready for review', 'agentic-bridge'), 'approved' => __('Approval recorded', 'agentic-bridge'), 'rejected' => __('Change declined', 'agentic-bridge'), 'applied' => __('Change applied', 'agentic-bridge'), 'verified' => __('Verification completed', 'agentic-bridge'), 'verification_failed' => __('Verification completed', 'agentic-bridge'), 'restored' => __('Previous value restored', 'agentic-bridge'), 'refused_stale' => __('Change refused', 'agentic-bridge'), 'failed' => __('Change could not be applied', 'agentic-bridge'), default => __('Review activity recorded', 'agentic-bridge') }; } private static function activity_result_label(string $event, string $state): string { return match ($event) { 'verified' => __('Verified', 'agentic-bridge'), 'rejected' => __('Not approved', 'agentic-bridge'), 'verification_failed' => __('Verification needs attention', 'agentic-bridge'), 'restored' => __('Previous value restored', 'agentic-bridge'), 'refused_stale' => __('Refused because the value or evidence was no longer current', 'agentic-bridge'), 'failed' => __('Could not apply', 'agentic-bridge'), default => match ($state) { 'READY_FOR_REVIEW' => __('Ready for review', 'agentic-bridge'), 'APPROVED' => __('Approved', 'agentic-bridge'), 'APPLIED' => __('Applied', 'agentic-bridge'), default => __('Recorded', 'agentic-bridge') }, }; } /** @return array */ private static function transition(string $proposal_id, string $state, int $actor_id): array { $proposal = self::find($proposal_id); if ($proposal === null || !in_array($state, self::STATES, true)) return self::failure('invalid_state', 'Unable to prepare this review change.'); $proposal['state'] = $state; $proposal['updated_at'] = gmdate('c'); self::save($proposal); self::event($proposal_id, 'ready_for_review', $actor_id, $state); return ['ok' => true, 'proposal' => $proposal]; } /** @return array */ private static function mark_stale(array $proposal, int $actor_id, string $message): array { $proposal['state'] = 'STALE'; $proposal['updated_at'] = gmdate('c'); self::save($proposal); self::event((string) $proposal['id'], 'refused_stale', $actor_id, 'STALE'); return self::failure('stale', $message, $proposal); } /** @return array */ private static function fail(array $proposal, int $actor_id, string $message): array { $proposal['state'] = 'FAILED'; $proposal['updated_at'] = gmdate('c'); self::save($proposal); self::event((string) $proposal['id'], 'failed', $actor_id, 'FAILED'); return self::failure('failed', $message, $proposal); } /** @return array */ private static function failure(string $code, string $message, ?array $proposal = null): array { return array_filter(['ok' => false, 'code' => $code, 'message' => $message, 'proposal' => $proposal], static fn(mixed $v): bool => $v !== null); } private static function normalize_value(string $value): string { return sanitize_text_field(wp_unslash($value)); } private static function read_value(int $post_id): string { return self::normalize_value((string) get_post_meta($post_id, self::META_KEY, true)); } private static function hash(string $value): string { return hash('sha256', $value); } private static function evidence_is_fresh(string $when): bool { $time = strtotime($when . ' UTC'); return $time !== false && $time <= time() && $time >= time() - self::EVIDENCE_MAX_AGE; } /** @param array $proposal */ private static function fresh(array $proposal): bool { return self::evidence_is_fresh((string) ($proposal['evidence_observed_at'] ?? '')) && strtotime((string) ($proposal['expires_at'] ?? '')) >= time(); } /** @param array $proposal */ private static function site_matches(array $proposal): bool { return (int) ($proposal['site_id'] ?? 0) === get_current_blog_id(); } private static function allowed_post(int $post_id): ?WP_Post { $post = get_post($post_id); return $post instanceof WP_Post && in_array($post->post_type, ['post', 'page'], true) ? $post : null; } }