'logged_ai_pro_baselines', 'decisions' => 'logged_ai_pro_baseline_decisions', 'history' => 'logged_ai_pro_baseline_history', ); private const STATES = array( 'none', 'disputed', 'accepted_benign' ); private const CATEGORIES = array( 'identity', 'content', 'configuration', 'commerce', 'privacy', 'monitoring', 'extension' ); private const SUMMARY_BYTES = 8192; private const MAX_RECORDS = 10000; private const DAYS = 90; private const DELAY = 86400; /** * Resolve fixed baseline table names under the supplied connection prefix. * * @param \wpdb $db Native database connection selected for this operation. * * @return array Baseline storage roles mapped to native table names. */ public static function tables( \wpdb $db ): array { return array_map( static fn( string $suffix ): string => $db->base_prefix . $suffix, self::SUFFIXES ); } /** * Create a baseline privacy availability error. * * @param string $message Explanation of the unavailable privacy operation. * * @return \WP_Error Structured error carrying HTTP status 503. */ private static function error( string $message ): \WP_Error { return new \WP_Error( 'baseline_privacy', $message, array( 'status' => 503 ) ); } /** * Validate a positive integer or supported canonical decimal baseline identifier. * * @param mixed $value Supplied value to validate without narrowing its native input contract. * * @return bool Whether the value satisfies the internal positive-identifier check. */ private static function id( mixed $value ): bool { return ( is_int( $value ) && $value > 0 ) || ( is_string( $value ) && 1 === preg_match( '/\A[1-9][0-9]{0,18}\z/D', $value ) && ( strlen( $value ) < strlen( (string) PHP_INT_MAX ) || strcmp( $value, (string) PHP_INT_MAX ) <= 0 ) ); } /** * Validate the stored lowercase UUID-shaped baseline or event identifier grammar. * * @param mixed $value Supplied value to validate without narrowing its native input contract. * * @return bool Whether the value matches the supported identifier representation. */ private static function uid( mixed $value ): bool { return is_string( $value ) && 1 === preg_match( '/\A[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\z/D', $value ); } /** * Validate an exact UTC timestamp with six fractional-second digits. * * @param mixed $value Supplied value to validate without narrowing its native input contract. * * @return bool Whether parsing and canonical formatting reproduce the supplied value. */ private static function time_value( mixed $value ): bool { $time = is_string( $value ) ? \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s.u', $value, new \DateTimeZone( 'UTC' ) ) : false; return $time && $time->format( 'Y-m-d H:i:s.u' ) === $value; } /** * Format an epoch as the stored UTC timestamp with zero fractional seconds. * * @param int $epoch Unix timestamp to format as a UTC second. * * @return string Canonical UTC second followed by a six-zero microsecond component. */ private static function date( int $epoch ): string { return gmdate( 'Y-m-d H:i:s', $epoch ) . '.000000'; } /** * Read the current native context of the global database connection. * * @phpstan-impure Reads current native authority or mutable context. * * @return array Current native connection and authorization context. */ private static function context(): array { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ return Derived::native_context( $wpdb ); } /** * Recheck the original connection, native context and held mutation mutex. * * @phpstan-impure Reads current native authority or mutable context. * * @param \wpdb $db Native database connection selected for this operation. * @param array $origin Original native context captured before callback or query boundaries. * * @return bool Whether the operation retains its caller connection and current mutation authority. */ private static function held( \wpdb $db, array $origin ): bool { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( $wpdb !== $db || self::context() !== $origin ) { return false; } $owned = $db->get_var( $db->prepare( 'SELECT IS_USED_LOCK(%s)=CONNECTION_ID()', Derived::lock_name() ) ); return '' === $db->last_error && '1' === (string) $owned && $wpdb === $db && self::context() === $origin && is_callable( array( Derived::class, 'mutation_allowed' ) ) && Derived::mutation_allowed() && $wpdb === $db && self::context() === $origin; // @phpstan-ignore booleanAnd.rightAlwaysTrue (Retain the explicit callable coordinator guard at this cleanup boundary; the current paired Derived implementation makes it statically known.) } /** * Check the current site or network namespace for internal baseline cleanup. * * @param int $network Server-selected native network identifier. * @param int $site Server-selected native site identifier. * * @return bool Whether supplied IDs match the current site or multisite network context. */ private static function in_scope( int $network, int $site ): bool { return Scope::network() === $network && ( 0 === $site ? is_multisite() : get_current_blog_id() === $site ); } /** * Distinguish absent baseline storage from a complete compatible installation. * * Metadata failure is an error. Partial installation is retained for repair, never treated as absent. * * @param \wpdb|null $db Optional native database adapter; null selects the current global connection. * * @return bool|\WP_Error False for absence, true for verified storage, or an inspection/layout error. */ public static function present( ?\wpdb $db = null ): bool|\WP_Error { if ( null === $db ) { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $db = $wpdb; } $count = 0; foreach ( self::tables( $db ) as $table ) { if ( 1 !== preg_match( '/\A[a-zA-Z0-9_]{1,64}\z/D', $table ) ) { return self::error( 'Baseline table identity is unavailable.' ); } $found = $db->get_var( $db->prepare( 'SHOW TABLES LIKE %s', $db->esc_like( $table ) ) ); if ( '' !== $db->last_error || ( null !== $found && $found !== $table ) ) { return self::error( 'Optional baseline storage could not be inspected.' ); } $count += null === $found ? 0 : 1; } if ( 0 === $count ) { return false; } return 3 === $count && self::ready( $db ) ? true : self::error( 'Optional baseline storage is incomplete or incompatible; original source joins are retained.' ); } /** * Declare the exact baseline privacy column and nullability contract. * * @return array Required columns and SQL types by storage role, with nullable types prefixed by a question mark. */ public static function columns(): array { return array( 'state' => array( 'id' => 'bigint unsigned', 'network_id' => 'bigint unsigned', 'site_id' => 'bigint unsigned', 'actor_id' => 'bigint unsigned', 'instance_uid' => 'char(36)', 'control_revision' => 'bigint unsigned', 'source_generation' => 'bigint unsigned', 'identifier_expires_at' => 'datetime(6)', 'reset_after' => '?datetime(6)', 'summary' => '?text', 'summary_control' => '?bigint unsigned', 'summary_source' => '?bigint unsigned', 'policy_binding' => '?char(64)', 'built_at' => '?datetime(6)', 'expires_at' => '?datetime(6)', ), 'decisions' => array( 'id' => 'bigint unsigned', 'network_id' => 'bigint unsigned', 'site_id' => 'bigint unsigned', 'actor_id' => 'bigint unsigned', 'instance_uid' => 'char(36)', 'event_id' => 'bigint unsigned', 'event_uid' => 'char(36)', 'state' => 'varchar(16)', 'reviewer_id' => '?bigint unsigned', 'revision' => 'bigint unsigned', 'decided_at' => 'datetime(6)', 'expires_at' => 'datetime(6)', ), 'history' => array( 'id' => 'bigint unsigned', 'network_id' => 'bigint unsigned', 'site_id' => 'bigint unsigned', 'actor_id' => 'bigint unsigned', 'instance_uid' => 'char(36)', 'reviewer_id' => '?bigint unsigned', 'event_id' => '?bigint unsigned', 'event_uid' => '?char(36)', 'action' => 'varchar(16)', 'before_state' => '?varchar(16)', 'after_state' => '?varchar(16)', 'revision' => 'bigint unsigned', 'occurred_at' => 'datetime(6)', 'expires_at' => 'datetime(6)', ), ); } /** * Declare required baseline identity, source, reviewer and expiry indexes. * * @return array Required uniqueness and ordered index fields for each baseline storage role. */ public static function indexes(): array { $common = array( 'PRIMARY' => array( true, array( 'id' ) ) ); return array( 'state' => $common + array( 'actor_scope' => array( true, array( 'network_id', 'site_id', 'actor_id' ) ), 'expiry' => array( false, array( 'identifier_expires_at', 'id' ) ), ), 'decisions' => $common + array( 'event_scope' => array( true, array( 'network_id', 'site_id', 'event_uid' ) ), 'actor_scope' => array( false, array( 'network_id', 'site_id', 'actor_id', 'id' ) ), 'reviewer' => array( false, array( 'reviewer_id', 'id' ) ), 'expiry' => array( false, array( 'expires_at', 'id' ) ), ), 'history' => $common + array( 'actor_scope' => array( false, array( 'network_id', 'site_id', 'actor_id', 'id' ) ), 'event_scope' => array( false, array( 'network_id', 'site_id', 'event_uid', 'id' ) ), 'reviewer' => array( false, array( 'reviewer_id', 'id' ) ), 'expiry' => array( false, array( 'expires_at', 'id' ) ), ), ); } /** * Verify baseline columns, required indexes and transactional table engines. * * @param \wpdb $db Native database connection selected for this operation. * * @return bool Whether all baseline tables satisfy the supported privacy contract. */ private static function ready( \wpdb $db ): bool { foreach ( self::tables( $db ) as $kind => $table ) { if ( ! preg_match( '/\A[a-zA-Z0-9_]{1,64}\z/D', $table ) ) { return false; } $columns = $db->get_results( $db->prepare( 'SHOW COLUMNS FROM %i', $table ), ARRAY_A ); /** * Validate the untyped result from the supplied database adapter. * * @var mixed $columns */ if ( '' !== $db->last_error || ! is_array( $columns ) || count( $columns ) !== count( self::columns()[ $kind ] ) ) { return false; } $found = array(); foreach ( $columns as $column ) { $found[ $column['Field'] ] = $column; } foreach ( self::columns()[ $kind ] as $field => $type ) { $column = $found[ $field ] ?? null; $nullable = str_starts_with( $type, '?' ); if ( ! $column || ltrim( $type, '?' ) !== preg_replace( '/\b(bigint|int)\([0-9]+\)/', '$1', strtolower( $column['Type'] ) ) || ( $nullable ? 'YES' : 'NO' ) !== $column['Null'] || ( 'id' === $field && 'auto_increment' !== $column['Extra'] ) ) { return false; } } $engine = $db->get_var( $db->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $table ) ); if ( '' !== $db->last_error || 'InnoDB' !== $engine ) { return false; } $indexes = $db->get_results( $db->prepare( 'SHOW INDEX FROM %i', $table ), ARRAY_A ); /** * Validate the untyped result from the supplied database adapter. * * @var mixed $indexes */ if ( '' !== $db->last_error || ! is_array( $indexes ) ) { return false; } $found = array(); foreach ( $indexes as $index ) { if ( null !== $index['Sub_part'] ) { return false; } $found[ $index['Key_name'] ]['unique'] = 0 === (int) $index['Non_unique']; $found[ $index['Key_name'] ]['fields'][ (int) $index['Seq_in_index'] ] = $index['Column_name']; } foreach ( self::indexes()[ $kind ] as $name => $expected ) { if ( ! isset( $found[ $name ] ) || $found[ $name ]['unique'] !== $expected[0] ) { return false; } ksort( $found[ $name ]['fields'] ); if ( array_values( $found[ $name ]['fields'] ) !== $expected[1] ) { return false; } } } return true; } /** * Invalidate affected baseline summaries before deleting bounded event governance copies. * * Called inside Derived's mutex before the source owner deletes its selected ID/UID pairs. * * @param int $network Server-selected native network identifier. * @param int $site Server-selected native site identifier. * @param array $rows Bounded server-selected exact source ID/UUID pairs. * * @return array|\WP_Error Deleted decision/history count and completion, or an authority/identity/storage error. */ public static function erase_events( int $network, int $site, array $rows ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $db = $wpdb; $origin = self::context(); if ( ! self::in_scope( $network, $site ) || ! self::held( $db, $origin ) || count( $rows ) > 500 ) { return self::error( 'Baseline event cleanup requires the current held privacy scope.' ); } if ( ! $rows ) { return array( 'removed' => 0, 'done' => true, ); } $ids = array(); $uids = array(); $pairs = array(); foreach ( $rows as $row ) { if ( ! is_array( $row ) || ! self::id( $row['id'] ?? null ) || ! self::uid( $row['uid'] ?? null ) ) { return self::error( 'Baseline cleanup received an invalid internal source reference.' ); } $ids[] = (string) $row['id']; $uids[] = $row['uid']; array_push( $pairs, $row['id'], $row['uid'] ); } $present = self::present( $db ); if ( is_wp_error( $present ) ) { return $present; } if ( ! self::held( $db, $origin ) ) { return self::error( 'Baseline cleanup context changed during storage inspection.' ); } if ( ! $present || 0 === $site ) { return array( 'removed' => 0, 'done' => true, ); } $tables = self::tables( $db ); $marks = implode( ',', array_fill( 0, count( $ids ), '%s' ) ); $pair_placeholders = implode( ',', array_fill( 0, count( $rows ), '(%s,%s)' ) ); $source = $db->base_prefix . 'logged_ai_events'; // Source actor A may have acted on erased subject B. Invalidate A too, before deleting that join. $actors = array(); foreach ( array( $source, $tables['decisions'], $tables['history'] ) as $table ) { // Inventory bounded physical rows and deduplicate actor IDs in PHP. // A snapshot conflict remains an error; the caller owns any transaction retry. // Governance inventory matches this pass's ordered DELETE quantum; remaining // joins retain the original source for the next pass before it may be erased. if ( $source === $table ) { $batch = $db->get_col( $db->prepare( "SELECT actor_id FROM %i WHERE network_id=%d AND site_id=%d AND (id,uid) IN ($pair_placeholders) AND actor_id>0 LIMIT 501 LOCK IN SHARE MODE", array_merge( array( $table, $network, $site ), $pairs ) ) ); } else { $batch = $db->get_col( $db->prepare( "SELECT actor_id FROM %i WHERE network_id=%d AND site_id=%d AND (event_id IN ($marks) OR event_uid IN ($marks)) ORDER BY id LIMIT 500 LOCK IN SHARE MODE", array_merge( array( $table, $network, $site ), $ids, $uids ) ) ); } /** * Validate actor references before invalidating bounded baseline identities from the untyped wpdb adapter. * * @var mixed $batch */ if ( '' !== $db->last_error || ! is_array( $batch ) || ( $source === $table && count( $batch ) > 500 ) ) { return self::error( 'Affected baseline identities could not be read within the verified cleanup bound.' ); } foreach ( $batch as $actor ) { if ( ! self::id( $actor ) ) { return self::error( 'An affected baseline actor reference is invalid.' ); } $actors[ (string) $actor ] = (string) $actor; } if ( count( $actors ) > 1500 ) { return self::error( 'Affected baseline identities exceed the verified cleanup bound.' ); } } $actors = array_values( $actors ); if ( $actors ) { $actor_marks = implode( ',', array_fill( 0, count( $actors ), '%s' ) ); $bad = $db->get_var( $db->prepare( "SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND actor_id IN ($actor_marks) AND source_generation>=%d LIMIT 1 FOR UPDATE", array_merge( array( $tables['state'], $network, $site ), $actors, array( PHP_INT_MAX ) ) ) ); if ( '' !== $db->last_error || null !== $bad || ! self::held( $db, $origin ) ) { return self::error( 'Baseline source generations cannot be safely advanced.' ); } if ( false === $db->query( $db->prepare( "UPDATE %i SET source_generation=source_generation+1,summary=NULL,summary_control=NULL,summary_source=NULL,policy_binding=NULL,built_at=NULL,expires_at=NULL WHERE network_id=%d AND site_id=%d AND actor_id IN ($actor_marks)", array_merge( array( $tables['state'], $network, $site ), $actors ) ) ) ) { return self::error( 'Baseline invalidation failed; original facts must remain for retry.' ); } } $removed = 0; $done = true; foreach ( array( 'decisions', 'history' ) as $kind ) { if ( ! self::held( $db, $origin ) ) { return self::error( 'Baseline cleanup scope changed.' ); } $count = $db->query( $db->prepare( "DELETE FROM %i WHERE network_id=%d AND site_id=%d AND (event_id IN ($marks) OR event_uid IN ($marks)) ORDER BY id LIMIT 500", array_merge( array( $tables[$kind], $network, $site ), $ids, $uids ) ) ); if ( false === $count ) { return self::error( 'Linked baseline governance could not be erased; retain original facts for retry.' ); } $removed += $count; $left = $db->get_var( $db->prepare( "SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND (event_id IN ($marks) OR event_uid IN ($marks)) LIMIT 1 LOCK IN SHARE MODE", array_merge( array( $tables[$kind], $network, $site ), $ids, $uids ) ) ); if ( '' !== $db->last_error ) { return self::error( 'Remaining baseline governance could not be verified.' ); } $done = $done && null === $left; } return self::held( $db, $origin ) ? array( 'removed' => $removed, 'done' => $done, ) : self::error( 'Baseline cleanup context changed before completion.' ); } /** * Remove bounded actor-owned baseline data and minimize separate reviewer references. * * Remove the actor identity, every owned control/history, and separately nullable reviewer references. * * @param Scope $scope Explicit current native authorization scope for the operation. * @param int $user Positive personal subject identifier selected for cleanup or export. * * @return array|\WP_Error Deleted/updated-row count and completion, or an authority/storage error. */ public static function erase_user( Scope $scope, int $user ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $db = $wpdb; $origin = self::context(); $authorize = static fn(): bool => $scope->site_id > 0 && $scope->valid_for_all( array( 'logged_ai_manage_settings', 'logged_ai_purge_data', 'manage_privacy_options' ) ); if ( $user < 1 || ! $authorize() || ! self::held( $db, $origin ) ) { return self::error( 'Baseline identity cleanup requires current privacy and purge permissions.' ); } $present = self::present( $db ); if ( is_wp_error( $present ) ) { return $present; } if ( ! $present ) { return array( 'removed' => 0, 'done' => true, ); } $tables = self::tables( $db ); $removed = 0; $children = false; $reviewers = false; // This row lock remains with a caller-owned transaction after the advisory mutex is released. $state = $db->get_var( $db->prepare( 'SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND actor_id=%d FOR UPDATE', $tables['state'], $scope->network_id, $scope->site_id, $user ) ); if ( '' !== $db->last_error ) { return self::error( 'The baseline identity could not be locked for erasure.' ); } if ( ! self::held( $db, $origin ) || ! $authorize() ) { return self::error( 'Baseline erasure authority changed after identity locking.' ); } if ( null !== $state && false === $db->query( $db->prepare( 'UPDATE %i SET summary=NULL,summary_control=NULL,summary_source=NULL,policy_binding=NULL,built_at=NULL,expires_at=NULL WHERE network_id=%d AND site_id=%d AND actor_id=%d', $tables['state'], $scope->network_id, $scope->site_id, $user ) ) ) { return self::error( 'The baseline identity could not be invalidated before its bounded erasure.' ); } foreach ( array( 'decisions', 'history' ) as $kind ) { if ( ! $authorize() || ! self::held( $db, $origin ) ) { return self::error( 'Baseline identity erasure authority changed.' ); } $count = $db->query( $db->prepare( 'DELETE FROM %i WHERE network_id=%d AND site_id=%d AND actor_id=%d ORDER BY id LIMIT 500', $tables[$kind], $scope->network_id, $scope->site_id, $user ) ); if ( false === $count ) { return self::error( 'Actor baseline records could not be removed; identity references remain for retry.' ); } $removed += $count; $left = $db->get_var( $db->prepare( 'SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND actor_id=%d LIMIT 1 LOCK IN SHARE MODE', $tables[$kind], $scope->network_id, $scope->site_id, $user ) ); if ( '' !== $db->last_error ) { return self::error( 'Remaining actor baseline records could not be verified.' ); } $children = $children || null !== $left; if ( ! self::held( $db, $origin ) || ! $authorize() ) { return self::error( 'Baseline reviewer erasure authority changed after child cleanup.' ); } $count = $db->query( $db->prepare( 'UPDATE %i SET reviewer_id=NULL WHERE network_id=%d AND site_id=%d AND reviewer_id=%d AND actor_id<>%d ORDER BY id LIMIT 500', $tables[$kind], $scope->network_id, $scope->site_id, $user, $user ) ); if ( false === $count ) { return self::error( 'Baseline reviewer references could not be removed.' ); } $removed += $count; $left = $db->get_var( $db->prepare( 'SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND reviewer_id=%d AND actor_id<>%d LIMIT 1 LOCK IN SHARE MODE', $tables[$kind], $scope->network_id, $scope->site_id, $user, $user ) ); if ( '' !== $db->last_error ) { return self::error( 'Remaining baseline reviewer references could not be verified.' ); } $reviewers = $reviewers || null !== $left; } if ( ! $children ) { if ( ! $authorize() || ! self::held( $db, $origin ) ) { return self::error( 'Baseline identity erasure authority changed.' ); } $count = $db->query( $db->prepare( 'DELETE FROM %i WHERE network_id=%d AND site_id=%d AND actor_id=%d', $tables['state'], $scope->network_id, $scope->site_id, $user ) ); if ( false === $count ) { return self::error( 'Baseline identity removal failed; retry before reporting completion.' ); } $removed += $count; $left = $db->get_var( $db->prepare( 'SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND actor_id=%d LIMIT 1 LOCK IN SHARE MODE', $tables['state'], $scope->network_id, $scope->site_id, $user ) ); if ( '' !== $db->last_error || null !== $left ) { return self::error( 'Baseline identity removal could not be verified.' ); } } return $authorize() && self::held( $db, $origin ) ? array( 'removed' => $removed, 'done' => ! $children && ! $reviewers, ) : self::error( 'Baseline identity erasure context changed.' ); } /** * Validate a stored bounded baseline snapshot and its declared aggregate consistency. * * Only finite reducer output is presented or supplied to an assessment model. * * @param string $json Stored bounded configuration or snapshot bytes to validate. * @param int $network Server-selected native network identifier. * @param int $site Server-selected native site identifier. * @param int $actor Positive actor identifier bound to the stored baseline snapshot. * * @return array|\WP_Error Validated snapshot fields, or an identity/grammar/consistency error. */ public static function normalize_summary( string $json, int $network, int $site, int $actor ): array|\WP_Error { if ( strlen( $json ) > self::SUMMARY_BYTES ) { return self::error( 'The stored baseline summary exceeds its limit.' ); } $data = json_decode( $json, true, 8 ); if ( ! is_array( $data ) || 1 !== ( $data['contract_version'] ?? null ) || ( $data['network_id'] ?? null ) !== $network || ( $data['site_id'] ?? null ) !== $site || ( $data['actor_id'] ?? null ) !== $actor || ! in_array( $data['state'] ?? null, array( 'ready', 'insufficient_data', 'incomplete' ), true ) || ! in_array( $data['incident_check'] ?? null, array( 'not_implemented', 'available' ), true ) || 'bounded_committed_rescan' !== ( $data['source_consistency'] ?? null ) ) { return self::error( 'The stored baseline does not match its supported contract or actor.' ); } foreach ( array( 'as_of', 'window_start', 'learned_through', 'examined', 'accepted', 'eligible_observations', 'active_days', 'last_source_id' ) as $key ) { if ( ! is_int( $data[ $key ] ?? null ) || $data[ $key ] < 0 ) { return self::error( 'A stored baseline count or boundary is invalid.' ); } } if ( $data['as_of'] > time() || $data['learned_through'] !== $data['as_of'] - self::DELAY || $data['window_start'] < $data['as_of'] - self::DAYS * DAY_IN_SECONDS || $data['window_start'] > $data['as_of'] || $data['examined'] > self::MAX_RECORDS || $data['accepted'] > $data['examined'] || $data['eligible_observations'] > $data['accepted'] || $data['active_days'] > 91 || $data['active_days'] > $data['eligible_observations'] || ! is_bool( $data['truncated'] ?? null ) ) { return self::error( 'A stored baseline bound is inconsistent.' ); } if ( ! is_array( $data['hour_counts'] ?? null ) || ! array_is_list( $data['hour_counts'] ) || 24 !== count( $data['hour_counts'] ) || ! self::counts( $data['hour_counts'], $data['eligible_observations'] ) || ! is_array( $data['category_counts'] ?? null ) || array_keys( $data['category_counts'] ) !== self::CATEGORIES || ! self::counts( $data['category_counts'], $data['accepted'] ) ) { return self::error( 'A stored baseline distribution is invalid.' ); } $excluded = array( 'synthetic_or_external', 'outside_window', 'unverified_actor', 'invalid', 'disputed', 'incident', 'decision_unavailable' ); if ( ! is_array( $data['excluded'] ?? null ) || array_keys( $data['excluded'] ) !== $excluded || ! self::counts( $data['excluded'], $data['examined'] - $data['accepted'] ) ) { return self::error( 'A stored baseline exclusion count is invalid.' ); } foreach ( array( 'first_observed', 'last_observed' ) as $key ) { if ( ! array_key_exists( $key, $data ) || ( null !== $data[ $key ] && ! self::time_value( $data[ $key ] ) ) ) { return self::error( 'A stored baseline observation time is invalid.' ); } } if ( 0 === $data['accepted'] ? null !== $data['first_observed'] || null !== $data['last_observed'] : null === $data['first_observed'] || null === $data['last_observed'] || strcmp( $data['first_observed'], $data['last_observed'] ) > 0 || strcmp( $data['first_observed'], self::date( $data['window_start'] ) ) <= 0 || strcmp( $data['last_observed'], self::date( $data['learned_through'] ) ) > 0 ) { return self::error( 'Stored observation times disagree with the learning window.' ); } if ( ( 0 === $data['examined'] ) !== ( 0 === $data['last_source_id'] ) ) { return self::error( 'The stored source boundary disagrees with its count.' ); } $ready = $data['active_days'] >= 14 && $data['eligible_observations'] >= 30; if ( ( 'ready' === $data['state'] && ( ! $ready || $data['truncated'] || $data['excluded']['invalid'] || $data['excluded']['decision_unavailable'] ) ) || ( 'insufficient_data' === $data['state'] && ( $ready || $data['truncated'] || $data['excluded']['invalid'] || $data['excluded']['decision_unavailable'] ) ) ) { return self::error( 'The stored baseline readiness is inconsistent.' ); } $availability = array( 'login_hour' => 'ready' === $data['state'] ? 'available' : $data['state'], 'ip' => 'not_collected_by_administrative_sensor', 'client_family' => 'not_collected_by_administrative_sensor', 'geography' => 'unavailable', 'reputation' => 'unavailable', ); if ( ( $data['availability'] ?? null ) !== $availability || 23 !== count( $data ) ) { return self::error( 'The stored baseline availability contract is incompatible.' ); } if ( ! array_key_exists( 'incident_binding', $data ) || ( 'not_implemented' === $data['incident_check'] ? null !== $data['incident_binding'] : ! is_string( $data['incident_binding'] ) || 1 !== preg_match( '/\A[a-f0-9]{64}\z/D', $data['incident_binding'] ) ) ) { return self::error( 'The stored incident binding is invalid.' ); } return $data; } /** * Validate bounded nonnegative aggregate counts against the supplied total. * * @param array $values Aggregate distribution counts to validate. * @param int $sum Required total of the selected distribution. * * @return bool Whether every count is supported and their sum matches the expected total. */ private static function counts( array $values, int $sum ): bool { foreach ( $values as $value ) { if ( ! is_int( $value ) || $value < 0 || $value > self::MAX_RECORDS ) { return false; } } return array_sum( $values ) === $sum; } /** * Open a private native database connection for bounded current baseline reads. * * No add-on dependency: a private current-read transaction also waits on pending source erasure. * * @return \wpdb|\WP_Error Private connection with fixed session settings, or an adapter/storage error. */ private static function connection(): \wpdb|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( 'wpdb' !== get_class( $wpdb ) ) { return self::error( 'Baseline export requires a verified independent database adapter.' ); } require_once __DIR__ . '/class-workerconnection.php'; $db = null; try { $db = new WorkerConnection( DB_USER, DB_PASSWORD, DB_NAME, DB_HOST ); if ( ! $db->ready ) { $db->close(); return self::error( 'Baseline export storage is unavailable.' ); } $db->set_prefix( $wpdb->base_prefix ); $db->set_blog_id( get_current_blog_id(), Scope::network() ); $db->suppress_errors( true ); if ( $db->prefix !== $wpdb->prefix || $db->users !== $wpdb->users || false === $db->query( 'SET SESSION autocommit=1' ) || false === $db->query( 'SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED' ) || false === $db->query( 'SET SESSION innodb_lock_wait_timeout=2' ) ) { $db->close(); return self::error( 'Baseline export transaction settings are unavailable.' ); } return $db; } catch ( \Throwable $error ) { if ( $db instanceof \wpdb ) { $db->close(); } return self::error( 'Baseline export storage is unavailable.' ); } } /** * Read and validate current event retention for a baseline export. * * @param \wpdb $db Native database connection selected for this operation. * @param int $site Server-selected native site identifier. * * @return array|\WP_Error Current policy fingerprint and event-retention cutoff, or a policy/storage error. */ private static function policy( \wpdb $db, int $site ): array|\WP_Error { $options = $db->get_blog_prefix( $site ) . 'options'; $row = $db->get_row( $db->prepare( 'SELECT CASE WHEN OCTET_LENGTH(option_value)<=16384 THEN option_value ELSE NULL END AS value FROM %i WHERE option_name=\'logged_ai_settings\' LIMIT 1 LOCK IN SHARE MODE', $options ), ARRAY_A ); if ( '' !== $db->last_error || ( null !== $row && ! is_string( $row['value'] ?? null ) ) ) { return self::error( 'Current baseline export retention could not be read.' ); } $raw = $row['value'] ?? null; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- Decode the bounded native serialized privacy option with classes disabled; the following settings shape checks reject unreadable values. Suppress parser warnings so stored bytes cannot leak into output. $settings = null === $raw ? Settings::defaults() : ( is_serialized( $raw ) ? @unserialize( $raw, array( 'allowed_classes' => false, 'max_depth' => 8, ) ) : null ); if ( ! is_array( $settings ) || ! is_int( $settings['event_days'] ?? null ) || $settings['event_days'] < 1 || $settings['event_days'] > 3650 || ! is_int( $settings['revision'] ?? null ) || $settings['revision'] < 1 ) { return self::error( 'Current baseline export retention is invalid.' ); } return array( 'binding' => hash( 'sha256', null === $raw ? 'absent:free-defaults-v1' : $raw ), 'cutoff' => self::date( time() - $settings['event_days'] * DAY_IN_SECONDS ), ); } /** * Seal protected baseline reads with current grants and observed callback queries. * * Seal protected reads with native authority while observing every callback query. * Only Scope's own separate permission connection may establish its fixed session. * * @phpstan-impure * * @param Scope $scope Explicit current native authorization scope for the operation. * @param array $caps Fixed capabilities required by the protected operation. * @param \wpdb $worker Private database connection used by the protected read. * @param callable $context_check Callback checking current source, retention and native context bindings. * * @return bool Whether dependency checks and grants hold without observed mutation or context drift. */ private static function export_allowed( Scope $scope, array $caps, \wpdb $worker, callable $context_check ): bool { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $caller = $wpdb; $origin = Derived::native_context( $caller ); $worker_origin = Derived::native_context( $worker ); $changed = false; $observe = static function ( string $sql ) use ( &$changed, $worker, $caller ): string { $statement = trim( $sql ); $setup = false; if ( in_array( $statement, array( 'SET SESSION autocommit=1', 'SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED' ), true ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Inspect at most 24 argument-free frames to permit only separate Scope permission-connection setup during the privacy read observer. $trace = debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS, 24 ); foreach ( $trace as $index => $frame ) { if ( 'query' !== $frame['function'] || ! ( $frame['object'] ?? null ) instanceof \wpdb ) { continue; } $next = $trace[ $index + 1 ] ?? array(); $setup = $frame['object'] instanceof WorkerConnection && $frame['object'] !== $worker && $frame['object'] !== $caller && Scope::class === ( $next['class'] ?? '' ) && 'committed_capabilities' === $next['function']; break; } } if ( ! $setup && ! preg_match( '/\A(?:SELECT|SHOW|DESCRIBE|EXPLAIN)\s/i', $statement ) ) { $changed = true; } return $sql; }; add_filter( 'query', $observe, PHP_INT_MAX ); try { $allowed = true === $context_check() && $scope->valid_for_all( $caps ); } catch ( \Throwable $error ) { $allowed = false; } finally { remove_filter( 'query', $observe, PHP_INT_MAX ); } return $allowed && ! $changed && $wpdb === $caller && Derived::native_context( $caller ) === $origin && Derived::native_context( $worker ) === $worker_origin; } /** * Export bounded actor and reviewer records under current source and retention checks. * * Bounded keyset pages include actor-owned and reviewer-reference records; never silently truncate. * * @param Scope $scope Explicit current native authorization scope for the operation. * @param int $user Positive personal subject identifier selected for cleanup or export. * @param array $after Separate decision and history keyset cursors; zero starts each stream. * @param int $limit Requested maximum records per stream, validated within one through one hundred. * * @return array|\WP_Error Validated baseline metadata, governance pages and continuation fields, or an export error. */ public static function personal_projection( Scope $scope, int $user, array $after = array( 'decisions' => '0', 'history' => '0', ), int $limit = 100 ): array|\WP_Error { $cap = 0 === $scope->site_id ? 'logged_ai_export_network_events' : 'logged_ai_export_events'; $caps = 0 === $scope->site_id ? array( $cap ) : array( $cap, 'logged_ai_manage_settings', 'manage_privacy_options' ); global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $caller = $wpdb; $origin = self::context(); $current = static function () use ( $caller, $origin ): bool { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ return $caller === $wpdb && self::context() === $origin; }; $allowed = static function () use ( $scope, $caps, $caller, $origin ): bool { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ return $caller === $wpdb && self::context() === $origin && $scope->valid_for_all( $caps ) && $caller === $wpdb && self::context() === $origin; }; if ( $user < 1 || ! $allowed() || array_keys( $after ) !== array( 'decisions', 'history' ) || $limit < 1 || $limit > 100 ) { return self::error( 'Baseline personal export scope or bounds are unavailable.' ); } foreach ( $after as $cursor ) { if ( '0' !== $cursor && ! self::id( $cursor ) ) { return self::error( 'A baseline export cursor is invalid.' ); } } $empty = array( 'contract_version' => self::VERSION, 'availability' => 'optional_storage_absent', 'actor_id' => (string) $user, 'baseline' => null, 'decisions' => array(), 'history' => array(), 'next' => $after, 'done' => true, ); if ( 0 === $scope->site_id ) { $empty['availability'] = 'site_only_collection'; return $empty; } $db = self::connection(); if ( is_wp_error( $db ) ) { return $db; } $worker_context = Derived::native_context( $db ); $locked = false; $transaction = false; $lock = Derived::lock_name(); $started = microtime( true ); try { $locked = '1' === (string) $db->get_var( $db->prepare( 'SELECT GET_LOCK(%s,1)', $lock ) ); if ( ! $locked || '' !== $db->last_error || ! $allowed() ) { return self::error( 'Baseline export is busy or its authority changed.' ); } $present = self::present( $db ); if ( is_wp_error( $present ) ) { return $present; } if ( ! $present ) { return self::export_allowed( $scope, $caps, $db, static fn(): bool => $current() && Derived::native_context( $db ) === $worker_context ) && $current() && Derived::native_context( $db ) === $worker_context ? $empty : self::error( 'Baseline export authority changed.' ); } if ( false === $db->query( 'START TRANSACTION' ) ) { return self::error( 'Baseline personal export could not start its isolated read.' ); } $transaction = true; $tables = self::tables( $db ); $state = $db->get_row( $db->prepare( 'SELECT id,actor_id,instance_uid,control_revision,source_generation,identifier_expires_at,reset_after,summary IS NOT NULL AS has_summary,CASE WHEN OCTET_LENGTH(summary)<=8192 THEN summary ELSE NULL END AS summary,summary_control,summary_source,policy_binding,built_at,expires_at FROM %i WHERE network_id=%d AND site_id=%d AND actor_id=%d LOCK IN SHARE MODE', $tables['state'], $scope->network_id, $scope->site_id, $user ), ARRAY_A ); if ( '' !== $db->last_error ) { return self::error( 'Baseline identity is locked or could not be read.' ); } $policy = self::policy( $db, $scope->site_id ); if ( is_wp_error( $policy ) ) { return $policy; } $result = $empty; $result['availability'] = 'retained_baseline_records'; $result['done'] = true; if ( '0' === (string) $after['decisions'] && '0' === (string) $after['history'] && null !== $state ) { $record = self::state_record( $state, $scope, $user, $policy ); if ( is_wp_error( $record ) ) { return $record; } $result['baseline'] = $record; } foreach ( array( 'decisions', 'history' ) as $kind ) { if ( 'decisions' === $kind ) { $rows = $db->get_results( $db->prepare( 'SELECT id,network_id,site_id,actor_id,instance_uid,event_id,event_uid,state,reviewer_id,revision,decided_at,expires_at FROM %i WHERE network_id=%d AND site_id=%d AND (actor_id=%d OR reviewer_id=%d) AND id>%s ORDER BY id LIMIT %d LOCK IN SHARE MODE', $tables['decisions'], $scope->network_id, $scope->site_id, $user, $user, $after[$kind], $limit + 1 ), ARRAY_A ); } else { $rows = $db->get_results( $db->prepare( 'SELECT id,network_id,site_id,actor_id,instance_uid,reviewer_id,event_id,event_uid,action,before_state,after_state,revision,occurred_at,expires_at FROM %i WHERE network_id=%d AND site_id=%d AND (actor_id=%d OR reviewer_id=%d) AND id>%s ORDER BY id LIMIT %d LOCK IN SHARE MODE', $tables['history'], $scope->network_id, $scope->site_id, $user, $user, $after[$kind], $limit + 1 ), ARRAY_A ); } if ( '' !== $db->last_error || ! is_array( $rows ) ) { return self::error( 'Retained personal governance could not be read.' ); } if ( count( $rows ) > $limit ) { array_pop( $rows ); $result['done'] = false; } foreach ( $rows as $row ) { if ( microtime( true ) - $started > 5 ) { return self::error( 'Baseline export reached its time bound; retry with a smaller page.' ); } $record = self::governance_record( $row, $kind, $scope, $user ); if ( is_wp_error( $record ) ) { return $record; } $result['next'][ $kind ] = (string) $row['id']; if ( null !== $record['event_id'] ) { $source = $db->get_var( $db->prepare( 'SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND id=%s AND uid=%s AND actor_id=%s AND occurred_at>=%s LIMIT 1 LOCK IN SHARE MODE', $db->base_prefix . 'logged_ai_events', $scope->network_id, $scope->site_id, $record['event_id'], $record['event_uid'], $record['actor_id'], $policy['cutoff'] ) ); if ( '' !== $db->last_error ) { return self::error( 'Personal governance source retention could not be verified.' ); } $record['source_availability'] = null === $source ? 'outside_current_event_retention_or_erased' : 'retained'; if ( null === $source ) { $record['event_id'] = null; $record['event_uid'] = null; } } else { $record['source_availability'] = 'not_applicable'; } $result[ $kind ][] = $record; } } $result['meaning'] = 'Retained delayed aggregate and fixed governance records, including this person\'s reviewer references. Summary is a recorded snapshot, not a current assessment or probability. Pages are current bounded reads, not a complete transactional history.'; $binding_current = static function () use ( $db, $scope, $policy, $result, $current, $worker_context ): bool { if ( ! $current() || Derived::native_context( $db ) !== $worker_context ) { return false; } $latest = self::policy( $db, $scope->site_id ); if ( is_wp_error( $latest ) || $latest['binding'] !== $policy['binding'] ) { return false; } if ( null !== $result['baseline'] && null !== $result['baseline']['summary'] && strcmp( $result['baseline']['expires_at'], Store::now() ) <= 0 ) { return false; } return $current() && Derived::native_context( $db ) === $worker_context; }; if ( ! self::export_allowed( $scope, $caps, $db, $binding_current ) || ! $current() || Derived::native_context( $db ) !== $worker_context ) { return self::error( 'Baseline export authority, retention or context changed before commit.' ); } if ( false === $db->query( 'COMMIT' ) ) { return self::error( 'Baseline personal export read could not complete.' ); } $transaction = false; if ( ! self::export_allowed( $scope, $caps, $db, $binding_current ) || ! $current() || Derived::native_context( $db ) !== $worker_context ) { return self::error( 'Baseline export authority, retention or context changed before output.' ); } if ( null !== $result['baseline'] && null !== $result['baseline']['summary'] && strcmp( $result['baseline']['expires_at'], Store::now() ) <= 0 ) { return self::error( 'The baseline snapshot expired while being exported.' ); } return $current() && Derived::native_context( $db ) === $worker_context ? $result : self::error( 'Baseline export context changed before output.' ); } catch ( \Throwable $error ) { return self::error( 'Baseline personal export was interrupted; no incomplete page was returned.' ); } finally { if ( $transaction ) { $db->query( 'ROLLBACK' ); } // Closing the private connection releases its advisory lock without a // query callback after the final protected-read authorization decision. $db->close(); } } /** * Validate a retained baseline identity and disclose only a currently eligible snapshot. * * @param array $row Retained native storage row selected for validation and disclosure. * @param Scope $scope Explicit current native authorization scope for the operation. * @param int $user Positive personal subject identifier selected for cleanup or export. * @param array $policy Current event-retention cutoff and exact policy fingerprint. * * @return array|\WP_Error Minimized identity/control metadata and snapshot availability, or a contract error. */ private static function state_record( array $row, Scope $scope, int $user, array $policy ): array|\WP_Error { foreach ( array( 'id', 'actor_id', 'control_revision', 'source_generation' ) as $field ) { if ( ! self::id( $row[ $field ] ?? null ) ) { return self::error( 'A retained baseline identity is incompatible.' ); } } if ( (string) $user !== (string) $row['actor_id'] || ! self::uid( $row['instance_uid'] ) || ! self::time_value( $row['identifier_expires_at'] ) || ( null !== $row['reset_after'] && ! self::time_value( $row['reset_after'] ) ) || ( '1' === (string) $row['has_summary'] && ! is_string( $row['summary'] ) ) ) { return self::error( 'Retained baseline controls or summary exceed their contract.' ); } foreach ( array( 'built_at', 'expires_at' ) as $field ) { if ( null !== $row[ $field ] && ! self::time_value( $row[ $field ] ) ) { return self::error( 'A retained baseline time is invalid.' ); } } $summary = null; $status = 'needs_rebuild'; if ( strcmp( $row['identifier_expires_at'], Store::now() ) <= 0 ) { $status = 'identity_expired'; } elseif ( null !== $row['summary'] && (string) $row['summary_control'] === (string) $row['control_revision'] && (string) $row['summary_source'] === (string) $row['source_generation'] && is_string( $row['policy_binding'] ) && hash_equals( $policy['binding'], $row['policy_binding'] ) && null !== $row['expires_at'] && strcmp( $row['expires_at'], Store::now() ) > 0 ) { $summary = self::normalize_summary( $row['summary'], $scope->network_id, $scope->site_id, $user ); if ( is_wp_error( $summary ) ) { return $summary; } $status = 'recorded_snapshot'; } return array( 'instance_uid' => $row['instance_uid'], 'control_revision' => (string) $row['control_revision'], 'source_generation' => (string) $row['source_generation'], 'identifier_expires_at' => $row['identifier_expires_at'], 'reset_after' => $row['reset_after'], 'summary_status' => $status, 'summary' => $summary, 'built_at' => $row['built_at'], 'expires_at' => $row['expires_at'], 'incident_freshness' => 'not_reassessed_by_privacy_export', ); } /** * Validate a personal decision or history row and label its retention relationship. * * @param array $row Retained native storage row selected for validation and disclosure. * @param string $kind Decision or history storage role selecting its fixed record contract. * @param Scope $scope Explicit current native authorization scope for the operation. * @param int $user Positive personal subject identifier selected for cleanup or export. * * @return array|\WP_Error Validated governance row with relationship and retention status, or a contract error. */ private static function governance_record( array $row, string $kind, Scope $scope, int $user ): array|\WP_Error { foreach ( array( 'id', 'network_id', 'site_id', 'actor_id', 'revision' ) as $field ) { if ( ! self::id( $row[ $field ] ?? null ) ) { return self::error( 'A retained baseline governance identifier is invalid.' ); } } if ( (int) $row['network_id'] !== $scope->network_id || (int) $row['site_id'] !== $scope->site_id || ( (int) $row['actor_id'] !== $user && (int) $row['reviewer_id'] !== $user ) || ! self::uid( $row['instance_uid'] ) || ( null !== $row['reviewer_id'] && ! self::id( $row['reviewer_id'] ) ) || ! self::time_value( $row['expires_at'] ) ) { return self::error( 'A retained governance record does not match this personal export.' ); } $time = 'decisions' === $kind ? 'decided_at' : 'occurred_at'; if ( ! self::time_value( $row[ $time ] ) ) { return self::error( 'A retained governance timestamp is invalid.' ); } if ( 'decisions' === $kind ) { if ( ! self::id( $row['event_id'] ) || ! self::uid( $row['event_uid'] ) || ! in_array( $row['state'], array( 'disputed', 'accepted_benign' ), true ) ) { return self::error( 'A retained baseline decision is invalid.' ); } } elseif ( ! in_array( $row['action'], array( 'reset', 'decision' ), true ) || ( 'reset' === $row['action'] ? null !== $row['event_id'] || null !== $row['event_uid'] || null !== $row['before_state'] || null !== $row['after_state'] : ! self::id( $row['event_id'] ) || ! self::uid( $row['event_uid'] ) || ! in_array( $row['before_state'], self::STATES, true ) || ! in_array( $row['after_state'], self::STATES, true ) ) ) { return self::error( 'A retained governance history record is invalid.' ); } $row['retention_status'] = strcmp( $row['expires_at'], Store::now() ) <= 0 ? 'expired_pending_cleanup' : 'retained'; $row['personal_relation'] = (int) $row['actor_id'] === $user ? 'actor' : 'reviewer'; return $row; } /** * Expire bounded site baseline identities and governance through a private transaction. * * Server-known site expiry remains available when Pro is inactive; no account or grant is assumed. * * @return array|\WP_Error Deleted-row count and remaining-work status, or an authority/storage error. */ public static function retention(): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $caller = $wpdb; $origin = self::context(); $network = Scope::network(); $site = get_current_blog_id(); return Derived::with_mutation( static function () use ( $caller, $origin, $network, $site ): array|\WP_Error { if ( ! self::held( $caller, $origin ) || $site < 1 || $network < 1 ) { return self::error( 'Baseline retention scope is unavailable.' ); } $db = self::connection(); if ( is_wp_error( $db ) ) { return $db; } $transaction = false; $started = microtime( true ); try { $present = self::present( $db ); if ( is_wp_error( $present ) ) { return $present; } if ( ! $present ) { return array( 'removed' => 0, 'done' => true, ); } if ( ! self::held( $caller, $origin ) || false === $db->query( 'START TRANSACTION' ) ) { return self::error( 'Baseline expiry could not start its own transaction.' ); } $transaction = true; $tables = self::tables( $db ); $now = Store::now(); $removed = 0; $pending = false; $rows = $db->get_results( $db->prepare( 'SELECT id,actor_id,instance_uid FROM %i WHERE network_id=%d AND site_id=%d AND identifier_expires_at<=%s ORDER BY id LIMIT 5 FOR UPDATE', $tables['state'], $network, $site, $now ), ARRAY_A ); if ( '' !== $db->last_error || ! is_array( $rows ) ) { return self::error( 'Expired baseline identities could not be locked.' ); } foreach ( $rows as $row ) { if ( microtime( true ) - $started >= 5 ) { $pending = true; break; } if ( ! self::id( $row['actor_id'] ) || ! self::uid( $row['instance_uid'] ) ) { return self::error( 'An expired baseline identity is invalid.' ); } $children = false; foreach ( array( 'decisions', 'history' ) as $kind ) { if ( ! self::held( $caller, $origin ) ) { return self::error( 'Baseline retention scope changed before child deletion.' ); } $count = $db->query( $db->prepare( 'DELETE FROM %i WHERE network_id=%d AND site_id=%d AND actor_id=%s ORDER BY id LIMIT 500', $tables[$kind], $network, $site, $row['actor_id'] ) ); if ( false === $count ) { return self::error( 'Expired baseline children could not be removed.' ); } $removed += $count; $left = $db->get_var( $db->prepare( 'SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND actor_id=%s LIMIT 1 LOCK IN SHARE MODE', $tables[$kind], $network, $site, $row['actor_id'] ) ); if ( '' !== $db->last_error ) { return self::error( 'Remaining expired baseline children could not be verified.' ); } $children = $children || null !== $left; } if ( $children ) { $pending = true; continue; } if ( ! self::held( $caller, $origin ) ) { return self::error( 'Baseline retention scope changed before identity deletion.' ); } $count = $db->delete( $tables['state'], array( 'id' => $row['id'], 'instance_uid' => $row['instance_uid'], 'network_id' => $network, 'site_id' => $site, ) ); if ( false === $count ) { return self::error( 'Expired baseline identity removal failed.' ); } $removed += $count; } foreach ( array( 'decisions', 'history' ) as $kind ) { if ( ! self::held( $caller, $origin ) ) { return self::error( 'Baseline retention scope changed before governance expiry.' ); } $count = $db->query( $db->prepare( 'DELETE FROM %i WHERE network_id=%d AND site_id=%d AND expires_at<=%s ORDER BY id LIMIT 500', $tables[$kind], $network, $site, $now ) ); if ( false === $count ) { return self::error( 'Expired baseline governance could not be removed.' ); } $removed += $count; $left = $db->get_var( $db->prepare( 'SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND expires_at<=%s LIMIT 1 LOCK IN SHARE MODE', $tables[$kind], $network, $site, $now ) ); if ( '' !== $db->last_error ) { return self::error( 'Remaining governance expiry could not be verified.' ); } $pending = $pending || null !== $left; } $left = $db->get_var( $db->prepare( 'SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND identifier_expires_at<=%s LIMIT 1 LOCK IN SHARE MODE', $tables['state'], $network, $site, $now ) ); if ( '' !== $db->last_error ) { return self::error( 'Remaining identity expiry could not be verified.' ); } $pending = $pending || null !== $left; if ( ! self::held( $caller, $origin ) || false === $db->query( 'COMMIT' ) ) { return self::error( 'Baseline expiry could not commit its own work.' ); } $transaction = false; global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ return $caller === $wpdb && self::context() === $origin ? array( 'removed' => $removed, 'done' => ! $pending, ) : self::error( 'Baseline expiry context changed while completing.' ); } catch ( \Throwable $error ) { return self::error( 'Baseline expiry was interrupted; retry is required.' ); } finally { if ( $transaction ) { $db->query( 'ROLLBACK' ); } $db->close(); } }, static fn(): bool => self::context() === $origin ); } /** * Drop and verify fixed baseline tables under fresh explicit uninstall consent. * * Fixed tables only, with fresh persisted consent under the same mutex as schema installation. * * @return true|\WP_Error True after verified removal, or an authority/storage error requiring retry. */ public static function uninstall_owned_tables(): bool|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $db = $wpdb; $origin = self::context(); if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { return self::error( 'Baseline deletion requires native uninstall.' ); } foreach ( array( 'decisions', 'history', 'state' ) as $kind ) { if ( ! self::held( $db, $origin ) ) { return self::error( 'Baseline uninstall requires the held installation lifecycle lock.' ); } $sql = is_multisite() ? $db->prepare( 'SELECT meta_value FROM %i WHERE site_id=%d AND meta_key=\'logged_ai_delete_all_on_uninstall\' LIMIT 1 LOCK IN SHARE MODE', $db->sitemeta, get_main_network_id() ) : $db->prepare( "SELECT option_value FROM %i WHERE option_name='logged_ai_delete_all_on_uninstall' LIMIT 1 LOCK IN SHARE MODE", $db->options ); $choice = $db->get_var( $sql ); if ( '' !== $db->last_error || ! in_array( $choice, array( '1', 'b:1;' ), true ) || ! self::held( $db, $origin ) ) { return self::error( 'Baseline deletion requires the current explicit installation choice.' ); } $table = self::tables( $db )[ $kind ]; if ( 1 !== preg_match( '/\A[a-zA-Z0-9_]{1,64}\z/D', $table ) ) { return self::error( 'Baseline table identity is invalid.' ); } if ( false === $db->query( $db->prepare( 'DROP TABLE IF EXISTS %i', $table ) ) ) { return self::error( 'An owned baseline table could not be removed. Keep lifecycle metadata for retry.' ); } $found = $db->get_var( $db->prepare( 'SHOW TABLES LIKE %s', $db->esc_like( $table ) ) ); if ( '' !== $db->last_error || null !== $found || ! self::held( $db, $origin ) ) { return self::error( 'Baseline table removal could not be verified.' ); } } return true; } }