Bläddra i källkod

feat(M06): manual blocks, allowlist, CIDR evaluator

- admin endpoints for manual_blocks and allowlist (IP and CIDR, v4 + v6)
- non-canonical CIDR input auto-normalized; response includes normalized_from
- in-process CidrEvaluator with 60s cache + invalidate on writes
- EffectiveStatusService skeleton (allowlist + manual; score+policy lands in M07)
- allowlist always wins; warning logged on overlap with manual blocks

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 1 vecka sedan
förälder
incheckning
2e26a686e8

+ 5 - 0
.env.example

@@ -45,6 +45,11 @@ JOB_RECOMPUTE_MAX_ROWS_PER_TICK=5000
 JOB_AUDIT_RETENTION_DAYS=180
 JOB_GEOIP_REFRESH_INTERVAL_DAYS=7
 
+# Manual blocks / allowlist evaluator
+# In-process cache TTL for the CidrEvaluator. Mutations invalidate explicitly,
+# so this only matters for cross-replica visibility (per replica is fine).
+CIDR_EVALUATOR_TTL_SECONDS=60
+
 # GeoIP
 GEOIP_ENABLED=true
 GEOIP_COUNTRY_DB=/data/geoip/GeoLite2-Country.mmdb

+ 17 - 0
PROGRESS.md

@@ -108,3 +108,20 @@
 
 **Deviations from SPEC:** none.
 **Added dependencies:** none.
+
+## M06 — Manual blocks, allowlist (done)
+
+**Built:** CRUD for `manual_blocks` and `allowlist` (single-IP and CIDR, v4 + v6); CidrEvaluator (in-process containment over a snapshot); CidrEvaluatorFactory (60s TTL cache + invalidate on writes); EffectiveStatusService (allowlist + manual; score+policy lands in M07); SPEC §M06 acceptance script passes end-to-end.
+
+**Notes for next milestone:**
+- M07 wires `CidrEvaluatorFactory` into the distribution endpoint and finishes `EffectiveStatusService` by adding score-vs-policy evaluation. Inject `CategoryRepository`, `IpScoreRepository`, and the per-policy thresholds into the service alongside the existing evaluator.
+- Cache TTL is `CIDR_EVALUATOR_TTL_SECONDS` (default 60s); mutation endpoints invalidate explicitly **and** force a synchronous rebuild (`get()`) so an overlap WARNING fires inside the same request — operators see immediate feedback. Multi-replica deployments will see up to 60s of staleness across replicas — accepted.
+- Manual-block expiration cleanup: data model has `expires_at`, repo has `findExpired($now)` returning ids, but no job runs. Add in M14 hardening if desired, or leave as a documented limitation.
+- CIDR canonicalization picks recommendation (c) from the milestone doc: non-canonical input is silently normalized; the response body echoes `normalized_from: <original>` only when the normalization changed the input. Canonical input omits the field.
+- Repository inserts go through `RepositoryBase::insertRow()` for the binary-column ergonomics, but `insertRow()` returns `executeStatement()`'s row count — not the new id. The repos call `(int) $this->connection()->lastInsertId()` after `insertRow()` to recover the id. Same pattern `ReportRepository::insert` uses — kept consistent.
+- `Cidr::fromBinary($networkBin, $unifiedPrefix)` was added so repositories can hydrate stored rows back into the value object. The v4-vs-v6 heuristic mirrors what `IpAddress::fromBinary` does (v4-mapped IPv6 prefix + unified prefix ≥ 96 ⇒ render as v4).
+- `CidrEvaluatorFactory` is intentionally *not* `final` — `EffectiveStatusServiceTest` substitutes an in-memory stub via subclass to avoid spinning up the DB.
+- RBAC split per SPEC §6: list/show ⇒ Viewer, create/delete ⇒ Operator. Achieved with per-route `RbacMiddleware::require(...)` rather than group-level — a small departure from the all-Admin pattern used by reporters/consumers/tokens but the cleanest expression of "the same URL has different role requirements per method".
+
+**Deviations from SPEC:** none.
+**Added dependencies:** none.

+ 1 - 0
api/config/settings.php

@@ -51,4 +51,5 @@ return [
     'job_recompute_max_runtime_seconds' => (int) (getenv('JOB_RECOMPUTE_MAX_RUNTIME_SECONDS') ?: 240),
     'job_recompute_max_rows_per_tick' => (int) (getenv('JOB_RECOMPUTE_MAX_ROWS_PER_TICK') ?: 5000),
     'job_audit_retention_days' => (int) (getenv('JOB_AUDIT_RETENTION_DAYS') ?: 180),
+    'cidr_evaluator_ttl_seconds' => (int) (getenv('CIDR_EVALUATOR_TTL_SECONDS') ?: 60),
 ];

+ 26 - 0
api/src/App/AppFactory.php

@@ -4,7 +4,9 @@ declare(strict_types=1);
 
 namespace App\App;
 
+use App\Application\Admin\AllowlistController;
 use App\Application\Admin\ConsumersController;
+use App\Application\Admin\ManualBlocksController;
 use App\Application\Admin\MeController;
 use App\Application\Admin\ReportersController;
 use App\Application\Admin\TokensController;
@@ -146,6 +148,30 @@ final class AppFactory
                 $r->post('', [$tokens, 'create']);
                 $r->delete('/{id}', [$tokens, 'delete']);
             })->add(RbacMiddleware::require($rf, Role::Admin));
+
+            // Manual blocks: list/show require Viewer, create/delete require Operator.
+            // Per-route middleware lets us split read vs write at one URL group.
+            /** @var ManualBlocksController $manualBlocks */
+            $manualBlocks = $container->get(ManualBlocksController::class);
+            $admin->get('/manual-blocks', [$manualBlocks, 'list'])
+                ->add(RbacMiddleware::require($rf, Role::Viewer));
+            $admin->get('/manual-blocks/{id}', [$manualBlocks, 'show'])
+                ->add(RbacMiddleware::require($rf, Role::Viewer));
+            $admin->post('/manual-blocks', [$manualBlocks, 'create'])
+                ->add(RbacMiddleware::require($rf, Role::Operator));
+            $admin->delete('/manual-blocks/{id}', [$manualBlocks, 'delete'])
+                ->add(RbacMiddleware::require($rf, Role::Operator));
+
+            /** @var AllowlistController $allowlist */
+            $allowlist = $container->get(AllowlistController::class);
+            $admin->get('/allowlist', [$allowlist, 'list'])
+                ->add(RbacMiddleware::require($rf, Role::Viewer));
+            $admin->get('/allowlist/{id}', [$allowlist, 'show'])
+                ->add(RbacMiddleware::require($rf, Role::Viewer));
+            $admin->post('/allowlist', [$allowlist, 'create'])
+                ->add(RbacMiddleware::require($rf, Role::Operator));
+            $admin->delete('/allowlist/{id}', [$allowlist, 'delete'])
+                ->add(RbacMiddleware::require($rf, Role::Operator));
         })
             ->add($impersonation)
             ->add($tokenAuth);

+ 26 - 0
api/src/App/Container.php

@@ -4,7 +4,9 @@ declare(strict_types=1);
 
 namespace App\App;
 
+use App\Application\Admin\AllowlistController;
 use App\Application\Admin\ConsumersController;
+use App\Application\Admin\ManualBlocksController;
 use App\Application\Admin\MeController;
 use App\Application\Admin\ReportersController;
 use App\Application\Admin\TokensController;
@@ -18,9 +20,11 @@ use App\Application\Public\ReportController;
 use App\Domain\Auth\Role;
 use App\Domain\Auth\TokenHasher;
 use App\Domain\Auth\TokenIssuer;
+use App\Domain\Reputation\EffectiveStatusService;
 use App\Domain\Reputation\PairScorer;
 use App\Domain\Time\Clock;
 use App\Domain\Time\SystemClock;
+use App\Infrastructure\Allowlist\AllowlistRepository;
 use App\Infrastructure\Auth\RoleMappingRepository;
 use App\Infrastructure\Auth\ServiceTokenBootstrap;
 use App\Infrastructure\Auth\TokenRepository;
@@ -39,7 +43,9 @@ use App\Infrastructure\Jobs\JobLockRepository;
 use App\Infrastructure\Jobs\JobRegistry;
 use App\Infrastructure\Jobs\JobRunner;
 use App\Infrastructure\Jobs\JobRunRepository;
+use App\Infrastructure\ManualBlock\ManualBlockRepository;
 use App\Infrastructure\Reporter\ReporterRepository;
+use App\Infrastructure\Reputation\CidrEvaluatorFactory;
 use App\Infrastructure\Reputation\IpScoreRepository;
 use App\Infrastructure\Reputation\ReportRepository;
 
@@ -90,6 +96,7 @@ final class Container
             'settings.job_recompute_max_runtime_seconds' => (int) ($settings['job_recompute_max_runtime_seconds'] ?? 240),
             'settings.job_recompute_max_rows_per_tick' => (int) ($settings['job_recompute_max_rows_per_tick'] ?? 5000),
             'settings.job_audit_retention_days' => (int) ($settings['job_audit_retention_days'] ?? 180),
