= 64 || self::$dispatching ) { return new \WP_Error( 'definition', 'Invalid or unavailable custom event contract.' ); } $code = $definition['code']; if ( ! is_string( $code ) || strlen( $code ) > 80 || ! preg_match( '/\Acustom\.[a-z][a-z0-9_]{1,23}\.[a-z][a-z0-9_]{1,40}\z/D', $code ) || isset( self::$registered[ $code ] ) ) { return new \WP_Error( 'definition', 'Use a unique custom.vendor.event code.' ); } foreach ( array( 'description', 'explanation' ) as $field ) { if ( ! is_string( $definition[ $field ] ) || '' === $definition[ $field ] || strlen( $definition[ $field ] ) > 240 || preg_match( '/[<>\x00-\x1f\x7f]/', $definition[ $field ] ) ) { return new \WP_Error( 'definition', 'Use short plain-text descriptions and explanations.' ); } } if ( ! in_array( $definition['object_type'], array( 'site', 'user', 'post', 'comment', 'term', 'order', 'product' ), true ) || ! in_array( $definition['severity'], array( 'info', 'notice', 'warning', 'high', 'critical' ), true ) ) { return new \WP_Error( 'definition', 'Unsupported custom object or severity.' ); } foreach ( array( 'fields', 'diffs', 'privacy' ) as $field ) { if ( ! is_array( $definition[ $field ] ) || count( $definition[ $field ] ) > 32 ) { return new \WP_Error( 'definition', 'Custom schemas must be bounded maps.' ); } } $all = array_unique( array_merge( array_keys( $definition['fields'] ), array_keys( $definition['diffs'] ) ) ); if ( array_diff( $all, array_keys( $definition['privacy'] ) ) || array_diff( array_keys( $definition['privacy'] ), $all ) ) { return new \WP_Error( 'definition', 'Classify every declared field.' ); } foreach ( array( 'fields', 'diffs' ) as $group ) { foreach ( $definition[ $group ] as $field => $type ) { if ( ! is_string( $field ) || ! preg_match( '/\A[a-z][a-z0-9_]{0,31}\z/D', $field ) || ! in_array( $definition['privacy'][ $field ], array( 'non_personal', 'redacted' ), true ) ) { return new \WP_Error( 'definition', 'Invalid field name or privacy classification.' ); } if ( 'redacted' === $definition['privacy'][ $field ] ) { if ( 'redacted' !== $type ) { return new \WP_Error( 'definition', 'Private fields must use the redacted type.' ); } continue; } if ( in_array( $type, array( 'bool', 'count' ), true ) ) { continue; } if ( ! is_array( $type ) || ! array_is_list( $type ) || ! $type || count( $type ) > 16 ) { return new \WP_Error( 'definition', 'Use booleans, counts, finite enums or redacted values.' ); } foreach ( $type as $value ) { if ( ! is_string( $value ) || ! preg_match( '/\A[a-z][a-z0-9_]{0,31}\z/D', $value ) ) { return new \WP_Error( 'definition', 'Enum values must be static bounded tokens.' ); } } } } $definition['category'] = 'custom'; $definition['action'] = 'custom_recorded'; self::$registered[ $code ] = $definition; return true; } /** * Register a bounded native action observer with recursion and context safeguards. * * Register a bounded native action observer; callbacks remain installed PHP authority. * * @param string $id Unique bounded custom collector identifier registered by installed PHP. * @param string $hook Native action or filter name identifying the observed operation. * @param callable $observer Installed callback invoked when the registered native action runs. * @param int $accepted_args Bounded number of native action arguments passed to the observer. * @param int $priority Bounded native action priority for the registered observer. * * @return bool|\WP_Error True after registration, or an invalid/duplicate-collector error. */ public static function register_collector( string $id, string $hook, callable $observer, int $accepted_args = 1, int $priority = 10 ): bool|\WP_Error { if ( self::$dispatching || isset( self::$collectors[ $id ] ) || count( self::$collectors ) >= 64 || strlen( $id ) > 80 || ! preg_match( '/\Acustom\.[a-z][a-z0-9_]{1,23}\.[a-z][a-z0-9_]{1,40}\z/D', $id ) || ! preg_match( '/\A[a-zA-Z][a-zA-Z0-9_\/-]{0,79}\z/D', $hook ) || $accepted_args < 0 || $accepted_args > 8 || $priority < -1000 || $priority > 1000 ) { return new \WP_Error( 'collector', 'Invalid or duplicate bounded native collector.' ); } $callback = static function ( ...$args ) use ( $observer ) { if ( self::$dispatching ) { return; } if ( self::$collector_depth >= 8 ) { self::failure(); return; } ++self::$collector_depth; try { self::dispatch( static fn() => $observer( ...$args ), false ); } finally { --self::$collector_depth; } }; self::$collectors[ $id ] = $callback; add_action( $hook, $callback, $priority, $accepted_args ); return true; } /** * Build a custom observation with captured attribution and submit it to local storage. * * Actor/channel/scope are captured here; callers cannot supply attribution. * * @param string $code Registered finite event code for this observation. * @param int $object_id Native identifier of the object associated with the custom observation. * @param array $facts Finite observed facts declared by the event schema. * @param array $diffs Declared field differences to validate and minimize. * @param int|null $subject_id Optional affected-user identifier; null when no subject is supplied. * * @return bool Whether valid input was accepted by event capture. */ public static function record( string $code, int $object_id, array $facts = array(), array $diffs = array(), ?int $subject_id = null ): bool { $event = self::input( $code, $object_id, $facts, $diffs, $subject_id ); return false !== $event && Store::capture( $event ); } /** * Prepare a bounded custom observation using captured native attribution. * * Trusted PHP integration only: ready-to-insert finite data, with no private connection exposed to hooks. * * @param string $code Registered finite event code for this observation. * @param int $object_id Native identifier of the object associated with the custom observation. * @param array $facts Finite observed facts declared by the event schema. * @param array $diffs Declared field differences to validate and minimize. * @param int|null $subject_id Optional affected-user identifier; null when no subject is supplied. * * @return array|false Prepared insertion fields, or false when input or preparation is unavailable. */ public static function prepare( string $code, int $object_id, array $facts = array(), array $diffs = array(), ?int $subject_id = null ): array|false { $event = self::input( $code, $object_id, $facts, $diffs, $subject_id ); return false === $event ? false : Store::prepare_event( $event ); } /** * Bind registered custom facts and diffs to native attribution and required redactions. * * @param string $code Registered finite event code for this observation. * @param int $object_id Native identifier of the object associated with the custom observation. * @param array $facts Finite observed facts declared by the event schema. * @param array $diffs Declared field differences to validate and minimize. * @param int|null $subject_id Optional affected-user identifier; null when no subject is supplied. * * @return array|false Minimized custom event input, or false for unavailable/invalid input. */ private static function input( string $code, int $object_id, array $facts, array $diffs, ?int $subject_id ): array|false { $definition = self::$registered[ $code ] ?? null; if ( ! $definition || self::$dispatching || count( $facts ) > 32 || count( $diffs ) > 32 ) { return false; } $actor = Store::actor_context(); foreach ( $facts as $field => &$value ) { if ( 'redacted' === ( $definition['privacy'][ $field ] ?? null ) ) { $value = null; } } unset( $value ); foreach ( $diffs as &$diff ) { if ( ! is_array( $diff ) || ! is_string( $diff['field'] ?? null ) ) { return false; } if ( 'redacted' === ( $definition['privacy'][ $diff['field'] ] ?? null ) ) { $diff = array( 'field' => $diff['field'], 'before' => null, 'after' => null, 'availability' => 'redacted', ); } } unset( $diff ); return array( 'code' => $code, 'actor_context' => $actor, 'outcome' => 'observed', 'object_id' => $object_id, 'subject_id' => $subject_id, 'payload' => array( 'facts' => $facts, 'diffs' => $diffs, 'observed_via' => 'custom_extension_v1', 'confidence' => 'observed', ), ); } /** * Record extension degradation while preventing recursive diagnostic observers. * * A diagnostic cannot recursively invoke registered observers of option writes. * * @return void */ private static function failure(): void { $before = self::$dispatching; self::$dispatching = true; try { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- The optional extension failure sink may itself fail; avoid recursive diagnostics or exposing raw exception data, and restore dispatch state in finally. Store::degraded( 'extension_failed' ); } catch ( \Throwable $error ) { /* No raw exception or recursive fallback sink. */ } finally { self::$dispatching = $before; } } /** * Run an installed callback and restore native site and principal context afterward. * * Restore native blog/principal context even when a callback throws. * * @param callable $callback Installed callback executed inside the dispatch context boundary. * @param bool $block_capture Whether this dispatch blocks recursive custom event capture. * * @return array Callback result followed by exception and observed context-drift flags. */ private static function dispatch( callable $callback, bool $block_capture = true ): array { global $wpdb; $connection = $wpdb; $site = get_current_blog_id(); $network = Scope::network(); $user = get_current_user_id(); $stack = $GLOBALS['_wp_switched_stack'] ?? array(); $switched = $GLOBALS['switched'] ?? false; $prefix = $GLOBALS['table_prefix']; $failed = false; $drift = false; $value = null; $previous_dispatching = self::$dispatching; self::$dispatching = $block_capture || $previous_dispatching; try { $value = $callback(); } catch ( \Throwable $error ) { $failed = true; } finally { $drift = $wpdb !== $connection || get_current_blog_id() !== $site || Scope::network() !== $network || ( $GLOBALS['_wp_switched_stack'] ?? array() ) !== $stack; if ( $drift ) { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the captured caller connection after extension dispatch before repairing any callback-induced native context drift. $wpdb = $connection; if ( is_multisite() ) { try { if ( get_current_blog_id() !== $site ) { switch_to_blog( $site ); } } catch ( \Throwable $error ) { $failed = true; } // A callback on switch_blog cannot retain its changed table context. $wpdb->set_blog_id( $site, $network ); wp_cache_switch_to_blog( $site ); } // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the captured native blog identifier after the extension callback's site/table context has been repaired. $GLOBALS['blog_id'] = $site; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the captured native table prefix after repairing extension callback context drift. $GLOBALS['table_prefix'] = $prefix; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Core-owned WordPress globals must retain their native names when restoring the captured caller context. $GLOBALS['_wp_switched_stack'] = $stack; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Core-owned WordPress globals must retain their native names when restoring the captured caller context. $GLOBALS['switched'] = $switched; } // Native callbacks can revoke caps without replacing the current principal. // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Reload current site roles after extension callbacks so revoked grants cannot survive in a stale role object. $GLOBALS['wp_roles'] = new \WP_Roles( $site ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Reload the original native principal after extension callbacks using current grants, preserving the recorded actor context. $GLOBALS['current_user'] = new \WP_User( $user, '', $site ); self::$dispatching = $previous_dispatching; } if ( $failed || $drift ) { self::failure(); } return array( $value, $failed, $drift ); } /** * Apply only additional allowed field redactions from the local extension filter. * * A filter may only request additional redactions; canonical facts stay immutable. * * @param array $data Canonical event insertion fields, including the encoded payload. * * @return array|false Unchanged or further-redacted insertion data, or false after unsafe context/encoding failure. */ public static function pre_store( array $data ): array|false { if ( self::$dispatching ) { return false; } if ( false === has_filter( 'logged_ai_event_redactions' ) ) { return $data; } $network = Scope::network(); $site = get_current_blog_id(); $payload = json_decode( $data['payload'], true ); $normalized = $data; $normalized['payload'] = $payload; $request = array( 'facts' => array(), 'diffs' => array(), ); [ $filtered, $failed, $drift ] = self::dispatch( static fn() => apply_filters( 'logged_ai_event_redactions', $request, $normalized, self::VERSION ) ); if ( $drift || Scope::network() !== $network || get_current_blog_id() !== $site ) { return false; } if ( ! $failed ) { $request = $filtered; } $valid = is_array( $request ) && 2 === count( $request ) && isset( $request['facts'], $request['diffs'] ); foreach ( array( 'facts', 'diffs' ) as $group ) { if ( ! $valid || ! is_array( $request[ $group ] ) || ! array_is_list( $request[ $group ] ) || count( $request[ $group ] ) > 32 ) { $valid = false; break; } $allowed = 'facts' === $group ? array_keys( $payload['facts'] ?? array() ) : array_column( $payload['diffs'] ?? array(), 'field' ); foreach ( $request[ $group ] as $field ) { if ( ! is_string( $field ) || ! in_array( $field, $allowed, true ) ) { $valid = false; break; } } } if ( ! $valid ) { self::failure(); return $data; } foreach ( $request['facts'] as $field ) { unset( $payload['facts'][ $field ] ); } foreach ( $payload['diffs'] as &$diff ) { if ( in_array( $diff['field'], $request['diffs'], true ) ) { $diff['before'] = null; $diff['after'] = null; $diff['availability'] = 'redacted'; $diff['redacted'] = true; } } unset( $diff ); $json = wp_json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ( false === $json || strlen( $json ) > 16384 ) { self::failure(); return false; } $data['payload'] = $json; return $data; } /** * Notify installed observers after insertion under the guarded dispatch boundary. * * Successful insert is not a promise that a caller-owned transaction will commit. * * @param int $id Identifier returned by the successful event insertion. * @param array $data Canonical event insertion fields, including the encoded payload. * * @return void */ public static function post_store( int $id, array $data ): void { if ( self::$dispatching || false === has_action( 'logged_ai_event_stored' ) ) { return; } $data['payload'] = json_decode( $data['payload'], true ); self::dispatch( static function () use ( $id, $data ) { do_action( 'logged_ai_event_stored', $id, $data, self::VERSION ); } ); } }