prefix (trusted); every value is bound via prepare() placeholders, verified by PHPStan. /** * Data access for adaf_policies. */ final class PolicyRepository { /** * Constructor. * * @param wpdb $wpdb WordPress database. */ public function __construct( private readonly wpdb $wpdb ) { } /** * Fully prefixed table name for the site WordPress is on now. Worked * out per query, so after switch_to_blog() it is that site's table * rather than the one this repository was created on (ADR-16, found * 2026-10-16). */ private function table(): string { return $this->wpdb->prefix . 'adaf_policies'; } /** * Insert a new policy. * * @param int|null $agent_id Owning agent, null = global. * @param array> $rules Rules list. * @param int $priority Ascending evaluation order. */ public function create( ?int $agent_id, array $rules, int $priority ): Policy { $now = new DateTimeImmutable( 'now' ); $this->wpdb->insert( $this->table(), array( 'agent_id' => $agent_id, 'rules_json' => (string) wp_json_encode( $rules ), 'priority' => $priority, 'created_at' => $now->format( 'Y-m-d H:i:s' ), 'updated_at' => $now->format( 'Y-m-d H:i:s' ), ), array( '%d', '%s', '%d', '%s', '%s' ) ); return new Policy( (int) $this->wpdb->insert_id, $agent_id, $rules, $priority, $now, $now ); } /** * Find by primary key. * * @param int $id Row id. */ public function find( int $id ): ?Policy { $row = $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$this->table()} WHERE id = %d", $id ), ARRAY_A ); return null === $row ? null : $this->hydrate( $row ); } /** * Policies applying to an agent: its own plus global ones, priority * ascending; at equal priority, agent-specific policies sort before * global ones (exact scope overrides general; PolicyEvaluator relies * on this order). * * @param int $agent_id Agent id. * @return list */ public function for_agent( int $agent_id ): array { $rows = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$this->table()} WHERE agent_id = %d OR agent_id IS NULL ORDER BY priority ASC, (agent_id IS NULL) ASC, id ASC", $agent_id ), ARRAY_A ); return array_values( array_map( array( $this, 'hydrate' ), $rows ?? array() ) ); } /** * All policies, priority ascending. * * @return list */ public function all(): array { $rows = $this->wpdb->get_results( "SELECT * FROM {$this->table()} ORDER BY priority ASC, id ASC", ARRAY_A ); return array_values( array_map( array( $this, 'hydrate' ), $rows ?? array() ) ); } /** * Update rules and priority. * * @param int $id Row id. * @param array> $rules Rules list. * @param int $priority Evaluation order. */ public function update( int $id, array $rules, int $priority, ?int $agent_id = null ): void { // Replaces the whole row, so scope is written every time: omitting // $agent_id widens the policy to global rather than leaving it alone. // That matches the PUT the controller serves, where the request body // is the complete policy. $this->wpdb->update( $this->table(), array( 'agent_id' => $agent_id, 'rules_json' => (string) wp_json_encode( $rules ), 'priority' => $priority, 'updated_at' => ( new DateTimeImmutable( 'now' ) )->format( 'Y-m-d H:i:s' ), ), array( 'id' => $id ), array( '%d', '%s', '%d', '%s' ), array( '%d' ) ); } /** * Delete a policy. * * @param int $id Row id. */ public function delete( int $id ): void { $this->wpdb->delete( $this->table(), array( 'id' => $id ), array( '%d' ) ); } /** * Map a database row to the value object. * * @param array $row Raw row. */ private function hydrate( array $row ): Policy { $rules = json_decode( (string) $row['rules_json'], true ); return new Policy( (int) $row['id'], null === $row['agent_id'] ? null : (int) $row['agent_id'], is_array( $rules ) ? $rules : array(), (int) $row['priority'], new DateTimeImmutable( (string) $row['created_at'] ), new DateTimeImmutable( (string) $row['updated_at'] ) ); } }