+            'settings.cidr_evaluator_ttl_seconds' => (int) ($settings['cidr_evaluator_ttl_seconds'] ?? 60),
             ConnectionFactory::class => factory(static function (ContainerInterface $c): ConnectionFactory {
                 /** @var array{driver: string, sqlite_path: string, mysql_host: string, mysql_port: int, mysql_database: string, mysql_username: string, mysql_password: string} $db */
                 $db = $c->get('settings.db');
@@ -124,6 +131,23 @@ final class Container
             CategoryRepository::class => autowire(),
             ReportRepository::class => autowire(),
             IpScoreRepository::class => autowire(),
+            ManualBlockRepository::class => autowire(),
+            AllowlistRepository::class => autowire(),
+            CidrEvaluatorFactory::class => factory(static function (ContainerInterface $c): CidrEvaluatorFactory {
+                /** @var ManualBlockRepository $manual */
+                $manual = $c->get(ManualBlockRepository::class);
+                /** @var AllowlistRepository $allow */
+                $allow = $c->get(AllowlistRepository::class);
+                /** @var Clock $clock */
+                $clock = $c->get(Clock::class);
+                /** @var LoggerInterface $logger */
+                $logger = $c->get(LoggerInterface::class);
+                /** @var int $ttl */
+                $ttl = $c->get('settings.cidr_evaluator_ttl_seconds');
+
+                return new CidrEvaluatorFactory($manual, $allow, $clock, $logger, $ttl);
+            }),
+            EffectiveStatusService::class => autowire(),
             ServiceTokenBootstrap::class => autowire(),
             TokenAuthenticationMiddleware::class => autowire(),
             ImpersonationMiddleware::class => autowire(),
@@ -252,6 +276,8 @@ final class Container
             ConsumersController::class => autowire(),
             TokensController::class => autowire(),
             ReportController::class => autowire(),
+            ManualBlocksController::class => autowire(),
+            AllowlistController::class => autowire(),
         ]);
 
         return $builder->build();

+ 182 - 0
api/src/Application/Admin/AllowlistController.php

@@ -0,0 +1,182 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Admin;
+
+use App\Domain\Allowlist\AllowlistEntry;
+use App\Domain\Ip\Cidr;
+use App\Domain\Ip\InvalidCidrException;
+use App\Domain\Ip\InvalidIpException;
+use App\Domain\Ip\IpAddress;
+use App\Infrastructure\Allowlist\AllowlistRepository;
+use App\Infrastructure\Reputation\CidrEvaluatorFactory;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Admin CRUD over `allowlist`. Mirrors ManualBlocksController minus
+ * `expires_at` — allowlist entries don't expire on a clock.
+ *
+ * SPEC §6 RBAC: Operator may create/delete; Viewer may list/get.
+ */
+final class AllowlistController
+{
+    use AdminControllerSupport;
+
+    public function __construct(
+        private readonly AllowlistRepository $allowlist,
+        private readonly CidrEvaluatorFactory $evaluator,
+    ) {
+    }
+
+    public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $page = self::pagination($request);
+        $params = $request->getQueryParams();
+        $kind = null;
+        if (isset($params['kind']) && is_string($params['kind']) && in_array($params['kind'], [AllowlistEntry::KIND_IP, AllowlistEntry::KIND_SUBNET], true)) {
+            $kind = $params['kind'];
+        }
+
+        $rows = $this->allowlist->list($page['limit'], $page['offset'], ['kind' => $kind]);
+        $total = $this->allowlist->count($kind);
+
+        return self::json($response, 200, [
+            'items' => array_map(static fn (AllowlistEntry $e) => $e->toArray(), $rows),
+            'page' => $page['page'],
+            'limit' => $page['limit'],
+            'total' => $total,
+        ]);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function show(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $id = self::parseId($args['id']);
+        if ($id === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        $entry = $this->allowlist->findById($id);
+        if ($entry === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        return self::json($response, 200, $entry->toArray());
+    }
+
+    public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $body = self::jsonBody($request);
+        $errors = [];
+
+        $kind = $body['kind'] ?? null;
+        if (!in_array($kind, [AllowlistEntry::KIND_IP, AllowlistEntry::KIND_SUBNET], true)) {
+            return self::validationFailed($response, ['kind' => 'required, "ip" or "subnet"']);
+        }
+
+        $reason = null;
+        if (array_key_exists('reason', $body)) {
+            if ($body['reason'] !== null && !is_string($body['reason'])) {
+                $errors['reason'] = 'must be string or null';
+            } else {
+                $reason = $body['reason'];
+            }
+        }
+
+        if ($kind === AllowlistEntry::KIND_IP) {
+            if (!isset($body['ip']) || !is_string($body['ip']) || $body['ip'] === '') {
+                $errors['ip'] = 'required for kind=ip';
+            }
+            if (isset($body['cidr'])) {
+                $errors['cidr'] = 'forbidden for kind=ip';
+            }
+            if ($errors !== []) {
+                return self::validationFailed($response, $errors);
+            }
+
+            try {
+                /** @var string $ipText */
+                $ipText = $body['ip'];
+                $ip = IpAddress::fromString($ipText);
+            } catch (InvalidIpException $e) {
+                return self::validationFailed($response, ['ip' => $e->getMessage()]);
+            }
+
+            $id = $this->allowlist->createIp($ip, $reason, self::actingUserId($request));
+            $this->evaluator->invalidate();
+            // Eager rebuild so an allowlist/manual-block overlap surfaces
+            // as a WARNING log entry inside this request — matches the
+            // ManualBlocksController behaviour for symmetry.
+            $this->evaluator->get();
+            $created = $this->allowlist->findById($id);
+            if ($created === null) {
+                return self::error($response, 500, 'create_failed');
+            }
+
+            return self::json($response, 201, $created->toArray());
+        }
+
+        // kind=subnet
+        if (!isset($body['cidr']) || !is_string($body['cidr']) || $body['cidr'] === '') {
+            $errors['cidr'] = 'required for kind=subnet';
+        }
+        if (isset($body['ip'])) {
+            $errors['ip'] = 'forbidden for kind=subnet';
+        }
+        if ($errors !== []) {
+            return self::validationFailed($response, $errors);
+        }
+
+        try {
+            /** @var string $cidrInput */
+            $cidrInput = $body['cidr'];
+            $cidr = Cidr::fromString($cidrInput);
+        } catch (InvalidCidrException $e) {
+            return self::validationFailed($response, ['cidr' => $e->getMessage()]);
+        }
+
+        $id = $this->allowlist->createSubnet($cidr, $reason, self::actingUserId($request));
+        $this->evaluator->invalidate();
+        $this->evaluator->get();
+        $created = $this->allowlist->findById($id);
+        if ($created === null) {
+            return self::error($response, 500, 'create_failed');
+        }
+
+        $payload = $created->toArray();
+        if ($cidrInput !== $cidr->text()) {
+            $payload['normalized_from'] = $cidrInput;
+        }
+
+        return self::json($response, 201, $payload);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $id = self::parseId($args['id']);
+        if ($id === null) {
+            return self::error($response, 404, 'not_found');
+        }
+        $existing = $this->allowlist->findById($id);
+        if ($existing === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        $this->allowlist->delete($id);
+        $this->evaluator->invalidate();
+
+        return $response->withStatus(204);
+    }
+
+    private static function parseId(string $raw): ?int
+    {
+        return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null;
+    }
+}

+ 215 - 0
api/src/Application/Admin/ManualBlocksController.php

@@ -0,0 +1,215 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Admin;
+
+use App\Domain\Ip\Cidr;
+use App\Domain\Ip\InvalidCidrException;
+use App\Domain\Ip\InvalidIpException;
+use App\Domain\Ip\IpAddress;
+use App\Domain\ManualBlock\ManualBlock;
+use App\Infrastructure\ManualBlock\ManualBlockRepository;
+use App\Infrastructure\Reputation\CidrEvaluatorFactory;
+use DateTimeImmutable;
+use DateTimeZone;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Admin CRUD over `manual_blocks`. SPEC §6 RBAC:
+ *  - Operator: create + delete
+ *  - Viewer:   list + get
+ *
+ * Per-route role enforcement happens in `AppFactory` via `RbacMiddleware::require`;
+ * this controller only sees authorized requests.
+ *
+ * CIDR canonicalization (recommendation (c) in M06.md): non-canonical input
+ * such as `203.0.113.55/24` is silently normalized to `203.0.113.0/24` and
+ * the response body echoes both `cidr` and `normalized_from`. Canonical
+ * input omits the field. This is the behaviour the M06 acceptance script
+ * tests for.
+ */
+final class ManualBlocksController
+{
+    use AdminControllerSupport;
+
+    public function __construct(
+        private readonly ManualBlockRepository $manualBlocks,
+        private readonly CidrEvaluatorFactory $evaluator,
+    ) {
+    }
+
+    public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $page = self::pagination($request);
+        $params = $request->getQueryParams();
+        $kind = null;
+        if (isset($params['kind']) && is_string($params['kind']) && in_array($params['kind'], [ManualBlock::KIND_IP, ManualBlock::KIND_SUBNET], true)) {
+            $kind = $params['kind'];
+        }
+
+        $rows = $this->manualBlocks->list($page['limit'], $page['offset'], ['kind' => $kind]);
+        $total = $this->manualBlocks->count($kind);
+
+        return self::json($response, 200, [
+            'items' => array_map(static fn (ManualBlock $b) => $b->toArray(), $rows),
+            'page' => $page['page'],
+            'limit' => $page['limit'],
+            'total' => $total,
+        ]);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function show(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $id = self::parseId($args['id']);
+        if ($id === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        $entry = $this->manualBlocks->findById($id);
+        if ($entry === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        return self::json($response, 200, $entry->toArray());
+    }
+
+    public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $body = self::jsonBody($request);
+        $errors = [];
+
+        $kind = $body['kind'] ?? null;
+        if (!in_array($kind, [ManualBlock::KIND_IP, ManualBlock::KIND_SUBNET], true)) {
+            return self::validationFailed($response, ['kind' => 'required, "ip" or "subnet"']);
+        }
+
+        $reason = null;
+        if (array_key_exists('reason', $body)) {
+            if ($body['reason'] !== null && !is_string($body['reason'])) {
+                $errors['reason'] = 'must be string or null';
+            } else {
+                $reason = $body['reason'];
+            }
+        }
+
+        $expiresAt = null;
+        if (array_key_exists('expires_at', $body) && $body['expires_at'] !== null) {
+            if (!is_string($body['expires_at'])) {
+                $errors['expires_at'] = 'must be ISO-8601 string or null';
+            } else {
+                $parsed = self::parseTimestamp($body['expires_at']);
+                if ($parsed === null) {
+                    $errors['expires_at'] = 'must be ISO-8601 (e.g. 2026-12-31T23:59:59Z)';
+                } else {
+                    $expiresAt = $parsed;
+                }
+            }
+        }
+
+        if ($kind === ManualBlock::KIND_IP) {
+            if (!isset($body['ip']) || !is_string($body['ip']) || $body['ip'] === '') {
+                $errors['ip'] = 'required for kind=ip';
+            }
+            if (isset($body['cidr'])) {
+                $errors['cidr'] = 'forbidden for kind=ip';
+            }
+            if ($errors !== []) {
+                return self::validationFailed($response, $errors);
+            }
+
+            try {
+                /** @var string $ipText */
+                $ipText = $body['ip'];
+                $ip = IpAddress::fromString($ipText);
+            } catch (InvalidIpException $e) {
+                return self::validationFailed($response, ['ip' => $e->getMessage()]);
+            }
+
+            $id = $this->manualBlocks->createIp($ip, $reason, $expiresAt, self::actingUserId($request));
+            $this->evaluator->invalidate();
+            // Eagerly rebuild so any overlap with the allowlist surfaces as
+            // a WARNING log entry while we're still in this request — gives
+            // admins immediate feedback on a suspicious configuration.
+            $this->evaluator->get();
+            $created = $this->manualBlocks->findById($id);
+            if ($created === null) {
+                return self::error($response, 500, 'create_failed');
+            }
+
+            return self::json($response, 201, $created->toArray());
+        }
+
+        // kind=subnet
+        if (!isset($body['cidr']) || !is_string($body['cidr']) || $body['cidr'] === '') {
+            $errors['cidr'] = 'required for kind=subnet';
+        }
+        if (isset($body['ip'])) {
+            $errors['ip'] = 'forbidden for kind=subnet';
+        }
+        if ($errors !== []) {
+            return self::validationFailed($response, $errors);
+        }
+
+        try {
+            /** @var string $cidrInput */
+            $cidrInput = $body['cidr'];
+            $cidr = Cidr::fromString($cidrInput);
+        } catch (InvalidCidrException $e) {
+            return self::validationFailed($response, ['cidr' => $e->getMessage()]);
+        }
+
+        $id = $this->manualBlocks->createSubnet($cidr, $reason, $expiresAt, self::actingUserId($request));
+        $this->evaluator->invalidate();
+        $this->evaluator->get();
+        $created = $this->manualBlocks->findById($id);
+        if ($created === null) {
+            return self::error($response, 500, 'create_failed');
+        }
+
+        $payload = $created->toArray();
+        if ($cidrInput !== $cidr->text()) {
+            $payload['normalized_from'] = $cidrInput;
+        }
+
+        return self::json($response, 201, $payload);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $id = self::parseId($args['id']);
+        if ($id === null) {
+            return self::error($response, 404, 'not_found');
+        }
+        $existing = $this->manualBlocks->findById($id);
+        if ($existing === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        $this->manualBlocks->delete($id);
+        $this->evaluator->invalidate();
+
+        return $response->withStatus(204);
+    }
+
+    private static function parseId(string $raw): ?int
+    {
+        return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null;
+    }
+
+    private static function parseTimestamp(string $iso): ?DateTimeImmutable
+    {
+        try {
+            return new DateTimeImmutable($iso, new DateTimeZone('UTC'));
+        } catch (\Exception) {
+            return null;
+        }
+    }
+}

+ 57 - 0
api/src/Domain/Allowlist/AllowlistEntry.php

@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Allowlist;
+
+use App\Domain\Ip\Cidr;
+use App\Domain\Ip\IpAddress;
+use DateTimeImmutable;
+
+/**
+ * One never-block entry. Same shape as ManualBlock minus `expires_at` —
+ * allowlist entries don't expire on a clock; they're removed by an
+ * administrator. Per SPEC §5 allowlist always wins over manual blocks.
+ */
+final class AllowlistEntry
+{
+    public const KIND_IP = 'ip';
+    public const KIND_SUBNET = 'subnet';
+
+    public function __construct(
+        public readonly int $id,
+        public readonly string $kind,
+        public readonly ?IpAddress $ip,
+        public readonly ?Cidr $cidr,
+        public readonly ?string $reason,
+        public readonly DateTimeImmutable $createdAt,
+        public readonly ?int $createdByUserId,
+    ) {
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function toArray(): array
+    {
+        $base = [
+            'id' => $this->id,
+            'kind' => $this->kind,
+            'reason' => $this->reason,
+            'created_at' => $this->createdAt->format('Y-m-d\TH:i:s\Z'),
+            'created_by_user_id' => $this->createdByUserId,
+        ];
+
+        if ($this->kind === self::KIND_IP) {
+            $base['ip'] = $this->ip?->text();
+            $base['cidr'] = null;
+            $base['prefix_length'] = null;
+        } else {
+            $base['ip'] = null;
+            $base['cidr'] = $this->cidr?->text();
+            $base['prefix_length'] = $this->cidr?->originalPrefix();
+        }
+
+        return $base;
+    }
+}

+ 26 - 0
api/src/Domain/Ip/Cidr.php

@@ -22,6 +22,32 @@ final class Cidr
     ) {
     }
 
+    /**
+     * Build a Cidr from a stored 16-byte network binary plus the unified
+     * prefix length (0–128, with v4 prefixes pre-shifted by +96 per the
+     * class invariant). Used when hydrating rows that already store the
+     * canonical network — no input validation beyond the binary length and
+     * range, since the database is the source.
+     */
+    public static function fromBinary(string $networkBin, int $unifiedPrefixLength): self
+    {
+        if (strlen($networkBin) !== 16) {
+            throw new InvalidCidrException('Network binary must be exactly 16 bytes.');
+        }
+        if ($unifiedPrefixLength < 0 || $unifiedPrefixLength > 128) {
+            throw new InvalidCidrException(sprintf('Prefix out of range 0-128: %d', $unifiedPrefixLength));
+        }
+
+        $isV4Mapped = substr($networkBin, 0, 12) === "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff"
+            && $unifiedPrefixLength >= 96;
+        $original = $isV4Mapped ? $unifiedPrefixLength - 96 : $unifiedPrefixLength;
+
+        // Re-mask defensively in case the stored network has stray bits.
+        $masked = self::applyMask($networkBin, $unifiedPrefixLength);
+
+        return new self($masked, $unifiedPrefixLength, $original, $isV4Mapped);
+    }
+
     public static function fromString(string $input): self
     {
         if ($input === '') {

+ 59 - 0
api/src/Domain/ManualBlock/ManualBlock.php

@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\ManualBlock;
+
+use App\Domain\Ip\Cidr;
+use App\Domain\Ip\IpAddress;
+use DateTimeImmutable;
+
+/**
+ * One admin-defined block. Either a single IP (`kind=ip`, `ipBin` set) or a
+ * CIDR subnet (`kind=subnet`, `networkBin` + `prefixLength` set). The two
+ * shapes are kept in one row per SPEC §4 and disambiguated by `kind`.
+ */
+final class ManualBlock
+{
+    public const KIND_IP = 'ip';
+    public const KIND_SUBNET = 'subnet';
+
+    public function __construct(
+        public readonly int $id,
+        public readonly string $kind,
+        public readonly ?IpAddress $ip,
+        public readonly ?Cidr $cidr,
+        public readonly ?string $reason,
+        public readonly ?DateTimeImmutable $expiresAt,
+        public readonly DateTimeImmutable $createdAt,
+        public readonly ?int $createdByUserId,
+    ) {
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function toArray(): array
+    {
+        $base = [
+            'id' => $this->id,
+            'kind' => $this->kind,
+            'reason' => $this->reason,
+            'expires_at' => $this->expiresAt?->format('Y-m-d\TH:i:s\Z'),
+            'created_at' => $this->createdAt->format('Y-m-d\TH:i:s\Z'),
+            'created_by_user_id' => $this->createdByUserId,
+        ];
+
+        if ($this->kind === self::KIND_IP) {
+            $base['ip'] = $this->ip?->text();
+            $base['cidr'] = null;
+            $base['prefix_length'] = null;
+        } else {
+            $base['ip'] = null;
+            $base['cidr'] = $this->cidr?->text();
+            $base['prefix_length'] = $this->cidr?->originalPrefix();
+        }
+
+        return $base;
+    }
+}

+ 128 - 0
api/src/Domain/Reputation/CidrEvaluator.php

@@ -0,0 +1,128 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Reputation;
+
+use App\Domain\Ip\Cidr;
+use App\Domain\Ip\IpAddress;
+
+/**
+ * In-memory CIDR / IP containment evaluator.
+ *
+ * Built once from the current `manual_blocks` and `allowlist` snapshot, then
+ * queried per IP. Linear scan over subnet lists; PHP-fine for up to ~10k
+ * subnet entries per the SPEC §15 manual-only constraint.
+ *
+ * Single-IP lookups use a binary-key hash for O(1) membership; subnet
+ * lookups walk the list and apply prefix masking using `Cidr::contains()`.
+ */
+final class CidrEvaluator
+{
+    /** @var array<string, true> ip-bin keyed set */
+    private array $manualIps;
+    /** @var array<string, true> ip-bin keyed set */
+    private array $allowlistIps;
+    /** @var list<Cidr> */
+    private array $manualSubnets;
+    /** @var list<Cidr> */
+    private array $allowlistSubnets;
+
+    /**
+     * @param list<string> $manualIpBins
+     * @param list<Cidr>   $manualSubnets
+     * @param list<string> $allowlistIpBins
+     * @param list<Cidr>   $allowlistSubnets
+     */
+    public function __construct(
+        array $manualIpBins,
+        array $manualSubnets,
+        array $allowlistIpBins,
+        array $allowlistSubnets,
+    ) {
+        $this->manualIps = self::indexBinaries($manualIpBins);
+        $this->allowlistIps = self::indexBinaries($allowlistIpBins);
+        $this->manualSubnets = $manualSubnets;
+        $this->allowlistSubnets = $allowlistSubnets;
+    }
+
+    public function isAllowlisted(IpAddress $ip): bool
+    {
+        if (isset($this->allowlistIps[$ip->binary()])) {
+            return true;
+        }
+        foreach ($this->allowlistSubnets as $cidr) {
+            if ($cidr->contains($ip)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    public function isManuallyBlocked(IpAddress $ip): bool
+    {
+        if (isset($this->manualIps[$ip->binary()])) {
+            return true;
+        }
+        foreach ($this->manualSubnets as $cidr) {
+            if ($cidr->contains($ip)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Subnet entries (CIDRs) of `manual_blocks` — used by the M07
+     * distribution endpoint to emit one CIDR line per manual subnet.
+     *
+     * @return list<Cidr>
+     */
+    public function manualBlockedSubnets(): array
+    {
+        return $this->manualSubnets;
+    }
+
+    /**
+     * @return list<Cidr>
+     */
+    public function allowlistedSubnets(): array
+    {
+        return $this->allowlistSubnets;
+    }
+
+    /**
+     * Single-IP entries of `manual_blocks` as binary keys — exposed so the
+     * distribution endpoint can iterate them without re-querying.
+     *
+     * @return list<string>
+     */
+    public function manualBlockedIpBins(): array
+    {
+        return array_keys($this->manualIps);
+    }
+
+    /**
+     * @return list<string>
+     */
+    public function allowlistedIpBins(): array
+    {
+        return array_keys($this->allowlistIps);
+    }
+
+    /**
+     * @param list<string> $bins
+     * @return array<string, true>
+     */
+    private static function indexBinaries(array $bins): array
+    {
+        $out = [];
+        foreach ($bins as $b) {
+            $out[$b] = true;
+        }
+
+        return $out;
+    }
+}

+ 18 - 0
api/src/Domain/Reputation/EffectiveStatus.php

@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Reputation;
+
+/**
+ * Resolved override-vs-score state for one IP. Order matters — when
+ * multiple states overlap, the upper member wins (allowlist beats
+ * manual block beats score; SPEC §5).
+ */
+enum EffectiveStatus: string
+{
+    case Allowlisted = 'allowlisted';
+    case ManuallyBlocked = 'manually_blocked';
+    case Scored = 'scored';
+    case Clean = 'clean';
+}

+ 38 - 0
api/src/Domain/Reputation/EffectiveStatusService.php

@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Reputation;
+
+use App\Domain\Ip\IpAddress;
+use App\Infrastructure\Reputation\CidrEvaluatorFactory;
+
+/**
+ * Resolves an IP to one of `allowlisted | manually_blocked | scored | clean`.
+ *
+ * SPEC §5 resolution order — allowlist wins over everything; manual block
+ * wins over scored; score-vs-policy is M07 territory and currently maps to
+ * `Clean` for any non-overridden IP. The caller (admin "ip detail" or
+ * distribution endpoint in M07) renders accordingly.
+ */
+final class EffectiveStatusService
+{
+    public function __construct(private readonly CidrEvaluatorFactory $evaluatorFactory)
+    {
+    }
+
+    public function forIp(IpAddress $ip): EffectiveStatus
+    {
+        $evaluator = $this->evaluatorFactory->get();
+
+        if ($evaluator->isAllowlisted($ip)) {
+            return EffectiveStatus::Allowlisted;
+        }
+        if ($evaluator->isManuallyBlocked($ip)) {
+            return EffectiveStatus::ManuallyBlocked;
+        }
+
+        // M07 will replace this with score-vs-policy evaluation.
+        return EffectiveStatus::Clean;
+    }
+}

+ 176 - 0
api/src/Infrastructure/Allowlist/AllowlistRepository.php

@@ -0,0 +1,176 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Allowlist;
+
+use App\Domain\Allowlist\AllowlistEntry;
+use App\Domain\Ip\Cidr;
+use App\Domain\Ip\IpAddress;
+use App\Infrastructure\Db\RepositoryBase;
+use DateTimeImmutable;
+use DateTimeZone;
+use Doctrine\DBAL\ParameterType;
+
+/**
+ * DBAL gateway for `allowlist`. Mirrors ManualBlockRepository minus the
+ * `expires_at` column — allowlist entries don't expire on a clock; admins
+ * remove them explicitly.
+ */
+final class AllowlistRepository extends RepositoryBase
+{
+    public function findById(int $id): ?AllowlistEntry
+    {
+        /** @var array<string, mixed>|false $row */
+        $row = $this->connection()->fetchAssociative(
+            'SELECT id, kind, ip_bin, network_bin, prefix_length, reason, created_at, created_by_user_id '
+            . 'FROM allowlist WHERE id = :id',
+            ['id' => $id]
+        );
+
+        return $row === false ? null : self::hydrate($row);
+    }
+
+    /**
+     * @param array{kind?: ?string} $filters
+     * @return list<AllowlistEntry>
+     */
+    public function list(?int $limit, ?int $offset, array $filters = []): array
+    {
+        $sql = 'SELECT id, kind, ip_bin, network_bin, prefix_length, reason, created_at, created_by_user_id '
+            . 'FROM allowlist';
+        $params = [];
+        $types = [];
+        $where = [];
+
+        if (isset($filters['kind']) && $filters['kind'] !== null) {
+            $where[] = 'kind = :kind';
+            $params['kind'] = $filters['kind'];
+        }
+        if ($where !== []) {
+            $sql .= ' WHERE ' . implode(' AND ', $where);
+        }
+        $sql .= ' ORDER BY id DESC';
+
+        if ($limit !== null) {
+            $sql .= ' LIMIT :limit';
+            $params['limit'] = $limit;
+            $types['limit'] = ParameterType::INTEGER;
+            if ($offset !== null) {
+                $sql .= ' OFFSET :offset';
+                $params['offset'] = $offset;
+                $types['offset'] = ParameterType::INTEGER;
+            }
+        }
+
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection()->fetchAllAssociative($sql, $params, $types);
+
+        return array_map(self::hydrate(...), $rows);
+    }
+
+    public function count(?string $kindFilter = null): int
+    {
+        if ($kindFilter !== null) {
+            return (int) $this->connection()->fetchOne(
+                'SELECT COUNT(*) FROM allowlist WHERE kind = :kind',
+                ['kind' => $kindFilter]
+            );
+        }
+
+        return (int) $this->connection()->fetchOne('SELECT COUNT(*) FROM allowlist');
+    }
+
+    public function createIp(IpAddress $ip, ?string $reason, ?int $createdByUserId): int
+    {
+        $this->insertRow('allowlist', [
+            'kind' => AllowlistEntry::KIND_IP,
+            'ip_bin' => $ip->binary(),
+            'network_bin' => null,
+            'prefix_length' => null,
+            'reason' => $reason,
+            'created_by_user_id' => $createdByUserId,
+        ], ['ip_bin' => ParameterType::LARGE_OBJECT]);
+
+        return (int) $this->connection()->lastInsertId();
+    }
+
+    public function createSubnet(Cidr $cidr, ?string $reason, ?int $createdByUserId): int
+    {
+        $this->insertRow('allowlist', [
+            'kind' => AllowlistEntry::KIND_SUBNET,
+            'ip_bin' => null,
+            'network_bin' => $cidr->network(),
+            'prefix_length' => $cidr->prefixLength(),
+            'reason' => $reason,
+            'created_by_user_id' => $createdByUserId,
+        ], ['network_bin' => ParameterType::LARGE_OBJECT]);
+
+        return (int) $this->connection()->lastInsertId();
+    }
+
+    public function delete(int $id): void
+    {
+        $this->connection()->executeStatement('DELETE FROM allowlist WHERE id = :id', ['id' => $id]);
+    }
+
+    /**
+     * @return list<AllowlistEntry>
+     */
+    public function listSubnets(): array
+    {
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection()->fetchAllAssociative(
+            'SELECT id, kind, ip_bin, network_bin, prefix_length, reason, created_at, created_by_user_id '
+            . 'FROM allowlist WHERE kind = :kind ORDER BY id',
+            ['kind' => AllowlistEntry::KIND_SUBNET]
+        );
+
+        return array_map(self::hydrate(...), $rows);
+    }
+
+    /**
+     * @return list<AllowlistEntry>
+     */
+    public function listIps(): array
+    {
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection()->fetchAllAssociative(
+            'SELECT id, kind, ip_bin, network_bin, prefix_length, reason, created_at, created_by_user_id '
+            . 'FROM allowlist WHERE kind = :kind ORDER BY id',
+            ['kind' => AllowlistEntry::KIND_IP]
+        );
+
+        return array_map(self::hydrate(...), $rows);
+    }
+
+    /**
+     * @param array<string, mixed> $row
+     */
+    private static function hydrate(array $row): AllowlistEntry
+    {
+        $tz = new DateTimeZone('UTC');
+        $createdAt = isset($row['created_at']) && $row['created_at'] !== null
+            ? new DateTimeImmutable((string) $row['created_at'], $tz)
+            : new DateTimeImmutable('now', $tz);
+
+        $kind = (string) $row['kind'];
+        $ip = null;
+        $cidr = null;
+        if ($kind === AllowlistEntry::KIND_IP && $row['ip_bin'] !== null) {
+            $ip = IpAddress::fromBinary((string) $row['ip_bin']);
+        } elseif ($kind === AllowlistEntry::KIND_SUBNET && $row['network_bin'] !== null) {
+            $cidr = Cidr::fromBinary((string) $row['network_bin'], (int) $row['prefix_length']);
+        }
+
+        return new AllowlistEntry(
+            id: (int) $row['id'],
+            kind: $kind,
+            ip: $ip,
+            cidr: $cidr,
+            reason: $row['reason'] !== null ? (string) $row['reason'] : null,
+            createdAt: $createdAt,
+            createdByUserId: $row['created_by_user_id'] !== null ? (int) $row['created_by_user_id'] : null,
+        );
+    }
+}

+ 211 - 0
api/src/Infrastructure/ManualBlock/ManualBlockRepository.php

@@ -0,0 +1,211 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\ManualBlock;
+
+use App\Domain\Ip\Cidr;
+use App\Domain\Ip\IpAddress;
+use App\Domain\ManualBlock\ManualBlock;
+use App\Infrastructure\Db\RepositoryBase;
+use DateTimeImmutable;
+use DateTimeZone;
+use Doctrine\DBAL\ParameterType;
+
+/**
+ * DBAL gateway for `manual_blocks`. The table holds both single-IP and CIDR
+ * subnet entries in one shape (per SPEC §4); `kind` decides which set of
+ * columns is populated.
+ *
+ * No update path: an admin removes and re-adds. Soft-delete is unnecessary
+ * — manual_blocks is itself the override layer, not a long-lived audit
+ * surface (audit lives in `audit_log`, M12).
+ */
+final class ManualBlockRepository extends RepositoryBase
+{
+    public function findById(int $id): ?ManualBlock
+    {
+        /** @var array<string, mixed>|false $row */
+        $row = $this->connection()->fetchAssociative(
+            'SELECT id, kind, ip_bin, network_bin, prefix_length, reason, expires_at, created_at, created_by_user_id '
+            . 'FROM manual_blocks WHERE id = :id',
+            ['id' => $id]
+        );
+
+        return $row === false ? null : self::hydrate($row);
+    }
+
+    /**
+     * @param array{kind?: ?string} $filters
+     * @return list<ManualBlock>
+     */
+    public function list(?int $limit, ?int $offset, array $filters = []): array
+    {
+        $sql = 'SELECT id, kind, ip_bin, network_bin, prefix_length, reason, expires_at, created_at, created_by_user_id '
+            . 'FROM manual_blocks';
+        $params = [];
+        $types = [];
+        $where = [];
+
+        if (isset($filters['kind']) && $filters['kind'] !== null) {
+            $where[] = 'kind = :kind';
+            $params['kind'] = $filters['kind'];
+        }
+        if ($where !== []) {
+            $sql .= ' WHERE ' . implode(' AND ', $where);
+        }
+        $sql .= ' ORDER BY id DESC';
+
+        if ($limit !== null) {
+            $sql .= ' LIMIT :limit';
+            $params['limit'] = $limit;
+            $types['limit'] = ParameterType::INTEGER;
+            if ($offset !== null) {
+                $sql .= ' OFFSET :offset';
+                $params['offset'] = $offset;
+                $types['offset'] = ParameterType::INTEGER;
+            }
+        }
+
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection()->fetchAllAssociative($sql, $params, $types);
+
+        return array_map(self::hydrate(...), $rows);
+    }
+
+    public function count(?string $kindFilter = null): int
+    {
+        if ($kindFilter !== null) {
+            return (int) $this->connection()->fetchOne(
+                'SELECT COUNT(*) FROM manual_blocks WHERE kind = :kind',
+                ['kind' => $kindFilter]
+            );
+        }
+
+        return (int) $this->connection()->fetchOne('SELECT COUNT(*) FROM manual_blocks');
+    }
+
+    public function createIp(
+        IpAddress $ip,
+        ?string $reason,
+        ?DateTimeImmutable $expiresAt,
+        ?int $createdByUserId,
+    ): int {
+        $this->insertRow('manual_blocks', [
+            'kind' => ManualBlock::KIND_IP,
+            'ip_bin' => $ip->binary(),
+            'network_bin' => null,
+            'prefix_length' => null,
+            'reason' => $reason,
+            'expires_at' => $expiresAt?->format('Y-m-d H:i:s'),
+            'created_by_user_id' => $createdByUserId,
+        ], ['ip_bin' => ParameterType::LARGE_OBJECT]);
+
+        return (int) $this->connection()->lastInsertId();
+    }
+
+    public function createSubnet(
+        Cidr $cidr,
+        ?string $reason,
+        ?DateTimeImmutable $expiresAt,
+        ?int $createdByUserId,
+    ): int {
+        $this->insertRow('manual_blocks', [
+            'kind' => ManualBlock::KIND_SUBNET,
+            'ip_bin' => null,
+            'network_bin' => $cidr->network(),
+            'prefix_length' => $cidr->prefixLength(),
+            'reason' => $reason,
+            'expires_at' => $expiresAt?->format('Y-m-d H:i:s'),
+            'created_by_user_id' => $createdByUserId,
+        ], ['network_bin' => ParameterType::LARGE_OBJECT]);
+
+        return (int) $this->connection()->lastInsertId();
+    }
+
+    public function delete(int $id): void
+    {
+        $this->connection()->executeStatement('DELETE FROM manual_blocks WHERE id = :id', ['id' => $id]);
+    }
+
+    /**
+     * IDs of blocks whose `expires_at` has passed. Used by a future cleanup
+     * job; the caller decides whether to delete or just flag.
+     *
+     * @return list<int>
+     */
+    public function findExpired(DateTimeImmutable $now): array
+    {
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection()->fetchAllAssociative(
+            'SELECT id FROM manual_blocks WHERE expires_at IS NOT NULL AND expires_at < :now',
+            ['now' => $now->format('Y-m-d H:i:s')]
+        );
+
+        return array_map(static fn (array $r): int => (int) $r['id'], $rows);
+    }
+
+    /**
+     * @return list<ManualBlock>
+     */
+    public function listSubnets(): array
+    {
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection()->fetchAllAssociative(
+            'SELECT id, kind, ip_bin, network_bin, prefix_length, reason, expires_at, created_at, created_by_user_id '
+            . 'FROM manual_blocks WHERE kind = :kind ORDER BY id',
+            ['kind' => ManualBlock::KIND_SUBNET]
+        );
+
+        return array_map(self::hydrate(...), $rows);
+    }
+
+    /**
+     * @return list<ManualBlock>
+     */
+    public function listIps(): array
+    {
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection()->fetchAllAssociative(
+            'SELECT id, kind, ip_bin, network_bin, prefix_length, reason, expires_at, created_at, created_by_user_id '
+            . 'FROM manual_blocks WHERE kind = :kind ORDER BY id',
+            ['kind' => ManualBlock::KIND_IP]
+        );
+
+        return array_map(self::hydrate(...), $rows);
+    }
+
+    /**
+     * @param array<string, mixed> $row
+     */
+    private static function hydrate(array $row): ManualBlock
+    {
+        $tz = new DateTimeZone('UTC');
+        $createdAt = isset($row['created_at']) && $row['created_at'] !== null
+            ? new DateTimeImmutable((string) $row['created_at'], $tz)
+            : new DateTimeImmutable('now', $tz);
+        $expiresAt = isset($row['expires_at']) && $row['expires_at'] !== null
+            ? new DateTimeImmutable((string) $row['expires_at'], $tz)
+            : null;
+
+        $kind = (string) $row['kind'];
+        $ip = null;
+        $cidr = null;
+        if ($kind === ManualBlock::KIND_IP && $row['ip_bin'] !== null) {
+            $ip = IpAddress::fromBinary((string) $row['ip_bin']);
+        } elseif ($kind === ManualBlock::KIND_SUBNET && $row['network_bin'] !== null) {
+            $cidr = Cidr::fromBinary((string) $row['network_bin'], (int) $row['prefix_length']);
+        }
+
+        return new ManualBlock(
+            id: (int) $row['id'],
+            kind: $kind,
+            ip: $ip,
+            cidr: $cidr,
+            reason: $row['reason'] !== null ? (string) $row['reason'] : null,
+            expiresAt: $expiresAt,
+            createdAt: $createdAt,
+            createdByUserId: $row['created_by_user_id'] !== null ? (int) $row['created_by_user_id'] : null,
+        );
+    }
+}

+ 170 - 0
api/src/Infrastructure/Reputation/CidrEvaluatorFactory.php

@@ -0,0 +1,170 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Reputation;
+
+use App\Domain\Ip\IpAddress;
+use App\Domain\Reputation\CidrEvaluator;
+use App\Domain\Time\Clock;
+use App\Infrastructure\Allowlist\AllowlistRepository;
+use App\Infrastructure\ManualBlock\ManualBlockRepository;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Lazily-built, in-process cached `CidrEvaluator`.
+ *
+ * SPEC §M06: snapshot the current manual_blocks + allowlist tables and
+ * cache the evaluator for `CIDR_EVALUATOR_TTL_SECONDS` (default 60s) so
+ * the distribution endpoint and IP-detail page don't re-query the DB on
+ * every request. Mutation endpoints call `invalidate()` after a successful
+ * write to make changes visible immediately on the same replica.
+ *
+ * Multi-replica caveat: the cache is per-process. Other replicas keep
+ * their stale snapshot until their own TTL elapses (see PROGRESS.md M06).
+ */
+class CidrEvaluatorFactory
+{
+    private ?CidrEvaluator $cached = null;
+    private ?float $expiresAt = null;
+    /**
+     * Toggled to true if the most recent build observed an IP/subnet
+     * matching both lists. Logged once, then re-checked on every rebuild.
+     */
+    private bool $overlapWarned = false;
+
+    public function __construct(
+        private readonly ManualBlockRepository $manualBlocks,
+        private readonly AllowlistRepository $allowlist,
+        private readonly Clock $clock,
+        private readonly LoggerInterface $logger,
+        private readonly int $ttlSeconds = 60,
+    ) {
+    }
+
+    public function get(): CidrEvaluator
+    {
+        $now = (float) $this->clock->now()->getTimestamp();
+        if ($this->cached !== null && $this->expiresAt !== null && $now < $this->expiresAt) {
+            return $this->cached;
+        }
+
+        $manualIps = [];
+        foreach ($this->manualBlocks->listIps() as $entry) {
+            if ($entry->ip !== null) {
+                $manualIps[] = $entry->ip->binary();
+            }
+        }
+        $manualSubnets = [];
+        foreach ($this->manualBlocks->listSubnets() as $entry) {
+            if ($entry->cidr !== null) {
+                $manualSubnets[] = $entry->cidr;
+            }
+        }
+
+        $allowIps = [];
+        foreach ($this->allowlist->listIps() as $entry) {
+            if ($entry->ip !== null) {
+                $allowIps[] = $entry->ip->binary();
+            }
+        }
+        $allowSubnets = [];
+        foreach ($this->allowlist->listSubnets() as $entry) {
+            if ($entry->cidr !== null) {
+                $allowSubnets[] = $entry->cidr;
+            }
+        }
+
+        $evaluator = new CidrEvaluator($manualIps, $manualSubnets, $allowIps, $allowSubnets);
+        $this->warnOnOverlap($allowIps, $manualIps, $allowSubnets, $manualSubnets);
+
+        $this->cached = $evaluator;
+        $this->expiresAt = $now + $this->ttlSeconds;
+
+        return $evaluator;
+    }
+
+    public function invalidate(): void
+    {
+        $this->cached = null;
+        $this->expiresAt = null;
+        $this->overlapWarned = false;
+    }
+
+    /**
+     * SPEC §M06: when an IP or subnet appears on both lists, allowlist
+     * still wins — but we log a WARNING so the operator knows the manual
+     * block is dead-letter. We log the first overlap we find per cache
+     * cycle to avoid log spam on large overlapping lists.
+     *
+     * @param list<string>            $allowIpBins
+     * @param list<string>            $manualIpBins
+     * @param list<\App\Domain\Ip\Cidr> $allowSubnets
+     * @param list<\App\Domain\Ip\Cidr> $manualSubnets
+     */
+    private function warnOnOverlap(
+        array $allowIpBins,
+        array $manualIpBins,
+        array $allowSubnets,
+        array $manualSubnets,
+    ): void {
+        if ($this->overlapWarned) {
+            return;
+        }
+
+        $allowIpSet = [];
+        foreach ($allowIpBins as $b) {
+            $allowIpSet[$b] = true;
+        }
+        foreach ($manualIpBins as $b) {
+            if (isset($allowIpSet[$b])) {
+                $ip = IpAddress::fromBinary($b);
+                $this->logger->warning(
+                    sprintf(
+                        'IP %s is on both allowlist and manual block list; allowlist takes precedence',
+                        $ip->text()
+                    )
+                );
+                $this->overlapWarned = true;
+
+                return;
+            }
+        }
+
+        // Cross-check: any IP on one list that's *contained* in a subnet
+        // on the other counts as overlap too. Cheap because the lists are
+        // small per SPEC §15.
+        foreach ($manualIpBins as $b) {
+            $ip = IpAddress::fromBinary($b);
+            foreach ($allowSubnets as $cidr) {
+                if ($cidr->contains($ip)) {
+                    $this->logger->warning(
+                        sprintf(
+                            'IP %s is on both allowlist and manual block list; allowlist takes precedence',
+                            $ip->text()
+                        )
+                    );
+                    $this->overlapWarned = true;
+
+                    return;
+                }
+            }
+        }
+        foreach ($allowIpBins as $b) {
+            $ip = IpAddress::fromBinary($b);
+            foreach ($manualSubnets as $cidr) {
+                if ($cidr->contains($ip)) {
+                    $this->logger->warning(
+                        sprintf(
+                            'IP %s is on both allowlist and manual block list; allowlist takes precedence',
+                            $ip->text()
+                        )
+                    );
+                    $this->overlapWarned = true;
+
+                    return;
+                }
+            }
+        }
+    }
+}

+ 142 - 0
api/tests/Integration/Admin/AllowlistControllerTest.php

@@ -0,0 +1,142 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Admin;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use App\Domain\Ip\IpAddress;
+use App\Infrastructure\Reputation\CidrEvaluatorFactory;
+use App\Tests\Integration\Support\AppTestCase;
+use Monolog\Handler\TestHandler;
+use Monolog\Level;
+use Monolog\Logger;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Covers SPEC §6 allowlist CRUD plus the allowlist-precedence warning
+ * logged when an IP appears on both lists. Mirrors the manual-blocks
+ * suite minus `expires_at`.
+ */
+final class AllowlistControllerTest extends AppTestCase
+{
+    public function testOperatorCanCreateIpAllowlistEntry(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Operator);
+        $response = $this->request(
+            'POST',
+            '/api/v1/admin/allowlist',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['kind' => 'ip', 'ip' => '198.51.100.5', 'reason' => 'monitor']) ?: null,
+        );
+        self::assertSame(201, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertSame('198.51.100.5', $body['ip']);
+    }
+
+    public function testOperatorCanCreateSubnetAllowlistEntry(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Operator);
+        $response = $this->request(
+            'POST',
+            '/api/v1/admin/allowlist',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['kind' => 'subnet', 'cidr' => '10.0.0.0/8', 'reason' => 'private']) ?: null,
+        );
+        self::assertSame(201, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertSame('10.0.0.0/8', $body['cidr']);
+        self::assertSame(8, $body['prefix_length']);
+    }
+
+    public function testViewerCannotCreate(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $response = $this->request(
+            'POST',
+            '/api/v1/admin/allowlist',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['kind' => 'ip', 'ip' => '1.2.3.4']) ?: null,
+        );
+        self::assertSame(403, $response->getStatusCode());
+    }
+
+    public function testAllowlistedIpIsAllowlistedRegardlessOfManualBlock(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+
+        // Same IP on both lists — log should warn; evaluator reports both
+        // memberships independently; SPEC precedence is the
+        // EffectiveStatusService's job.
+        $this->request(
+            'POST',
+            '/api/v1/admin/manual-blocks',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['kind' => 'ip', 'ip' => '198.51.100.5']) ?: null,
+        );
+        $this->request(
+            'POST',
+            '/api/v1/admin/allowlist',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['kind' => 'ip', 'ip' => '198.51.100.5']) ?: null,
+        );
+
+        // Inject a TestHandler-backed logger before the evaluator builds.
+        $logger = new Logger('test');
+        $logger->pushHandler($handler = new TestHandler(Level::Warning));
+        /** @var \DI\Container $container */
+        $container = $this->container;
+        $container->set(LoggerInterface::class, $logger);
+        // Rebuild the factory with the new logger so the warning is captured.
+        $container->set(CidrEvaluatorFactory::class, new CidrEvaluatorFactory(
+            $container->get(\App\Infrastructure\ManualBlock\ManualBlockRepository::class),
+            $container->get(\App\Infrastructure\Allowlist\AllowlistRepository::class),
+            $container->get(\App\Domain\Time\Clock::class),
+            $logger,
+            0,
+        ));
+
+        /** @var CidrEvaluatorFactory $factory */
+        $factory = $this->container->get(CidrEvaluatorFactory::class);
+        $evaluator = $factory->get();
+
+        $ip = IpAddress::fromString('198.51.100.5');
+        self::assertTrue($evaluator->isAllowlisted($ip));
+        self::assertTrue($evaluator->isManuallyBlocked($ip));
+
+        $found = false;
+        foreach ($handler->getRecords() as $record) {
+            if (str_contains($record->message, 'allowlist takes precedence')) {
+                $found = true;
+                break;
+            }
+        }
+        self::assertTrue($found, 'expected overlap warning to be logged');
+    }
+
+    public function testDeleteInvalidatesEvaluator(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $created = $this->request(
+            'POST',
+            '/api/v1/admin/allowlist',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['kind' => 'subnet', 'cidr' => '10.0.0.0/8']) ?: null,
+        );
+        $id = (int) $this->decode($created)['id'];
+
+        /** @var CidrEvaluatorFactory $factory */
+        $factory = $this->container->get(CidrEvaluatorFactory::class);
+        self::assertTrue($factory->get()->isAllowlisted(IpAddress::fromString('10.0.0.1')));
+
+        $delete = $this->request(
+            'DELETE',
+            "/api/v1/admin/allowlist/{$id}",
+            ['Authorization' => 'Bearer ' . $token],
+        );
+        self::assertSame(204, $delete->getStatusCode());
+
+        self::assertFalse($factory->get()->isAllowlisted(IpAddress::fromString('10.0.0.1')));
+    }
+}

+ 200 - 0
api/tests/Integration/Admin/ManualBlocksControllerTest.php

@@ -0,0 +1,200 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Admin;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use App\Domain\Ip\IpAddress;
+use App\Infrastructure\Reputation\CidrEvaluatorFactory;
+use App\Tests\Integration\Support\AppTestCase;
+
+/**
+ * Covers SPEC §6: manual blocks CRUD with split RBAC (Viewer reads, Operator
+ * writes) and CIDR canonicalization. Each test boots a clean DB and Slim
+ * app — no shared state.
+ */
+final class ManualBlocksControllerTest extends AppTestCase
+{
+    public function testViewerCanList(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $response = $this->request('GET', '/api/v1/admin/manual-blocks', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertArrayHasKey('items', $body);
+        self::assertSame(0, $body['total']);
+    }
+
+    public function testViewerCannotCreate(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $response = $this->request(
+            'POST',
+            '/api/v1/admin/manual-blocks',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['kind' => 'ip', 'ip' => '203.0.113.5', 'reason' => 'x']) ?: null,
+        );
+        self::assertSame(403, $response->getStatusCode());
+    }
+
+    public function testOperatorCanCreateIpBlock(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Operator);
+        $response = $this->request(
+            'POST',
+            '/api/v1/admin/manual-blocks',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode([
+                'kind' => 'ip',
+                'ip' => '198.51.100.5',
+                'reason' => 'manual block test',
+            ]) ?: null,
+        );
+        self::assertSame(201, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertSame('ip', $body['kind']);
+        self::assertSame('198.51.100.5', $body['ip']);
+        self::assertNull($body['cidr']);
+        self::assertArrayNotHasKey('normalized_from', $body);
+    }
+
+    public function testOperatorCanCreateCanonicalSubnetBlock(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Operator);
+        $response = $this->request(
+            'POST',
+            '/api/v1/admin/manual-blocks',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['kind' => 'subnet', 'cidr' => '198.51.100.0/24', 'reason' => 'subnet']) ?: null,
+        );
+        self::assertSame(201, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertSame('subnet', $body['kind']);
+        self::assertSame('198.51.100.0/24', $body['cidr']);
+        self::assertSame(24, $body['prefix_length']);
+        self::assertArrayNotHasKey('normalized_from', $body);
+    }
+
+    public function testNonCanonicalSubnetIsAutoNormalizedAndAnnounced(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Operator);
+        $response = $this->request(
+            'POST',
+            '/api/v1/admin/manual-blocks',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['kind' => 'subnet', 'cidr' => '203.0.113.55/24', 'reason' => 'non-canonical']) ?: null,
+        );
+        self::assertSame(201, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertSame('203.0.113.0/24', $body['cidr']);
+        self::assertSame('203.0.113.55/24', $body['normalized_from']);
+    }
+
+    public function testV6SubnetIsAccepted(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Operator);
+        $response = $this->request(
+            'POST',
+            '/api/v1/admin/manual-blocks',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['kind' => 'subnet', 'cidr' => '2001:db8::/32', 'reason' => 'v6']) ?: null,
+        );
+        self::assertSame(201, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertSame('2001:db8::/32', $body['cidr']);
+        self::assertSame(32, $body['prefix_length']);
+    }
+
+    public function testRejectsIpKindWithCidr(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Operator);
+        $response = $this->request(
+            'POST',
+            '/api/v1/admin/manual-blocks',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['kind' => 'ip', 'ip' => '1.2.3.4', 'cidr' => '1.2.3.0/24']) ?: null,
+        );
+        self::assertSame(400, $response->getStatusCode());
+    }
+
+    public function testRejectsBadIp(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Operator);
+        $response = $this->request(
+            'POST',
+            '/api/v1/admin/manual-blocks',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['kind' => 'ip', 'ip' => 'not-an-ip']) ?: null,
+        );
+        self::assertSame(400, $response->getStatusCode());
+    }
+
+    public function testListFiltersByKind(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $this->createBlock($token, ['kind' => 'ip', 'ip' => '10.0.0.1']);
+        $this->createBlock($token, ['kind' => 'subnet', 'cidr' => '10.0.0.0/8']);
+
+        $response = $this->request('GET', '/api/v1/admin/manual-blocks?kind=subnet', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        $body = $this->decode($response);
+        self::assertSame(1, $body['total']);
+        self::assertSame('subnet', $body['items'][0]['kind']);
+    }
+
+    public function testDeleteRemovesEntryAndInvalidatesEvaluator(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $payload = $this->decode($this->createBlock($token, ['kind' => 'subnet', 'cidr' => '10.0.0.0/16']));
+        $id = (int) $payload['id'];
+
+        /** @var CidrEvaluatorFactory $factory */
+        $factory = $this->container->get(CidrEvaluatorFactory::class);
+        $evaluator = $factory->get();
+        self::assertCount(1, $evaluator->manualBlockedSubnets());
+        self::assertTrue($evaluator->isManuallyBlocked(IpAddress::fromString('10.0.0.5')));
+
+        $response = $this->request(
+            'DELETE',
+            "/api/v1/admin/manual-blocks/{$id}",
+            ['Authorization' => 'Bearer ' . $token],
+        );
+        self::assertSame(204, $response->getStatusCode());
+
+        $evaluator2 = $factory->get();
+        self::assertNotSame($evaluator, $evaluator2, 'evaluator should be rebuilt after invalidate()');
+        self::assertSame([], $evaluator2->manualBlockedSubnets());
+        self::assertFalse($evaluator2->isManuallyBlocked(IpAddress::fromString('10.0.0.5')));
+    }
+
+    public function testEvaluatorHandlesV4Slash16AsSingleCidr(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $this->createBlock($token, ['kind' => 'subnet', 'cidr' => '10.10.0.0/16']);
+
+        /** @var CidrEvaluatorFactory $factory */
+        $factory = $this->container->get(CidrEvaluatorFactory::class);
+        $subnets = $factory->get()->manualBlockedSubnets();
+        self::assertCount(1, $subnets);
+        self::assertSame('10.10.0.0/16', $subnets[0]->text());
+    }
+
+    /**
+     * @param array<string, mixed> $payload
+     */
+    private function createBlock(string $token, array $payload): \Psr\Http\Message\ResponseInterface
+    {
+        return $this->request(
+            'POST',
+            '/api/v1/admin/manual-blocks',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode($payload) ?: null,
+        );
+    }
+
+}

+ 1 - 0
api/tests/Integration/Support/AppTestCase.php

@@ -87,6 +87,7 @@ abstract class AppTestCase extends TestCase
             'oidc_default_role' => Role::Viewer,
             'score_hard_cutoff_days' => 365,
             'rate_limit_per_second' => 1000,
+            'cidr_evaluator_ttl_seconds' => 0,
         ];
 
         $this->container = Container::build($settings);

+ 98 - 0
api/tests/Unit/Reputation/CidrEvaluatorTest.php

@@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Reputation;
+
+use App\Domain\Ip\Cidr;
+use App\Domain\Ip\IpAddress;
+use App\Domain\Reputation\CidrEvaluator;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Unit-level coverage of the in-memory containment math: v4, v6, IPv4
+ * mapped into v6, and the empty-evaluator no-op shape.
+ */
+final class CidrEvaluatorTest extends TestCase
+{
+    public function testEmptyEvaluatorMatchesNothing(): void
+    {
+        $evaluator = new CidrEvaluator([], [], [], []);
+        $ip = IpAddress::fromString('203.0.113.42');
+
+        self::assertFalse($evaluator->isAllowlisted($ip));
+        self::assertFalse($evaluator->isManuallyBlocked($ip));
+        self::assertSame([], $evaluator->manualBlockedSubnets());
+        self::assertSame([], $evaluator->allowlistedSubnets());
+    }
+
+    public function testSingleIpManualBlockMatches(): void
+    {
+        $bin = IpAddress::fromString('203.0.113.42')->binary();
+        $evaluator = new CidrEvaluator([$bin], [], [], []);
+
+        self::assertTrue($evaluator->isManuallyBlocked(IpAddress::fromString('203.0.113.42')));
+        self::assertFalse($evaluator->isManuallyBlocked(IpAddress::fromString('203.0.113.43')));
+    }
+
+    public function testV4SubnetContainsHostV4(): void
+    {
+        $cidr = Cidr::fromString('203.0.113.0/24');
+        $evaluator = new CidrEvaluator([], [$cidr], [], []);
+
+        self::assertTrue($evaluator->isManuallyBlocked(IpAddress::fromString('203.0.113.42')));
+        self::assertFalse($evaluator->isManuallyBlocked(IpAddress::fromString('203.0.114.42')));
+    }
+
+    public function testV6SubnetContainsV6Host(): void
+    {
+        $cidr = Cidr::fromString('2001:db8::/32');
+        $evaluator = new CidrEvaluator([], [$cidr], [], []);
+
+        self::assertTrue($evaluator->isManuallyBlocked(IpAddress::fromString('2001:db8::1')));
+        self::assertTrue($evaluator->isManuallyBlocked(IpAddress::fromString('2001:db8:abcd::42')));
+        self::assertFalse($evaluator->isManuallyBlocked(IpAddress::fromString('2001:db9::1')));
+    }
+
+    public function testIpv4MappedV6RoundTripsAsV4(): void
+    {
+        // Per IpAddress: ::ffff:203.0.113.42 normalizes to the v4-mapped binary
+        // of 203.0.113.42 — i.e. it should match a v4 /24 manual block.
+        $cidr = Cidr::fromString('203.0.113.0/24');
+        $evaluator = new CidrEvaluator([], [$cidr], [], []);
+
+        $ip = IpAddress::fromString('::ffff:203.0.113.42');
+        self::assertTrue($evaluator->isManuallyBlocked($ip));
+    }
+
+    public function testAllowlistAndBlockOverlapAreIndependent(): void
+    {
+        // Both lists "see" the same IP — the evaluator just reports each
+        // independently. SPEC §5 precedence is the responsibility of the
+        // caller (EffectiveStatusService).
+        $bin = IpAddress::fromString('198.51.100.5')->binary();
+        $evaluator = new CidrEvaluator([$bin], [], [$bin], []);
+
+        $ip = IpAddress::fromString('198.51.100.5');
+        self::assertTrue($evaluator->isManuallyBlocked($ip));
+        self::assertTrue($evaluator->isAllowlisted($ip));
+    }
+
+    public function testManualBlockedSubnetsExposesSingleEntryForLargeCidr(): void
+    {
+        $cidr = Cidr::fromString('10.0.0.0/16');
+        $evaluator = new CidrEvaluator([], [$cidr], [], []);
+
+        self::assertCount(1, $evaluator->manualBlockedSubnets());
+        self::assertSame('10.0.0.0/16', $evaluator->manualBlockedSubnets()[0]->text());
+    }
+
+    public function testAllowlistedSubnetsExposesEntries(): void
+    {
+        $a = Cidr::fromString('10.0.0.0/8');
+        $b = Cidr::fromString('192.168.0.0/16');
+        $evaluator = new CidrEvaluator([], [], [], [$a, $b]);
+
+        self::assertCount(2, $evaluator->allowlistedSubnets());
+    }
+}

+ 99 - 0
api/tests/Unit/Reputation/EffectiveStatusServiceTest.php

@@ -0,0 +1,99 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Reputation;
+
+use App\Domain\Ip\Cidr;
+use App\Domain\Ip\IpAddress;
+use App\Domain\Reputation\CidrEvaluator;
+use App\Domain\Reputation\EffectiveStatus;
+use App\Domain\Reputation\EffectiveStatusService;
+use App\Infrastructure\Reputation\CidrEvaluatorFactory;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Locks the SPEC §5 precedence: allowlist > manual block > scored > clean.
+ * Score-vs-policy lands in M07; until then, anything not on a list is
+ * `Clean`.
+ */
+final class EffectiveStatusServiceTest extends TestCase
+{
+    public function testAllowlistWinsOverManualBlock(): void
+    {
+        $bin = IpAddress::fromString('198.51.100.5')->binary();
+        $factory = $this->factoryReturning(new CidrEvaluator(
+            manualIpBins: [$bin],
+            manualSubnets: [],
+            allowlistIpBins: [$bin],
+            allowlistSubnets: [],
+        ));
+
+        $service = new EffectiveStatusService($factory);
+        self::assertSame(
+            EffectiveStatus::Allowlisted,
+            $service->forIp(IpAddress::fromString('198.51.100.5'))
+        );
+    }
+
+    public function testManualBlockReturnedWhenNotAllowlisted(): void
+    {
+        $bin = IpAddress::fromString('203.0.113.42')->binary();
+        $factory = $this->factoryReturning(new CidrEvaluator([$bin], [], [], []));
+
+        $service = new EffectiveStatusService($factory);
+        self::assertSame(
+            EffectiveStatus::ManuallyBlocked,
+            $service->forIp(IpAddress::fromString('203.0.113.42'))
+        );
+    }
+
+    public function testIpInsideAllowlistedSubnetEvenWithSeparateManualBlockIsAllowlisted(): void
+    {
+        $allow = Cidr::fromString('203.0.113.0/24');
+        $factory = $this->factoryReturning(new CidrEvaluator(
+            manualIpBins: [IpAddress::fromString('203.0.113.42')->binary()],
+            manualSubnets: [],
+            allowlistIpBins: [],
+            allowlistSubnets: [$allow],
+        ));
+
+        $service = new EffectiveStatusService($factory);
+        self::assertSame(
+            EffectiveStatus::Allowlisted,
+            $service->forIp(IpAddress::fromString('203.0.113.42'))
+        );
+    }
+
+    public function testCleanWhenNothingMatches(): void
+    {
+        $factory = $this->factoryReturning(new CidrEvaluator([], [], [], []));
+        $service = new EffectiveStatusService($factory);
+
+        self::assertSame(
+            EffectiveStatus::Clean,
+            $service->forIp(IpAddress::fromString('203.0.113.99'))
+        );
+    }
+
+    private function factoryReturning(CidrEvaluator $evaluator): CidrEvaluatorFactory
+    {
+        return new class ($evaluator) extends CidrEvaluatorFactory {
+            public function __construct(private readonly CidrEvaluator $fixed)
+            {
+                // Deliberately not calling parent::__construct — this stub
+                // never queries the DB.
+            }
+
+            public function get(): CidrEvaluator
+            {
+                return $this->fixed;
+            }
+
+            public function invalidate(): void
+            {
+                // no-op
+            }
+        };
+    }
+}