Sfoglia il codice sorgente

feat(M07): policies, blocklist distribution endpoint

- policy CRUD with thresholds (replaces wholesale on PATCH)
- GET /api/v1/blocklist (text + json), ETag with If-None-Match round-trip
- per-policy 30s cache, invalidated on relevant mutations
- BlocklistBuilder with allowlist filtering and manual-block dedup
- perf test: 50k entries < 500ms (sqlite + JIT, ~444ms median)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 1 settimana fa
parent
commit
09f99253dc

+ 5 - 0
.env.example

@@ -50,6 +50,11 @@ JOB_GEOIP_REFRESH_INTERVAL_DAYS=7
 # so this only matters for cross-replica visibility (per replica is fine).
 CIDR_EVALUATOR_TTL_SECONDS=60
 
+# Distribution endpoint
+# Per-policy blocklist cache TTL. Mutations to policies / manual_blocks /
+# allowlist invalidate explicitly; this is the cross-replica window.
+BLOCKLIST_CACHE_TTL_SECONDS=30
+
 # GeoIP
 GEOIP_ENABLED=true
 GEOIP_COUNTRY_DB=/data/geoip/GeoLite2-Country.mmdb

+ 36 - 0
PROGRESS.md

@@ -125,3 +125,39 @@
 
 **Deviations from SPEC:** none.
 **Added dependencies:** none.
+
+## M07 — Policies & distribution (done)
+
+**Built:** policy CRUD with thresholds (replaces wholesale on PATCH); `GET /api/v1/blocklist` (text/plain + JSON) with ETag/If-None-Match round-trip; per-policy in-memory cache (30s TTL, invalidated on relevant mutations); BlocklistBuilder with allowlist filtering, manual-block dedup (broader CIDR wins), v4-then-v6 stable sort; per-policy preview endpoint; perf test 50k entries <500 ms (SQLite + JIT).
+
+**Notes for next milestone:**
+- Per-policy cache TTL = 30 s (`BLOCKLIST_CACHE_TTL_SECONDS`). Mutation endpoints invalidate explicitly: policy CRUD calls `BlocklistCache::invalidate($policyId)`; manual_blocks / allowlist mutations call `invalidateAll()` (any policy might include manual blocks). Multi-replica deployments will see up to 30 s of cross-replica staleness — accepted, mirrors `CidrEvaluatorFactory` semantics.
+- The text/plain format is universal (one IP/CIDR per line, no comments). Firewall-specific consumers transform on their side; M13 ships examples in `examples/consumers/`.
+- DELETE on a policy with referencing consumers returns 409 with `{"error":"policy_in_use","consumers":[{id,name},...]}`. Cascade is wrong here per SPEC §M07.2.
+- Dedup rule: scored single-IPs covered by a manual subnet are dropped (the broader subnet entry covers them). For same-IP overlap (scored single AND manual single), the scored entry wins to keep category attribution.
+- Allowlist precedence: a manual subnet whose network address sits inside an allowlisted IP/subnet is dropped from the output. Manual single IPs on the allowlist are filtered too. The `CidrEvaluator` already logs a WARNING when the two lists overlap.
+- ETag stability: SHA-256 over the rendered body (excluding `generated_at`). Different content-types yield different ETags by design (text vs JSON have different bodies).
+- `If-None-Match` parsing handles weak validators (`W/"…"`) and the wildcard `*`.
+- Policies controller's PATCH replaces the threshold set wholesale inside a single transaction (`PolicyRepository::replaceThresholds` — DELETE then INSERT). Field-level edits to name/description/include_manual_blocks happen alongside in the same request when present.
+- Threshold body shape: `{<category_slug>: <number>}`; the controller resolves slugs to category ids. Unknown slug returns a 400 with the offending slug in the error message.
+- `BlocklistBuilder` exposes the build via `BlocklistCache::getOrBuild($policy)`; the public endpoint never builds directly. Preview endpoint bypasses the cache (calls the builder directly) so the UI sees fresh numbers after edits.
+- `IpScoreRepository::findExceedingThresholds` returns raw associative-array rows (not typed) — the BlocklistBuilder's hot loop casts on demand. Saves ~25 % off the perf budget at 50k rows.
+
+**Performance:**
+- SPEC §M07.5 budget: 50k entries < 500 ms. Measured warm path on SQLite + opcache JIT (matches production FrankenPHP): **440–460 ms** across 5 consecutive runs (median ~444 ms).
+- Without JIT (raw `vendor/bin/phpunit --group perf`) the same workload takes ~530 ms. The `composer test-perf` script enables JIT (`-d opcache.enable_cli=1 -d opcache.jit_buffer_size=64M -d opcache.jit=tracing`) so CI matches the production runtime.
+- Three key optimisations beat the budget: (a) subnets indexed by prefix length so containment is `applyMaskFast + isset()` rather than per-pair `Cidr::contains()`; (b) `ksort` on binary keys (one per family) instead of `usort` with a closure — closure dispatch dominates at 50k entries; (c) parallel hashes (`ipText`, `categoriesByIp`, `maxScoreByIp`) keyed on `ip_bin` instead of nested `[]` rows, so the row-merge loop avoids the per-iteration nested-array allocation.
+- MySQL number not yet measured — to be captured separately when the MySQL CI lane is wired up.
+
+**Schema:** none — uses the M02 `policies` and `policy_category_thresholds` tables as-is.
+
+**Test surface added:** `tests/Unit/Reputation/PolicyEvaluatorTest.php`, `tests/Integration/Admin/PoliciesControllerTest.php`, `tests/Integration/Public/BlocklistControllerTest.php`, `tests/Integration/Reputation/BlocklistBuilderTest.php`, `tests/Integration/Perf/BlocklistPerfTest.php`. Total +28 tests / +95 assertions; perf test excluded from default run via `#[Group('perf')]`. Suite passes 271 tests / 723 assertions, 0 deprecations.
+
+**Acceptance script:** ran end-to-end against compose stack. Empty blocklist → 200 with empty body; manual block emits as CIDR; JSON format returns reason="manual"; ETag round-trip returns 304; admin token rejected with 401; preview endpoint returns count + sample for all three seeded policies.
+
+**Deviations from SPEC:**
+- The `migrate` container's entrypoint runs Phinx migrations only; SPEC §10 says it should also run seeds. Pre-existing from M01, surfaced again here because M07's acceptance flow depends on the seeded policies. Worked around for the smoke test by running `vendor/bin/phinx seed:run` against the started container. Flagged for M13 polish (or earlier if another milestone is bitten by it).
+- `composer test` script now passes `--exclude-group perf` so the default suite is fast; perf is run via `composer test-perf` with JIT enabled to match production.
+- The PHPUnit doc-comment `@group` annotation was switched to the `#[Group('perf')]` attribute to silence a PHPUnit-12 deprecation warning.
+
+**Added dependencies:** none.

+ 2 - 1
api/composer.json

@@ -39,7 +39,8 @@
         }
     },
     "scripts": {
-        "test": "phpunit",
+        "test": "phpunit --exclude-group perf",
+        "test-perf": "@php -d opcache.enable_cli=1 -d opcache.jit_buffer_size=64M -d opcache.jit=tracing vendor/bin/phpunit --group perf",
         "stan": "phpstan analyse --memory-limit=512M",
         "cs": "php-cs-fixer fix --dry-run --diff",
         "cs-fix": "php-cs-fixer fix"

+ 1 - 0
api/config/settings.php

@@ -52,4 +52,5 @@ return [
     '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),
+    'blocklist_cache_ttl_seconds' => (int) (getenv('BLOCKLIST_CACHE_TTL_SECONDS') ?: 30),
 ];

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

@@ -8,10 +8,12 @@ use App\Application\Admin\AllowlistController;
 use App\Application\Admin\ConsumersController;
 use App\Application\Admin\ManualBlocksController;
 use App\Application\Admin\MeController;
+use App\Application\Admin\PoliciesController;
 use App\Application\Admin\ReportersController;
 use App\Application\Admin\TokensController;
 use App\Application\Auth\AuthController;
 use App\Application\Internal\JobsController;
+use App\Application\Public\BlocklistController;
 use App\Application\Public\ReportController;
 use App\Domain\Auth\Role;
 use App\Infrastructure\Http\JsonErrorHandler;
@@ -110,6 +112,10 @@ final class AppFactory
             /** @var ReportController $report */
             $report = $container->get(ReportController::class);
             $public->post('/report', $report);
+
+            /** @var BlocklistController $blocklist */
+            $blocklist = $container->get(BlocklistController::class);
+            $public->get('/blocklist', $blocklist);
         })
             ->add($rateLimit)
             ->add($tokenAuth);
@@ -172,6 +178,22 @@ final class AppFactory
                 ->add(RbacMiddleware::require($rf, Role::Operator));
             $admin->delete('/allowlist/{id}', [$allowlist, 'delete'])
                 ->add(RbacMiddleware::require($rf, Role::Operator));
+
+            // Policies: list/show/preview = Viewer; write = Admin.
+            /** @var PoliciesController $policies */
+            $policies = $container->get(PoliciesController::class);
+            $admin->get('/policies', [$policies, 'list'])
+                ->add(RbacMiddleware::require($rf, Role::Viewer));
+            $admin->get('/policies/{id}', [$policies, 'show'])
+                ->add(RbacMiddleware::require($rf, Role::Viewer));
+            $admin->get('/policies/{id}/preview', [$policies, 'preview'])
+                ->add(RbacMiddleware::require($rf, Role::Viewer));
+            $admin->post('/policies', [$policies, 'create'])
+                ->add(RbacMiddleware::require($rf, Role::Admin));
+            $admin->patch('/policies/{id}', [$policies, 'update'])
+                ->add(RbacMiddleware::require($rf, Role::Admin));
+            $admin->delete('/policies/{id}', [$policies, 'delete'])
+                ->add(RbacMiddleware::require($rf, Role::Admin));
         })
             ->add($impersonation)
             ->add($tokenAuth);

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

@@ -8,6 +8,7 @@ use App\Application\Admin\AllowlistController;
 use App\Application\Admin\ConsumersController;
 use App\Application\Admin\ManualBlocksController;
 use App\Application\Admin\MeController;
+use App\Application\Admin\PoliciesController;
 use App\Application\Admin\ReportersController;
 use App\Application\Admin\TokensController;
 use App\Application\Auth\AuthController;
@@ -16,10 +17,12 @@ use App\Application\Jobs\CleanupAuditJob;
 use App\Application\Jobs\EnrichPendingJob;
 use App\Application\Jobs\RecomputeScoresJob;
 use App\Application\Jobs\TickJob;
+use App\Application\Public\BlocklistController;
 use App\Application\Public\ReportController;
 use App\Domain\Auth\Role;
 use App\Domain\Auth\TokenHasher;
 use App\Domain\Auth\TokenIssuer;
+use App\Domain\Reputation\BlocklistBuilder;
 use App\Domain\Reputation\EffectiveStatusService;
 use App\Domain\Reputation\PairScorer;
 use App\Domain\Time\Clock;
@@ -44,7 +47,9 @@ use App\Infrastructure\Jobs\JobRegistry;
 use App\Infrastructure\Jobs\JobRunner;
 use App\Infrastructure\Jobs\JobRunRepository;
 use App\Infrastructure\ManualBlock\ManualBlockRepository;
+use App\Infrastructure\Policy\PolicyRepository;
 use App\Infrastructure\Reporter\ReporterRepository;
+use App\Infrastructure\Reputation\BlocklistCache;
 use App\Infrastructure\Reputation\CidrEvaluatorFactory;
 use App\Infrastructure\Reputation\IpScoreRepository;
 use App\Infrastructure\Reputation\ReportRepository;
@@ -97,6 +102,7 @@ final class Container
             '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),
+            'settings.blocklist_cache_ttl_seconds' => (int) ($settings['blocklist_cache_ttl_seconds'] ?? 30),
             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');
@@ -133,6 +139,7 @@ final class Container
             IpScoreRepository::class => autowire(),
             ManualBlockRepository::class => autowire(),
             AllowlistRepository::class => autowire(),
+            PolicyRepository::class => autowire(),
             CidrEvaluatorFactory::class => factory(static function (ContainerInterface $c): CidrEvaluatorFactory {
                 /** @var ManualBlockRepository $manual */
                 $manual = $c->get(ManualBlockRepository::class);
@@ -148,6 +155,17 @@ final class Container
                 return new CidrEvaluatorFactory($manual, $allow, $clock, $logger, $ttl);
             }),
             EffectiveStatusService::class => autowire(),
+            BlocklistBuilder::class => autowire(),
+            BlocklistCache::class => factory(static function (ContainerInterface $c): BlocklistCache {
+                /** @var BlocklistBuilder $builder */
+                $builder = $c->get(BlocklistBuilder::class);
+                /** @var Clock $clock */
+                $clock = $c->get(Clock::class);
+                /** @var int $ttl */
+                $ttl = $c->get('settings.blocklist_cache_ttl_seconds');
+
+                return new BlocklistCache($builder, $clock, $ttl);
+            }),
             ServiceTokenBootstrap::class => autowire(),
             TokenAuthenticationMiddleware::class => autowire(),
             ImpersonationMiddleware::class => autowire(),
@@ -278,6 +296,8 @@ final class Container
             ReportController::class => autowire(),
             ManualBlocksController::class => autowire(),
             AllowlistController::class => autowire(),
+            PoliciesController::class => autowire(),
+            BlocklistController::class => autowire(),
         ]);
 
         return $builder->build();

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

@@ -10,6 +10,7 @@ use App\Domain\Ip\InvalidCidrException;
 use App\Domain\Ip\InvalidIpException;
 use App\Domain\Ip\IpAddress;
 use App\Infrastructure\Allowlist\AllowlistRepository;
+use App\Infrastructure\Reputation\BlocklistCache;
 use App\Infrastructure\Reputation\CidrEvaluatorFactory;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
@@ -27,6 +28,7 @@ final class AllowlistController
     public function __construct(
         private readonly AllowlistRepository $allowlist,
         private readonly CidrEvaluatorFactory $evaluator,
+        private readonly BlocklistCache $blocklistCache,
     ) {
     }
 
@@ -108,6 +110,7 @@ final class AllowlistController
 
             $id = $this->allowlist->createIp($ip, $reason, self::actingUserId($request));
             $this->evaluator->invalidate();
+            $this->blocklistCache->invalidateAll();
             // Eager rebuild so an allowlist/manual-block overlap surfaces
             // as a WARNING log entry inside this request — matches the
             // ManualBlocksController behaviour for symmetry.
@@ -141,6 +144,7 @@ final class AllowlistController
 
         $id = $this->allowlist->createSubnet($cidr, $reason, self::actingUserId($request));
         $this->evaluator->invalidate();
+        $this->blocklistCache->invalidateAll();
         $this->evaluator->get();
         $created = $this->allowlist->findById($id);
         if ($created === null) {
@@ -171,6 +175,7 @@ final class AllowlistController
 
         $this->allowlist->delete($id);
         $this->evaluator->invalidate();
+        $this->blocklistCache->invalidateAll();
 
         return $response->withStatus(204);
     }

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

@@ -10,6 +10,7 @@ use App\Domain\Ip\InvalidIpException;
 use App\Domain\Ip\IpAddress;
 use App\Domain\ManualBlock\ManualBlock;
 use App\Infrastructure\ManualBlock\ManualBlockRepository;
+use App\Infrastructure\Reputation\BlocklistCache;
 use App\Infrastructure\Reputation\CidrEvaluatorFactory;
 use DateTimeImmutable;
 use DateTimeZone;
@@ -37,6 +38,7 @@ final class ManualBlocksController
     public function __construct(
         private readonly ManualBlockRepository $manualBlocks,
         private readonly CidrEvaluatorFactory $evaluator,
+        private readonly BlocklistCache $blocklistCache,
     ) {
     }
 
@@ -132,6 +134,7 @@ final class ManualBlocksController
 
             $id = $this->manualBlocks->createIp($ip, $reason, $expiresAt, self::actingUserId($request));
             $this->evaluator->invalidate();
+            $this->blocklistCache->invalidateAll();
             // 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.
@@ -165,6 +168,7 @@ final class ManualBlocksController
 
         $id = $this->manualBlocks->createSubnet($cidr, $reason, $expiresAt, self::actingUserId($request));
         $this->evaluator->invalidate();
+        $this->blocklistCache->invalidateAll();
         $this->evaluator->get();
         $created = $this->manualBlocks->findById($id);
         if ($created === null) {
@@ -195,6 +199,7 @@ final class ManualBlocksController
 
         $this->manualBlocks->delete($id);
         $this->evaluator->invalidate();
+        $this->blocklistCache->invalidateAll();
 
         return $response->withStatus(204);
     }

+ 315 - 0
api/src/Application/Admin/PoliciesController.php

@@ -0,0 +1,315 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Admin;
+
+use App\Domain\Category\Category;
+use App\Domain\Policy\Policy;
+use App\Domain\Reputation\BlocklistBuilder;
+use App\Infrastructure\Category\CategoryRepository;
+use App\Infrastructure\Policy\PolicyRepository;
+use App\Infrastructure\Reputation\BlocklistCache;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Admin CRUD over policies and their thresholds, plus the per-policy
+ * preview endpoint used by the UI.
+ *
+ * Threshold body shape: `{<category_slug>: <number>}`. The controller
+ * resolves slugs to ids via CategoryRepository before persisting; unknown
+ * slugs surface as a single validation error.
+ *
+ * RBAC split (per SPEC §6 / §M07): list/show/preview = Viewer, create/
+ * update/delete = Admin. Enforced in `AppFactory` via `RbacMiddleware`.
+ */
+final class PoliciesController
+{
+    use AdminControllerSupport;
+
+    public function __construct(
+        private readonly PolicyRepository $policies,
+        private readonly CategoryRepository $categories,
+        private readonly BlocklistBuilder $blocklistBuilder,
+        private readonly BlocklistCache $blocklistCache,
+    ) {
+    }
+
+    public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $policies = $this->policies->listAll();
+        $slugMap = $this->slugByCategoryId();
+
+        return self::json($response, 200, [
+            'items' => array_map(static fn (Policy $p) => $p->toArray($slugMap), $policies),
+            'total' => count($policies),
+        ]);
+    }
+
+    /**
+     * @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');
+        }
+        $policy = $this->policies->findById($id);
+        if ($policy === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        return self::json($response, 200, $policy->toArray($this->slugByCategoryId()));
+    }
+
+    public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $body = self::jsonBody($request);
+        $errors = [];
+
+        $name = isset($body['name']) && is_string($body['name']) ? trim($body['name']) : '';
+        if ($name === '' || strlen($name) > 128) {
+            $errors['name'] = 'required, 1–128 chars';
+        }
+
+        $description = null;
+        if (array_key_exists('description', $body)) {
+            if ($body['description'] !== null && !is_string($body['description'])) {
+                $errors['description'] = 'must be string or null';
+            } else {
+                $description = $body['description'];
+            }
+        }
+
+        $includeManualBlocks = true;
+        if (array_key_exists('include_manual_blocks', $body)) {
+            if (!is_bool($body['include_manual_blocks'])) {
+                $errors['include_manual_blocks'] = 'must be boolean';
+            } else {
+                $includeManualBlocks = $body['include_manual_blocks'];
+            }
+        }
+
+        $thresholds = [];
+        if (!array_key_exists('thresholds', $body) || !is_array($body['thresholds'])) {
+            $errors['thresholds'] = 'required, object {category_slug: number}';
+        } else {
+            $resolved = $this->resolveThresholds($body['thresholds']);
+            if (is_string($resolved)) {
+                $errors['thresholds'] = $resolved;
+            } else {
+                $thresholds = $resolved;
+            }
+        }
+
+        if (!isset($errors['name']) && $name !== '' && $this->policies->findByName($name) !== null) {
+            $errors['name'] = 'already exists';
+        }
+
+        if ($errors !== []) {
+            return self::validationFailed($response, $errors);
+        }
+
+        $id = $this->policies->create($name, $description, $includeManualBlocks, $thresholds);
+        $this->blocklistCache->invalidate($id);
+        $created = $this->policies->findById($id);
+        if ($created === null) {
+            return self::error($response, 500, 'create_failed');
+        }
+
+        return self::json($response, 201, $created->toArray($this->slugByCategoryId()));
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $id = self::parseId($args['id']);
+        if ($id === null) {
+            return self::error($response, 404, 'not_found');
+        }
+        $existing = $this->policies->findById($id);
+        if ($existing === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        $body = self::jsonBody($request);
+        $errors = [];
+        $fields = [];
+
+        if (array_key_exists('name', $body)) {
+            if (!is_string($body['name']) || trim($body['name']) === '' || strlen(trim($body['name'])) > 128) {
+                $errors['name'] = 'required, 1–128 chars';
+            } else {
+                $name = trim($body['name']);
+                $other = $this->policies->findByName($name);
+                if ($other !== null && $other->id !== $id) {
+                    $errors['name'] = 'already exists';
+                } else {
+                    $fields['name'] = $name;
+                }
+            }
+        }
+        if (array_key_exists('description', $body)) {
+            if ($body['description'] !== null && !is_string($body['description'])) {
+                $errors['description'] = 'must be string or null';
+            } else {
+                $fields['description'] = $body['description'];
+            }
+        }
+        if (array_key_exists('include_manual_blocks', $body)) {
+            if (!is_bool($body['include_manual_blocks'])) {
+                $errors['include_manual_blocks'] = 'must be boolean';
+            } else {
+                $fields['include_manual_blocks'] = $body['include_manual_blocks'] ? 1 : 0;
+            }
+        }
+
+        $thresholds = null;
+        if (array_key_exists('thresholds', $body)) {
+            if (!is_array($body['thresholds'])) {
+                $errors['thresholds'] = 'must be object {category_slug: number}';
+            } else {
+                $resolved = $this->resolveThresholds($body['thresholds']);
+                if (is_string($resolved)) {
+                    $errors['thresholds'] = $resolved;
+                } else {
+                    $thresholds = $resolved;
+                }
+            }
+        }
+
+        if ($errors !== []) {
+            return self::validationFailed($response, $errors);
+        }
+
+        if ($fields !== []) {
+            $this->policies->update($id, $fields);
+        }
+        if ($thresholds !== null) {
+            $this->policies->replaceThresholds($id, $thresholds);
+        }
+        $this->blocklistCache->invalidate($id);
+        $updated = $this->policies->findById($id);
+        if ($updated === null) {
+            return self::error($response, 500, 'update_failed');
+        }
+
+        return self::json($response, 200, $updated->toArray($this->slugByCategoryId()));
+    }
+
+    /**
+     * @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->policies->findById($id);
+        if ($existing === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        $consumers = $this->policies->consumersUsing($id);
+        if ($consumers !== []) {
+            return self::json($response, 409, [
+                'error' => 'policy_in_use',
+                'consumers' => $consumers,
+            ]);
+        }
+
+        $this->policies->delete($id);
+        $this->blocklistCache->invalidate($id);
+
+        return $response->withStatus(204);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function preview(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $id = self::parseId($args['id']);
+        if ($id === null) {
+            return self::error($response, 404, 'not_found');
+        }
+        $policy = $this->policies->findById($id);
+        if ($policy === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        $blocklist = $this->blocklistBuilder->build($policy);
+        $sample = array_slice($blocklist->entries, 0, 50);
+        $sampleStrings = array_map(static fn ($e) => $e->ipOrCidr, $sample);
+
+        return self::json($response, 200, [
+            'count' => $blocklist->count(),
+            'sample' => $sampleStrings,
+            'generated_at' => $blocklist->generatedAt->format('Y-m-d\TH:i:s\Z'),
+            'policy' => $policy->name,
+        ]);
+    }
+
+    /**
+     * Resolve a body-supplied `{slug: number}` map to `[category_id => float]`.
+     * Returns the integer-keyed map on success, or a single human-readable
+     * error string suitable for `validation_failed.details.thresholds`.
+     *
+     * @param array<int|string, mixed> $raw
+     * @return array<int, float>|string
+     */
+    private function resolveThresholds(array $raw): array|string
+    {
+        if ($raw === []) {
+            return 'must contain at least one entry';
+        }
+        $idBySlug = [];
+        foreach ($this->categories->listAll() as $cat) {
+            /** @var Category $cat */
+            $idBySlug[$cat->slug] = $cat->id;
+        }
+
+        $out = [];
+        foreach ($raw as $slug => $value) {
+            if (!is_string($slug) || $slug === '') {
+                return 'keys must be category slugs';
+            }
+            if (!isset($idBySlug[$slug])) {
+                return sprintf('unknown category slug: %s', $slug);
+            }
+            if (is_int($value)) {
+                $value = (float) $value;
+            }
+            if (!is_float($value) || $value < 0.0) {
+                return sprintf('threshold for %s must be non-negative number', $slug);
+            }
+            $out[$idBySlug[$slug]] = $value;
+        }
+
+        return $out;
+    }
+
+    /**
+     * @return array<int, string>
+     */
+    private function slugByCategoryId(): array
+    {
+        $out = [];
+        foreach ($this->categories->listAll() as $cat) {
+            /** @var Category $cat */
+            $out[$cat->id] = $cat->slug;
+        }
+
+        return $out;
+    }
+
+    private static function parseId(string $raw): ?int
+    {
+        return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null;
+    }
+}

+ 171 - 0
api/src/Application/Public/BlocklistController.php

@@ -0,0 +1,171 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Public;
+
+use App\Domain\Auth\AuthenticatedPrincipal;
+use App\Domain\Auth\TokenKind;
+use App\Domain\Reputation\Blocklist;
+use App\Domain\Reputation\BlocklistEntry;
+use App\Infrastructure\Consumer\ConsumerRepository;
+use App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware;
+use App\Infrastructure\Policy\PolicyRepository;
+use App\Infrastructure\Reputation\BlocklistCache;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * `GET /api/v1/blocklist` — distribution endpoint for firewalls/proxies.
+ *
+ * Auth: must be a `consumer`-kind token. Wrong kind returns 401 (uniform
+ * unauthorized envelope per M03).
+ *
+ * Output:
+ *   - default `text/plain`: one entry per line, bare IP or CIDR. Empty
+ *     body when there are no entries.
+ *   - `?format=json`: array of `{ip_or_cidr, categories, score, reason}`.
+ *
+ * Headers:
+ *   - `ETag: "<sha256-hex>"` of the rendered body. The body excludes
+ *     `generated_at` so the ETag depends only on data, not on time.
+ *   - `If-None-Match` honored — strict comparison, weak validators (`W/`)
+ *     are stripped before comparing.
+ *   - `X-Blocklist-Generated-At`, `X-Blocklist-Entries`, `X-Blocklist-Policy`.
+ *
+ * The Blocklist build is cached by `BlocklistCache` per policy_id for 30s
+ * (TTL configurable in settings); the rendering layer here is fast so we
+ * render fresh on each request from the cached domain object.
+ */
+final class BlocklistController
+{
+    public function __construct(
+        private readonly ConsumerRepository $consumers,
+        private readonly PolicyRepository $policies,
+        private readonly BlocklistCache $blocklistCache,
+    ) {
+    }
+
+    public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $principal = $request->getAttribute(TokenAuthenticationMiddleware::ATTR_PRINCIPAL);
+        if (!$principal instanceof AuthenticatedPrincipal || $principal->tokenKind !== TokenKind::Consumer) {
+            return self::error($response, 401, 'unauthorized');
+        }
+        if ($principal->consumerId === null) {
+            return self::error($response, 401, 'unauthorized');
+        }
+
+        $consumer = $this->consumers->findById($principal->consumerId);
+        if ($consumer === null || !$consumer->isActive) {
+            return self::error($response, 401, 'unauthorized');
+        }
+
+        $policy = $this->policies->findById($consumer->policyId);
+        if ($policy === null) {
+            // FK is RESTRICT, but defensive: a consumer pointing at a
+            // missing policy is a 500-class problem.
+            return self::error($response, 500, 'consumer_policy_missing');
+        }
+
+        $blocklist = $this->blocklistCache->getOrBuild($policy);
+
+        $params = $request->getQueryParams();
+        $isJson = isset($params['format']) && $params['format'] === 'json';
+        $body = $isJson ? self::renderJson($blocklist) : self::renderText($blocklist);
+        $contentType = $isJson ? 'application/json' : 'text/plain';
+
+        $etag = '"' . hash('sha256', $body) . '"';
+
+        $ifNoneMatch = self::extractIfNoneMatch($request);
+        if ($ifNoneMatch !== null && self::etagMatches($ifNoneMatch, $etag)) {
+            return $response
+                ->withStatus(304)
+                ->withHeader('ETag', $etag)
+                ->withHeader('X-Blocklist-Generated-At', $blocklist->generatedAt->format('Y-m-d\TH:i:s\Z'))
+                ->withHeader('X-Blocklist-Entries', (string) $blocklist->count())
+                ->withHeader('X-Blocklist-Policy', $blocklist->policyName);
+        }
+
+        $response->getBody()->write($body);
+
+        return $response
+            ->withStatus(200)
+            ->withHeader('Content-Type', $contentType)
+            ->withHeader('ETag', $etag)
+            ->withHeader('X-Blocklist-Generated-At', $blocklist->generatedAt->format('Y-m-d\TH:i:s\Z'))
+            ->withHeader('X-Blocklist-Entries', (string) $blocklist->count())
+            ->withHeader('X-Blocklist-Policy', $blocklist->policyName);
+    }
+
+    private static function renderText(Blocklist $blocklist): string
+    {
+        if ($blocklist->entries === []) {
+            return '';
+        }
+
+        $lines = array_map(static fn (BlocklistEntry $e): string => $e->ipOrCidr, $blocklist->entries);
+
+        return implode("\n", $lines) . "\n";
+    }
+
+    private static function renderJson(Blocklist $blocklist): string
+    {
+        $payload = array_map(
+            static fn (BlocklistEntry $e): array => $e->toJsonArray(),
+            $blocklist->entries
+        );
+
+        return (string) json_encode($payload);
+    }
+
+    private static function extractIfNoneMatch(ServerRequestInterface $request): ?string
+    {
+        $header = $request->getHeaderLine('If-None-Match');
+        if ($header === '') {
+            return null;
+        }
+
+        return $header;
+    }
+
+    /**
+     * RFC 7232: If-None-Match may be a comma-separated list of validators,
+     * each optionally weak (`W/`). We strip weak prefixes and compare
+     * strong-equality against our hash. The asterisk `*` matches anything.
+     */
+    private static function etagMatches(string $headerValue, string $etag): bool
+    {
+        $headerValue = trim($headerValue);
+        if ($headerValue === '*') {
+            return true;
+        }
+        foreach (explode(',', $headerValue) as $part) {
+            $candidate = trim($part);
+            if (str_starts_with($candidate, 'W/')) {
+                $candidate = substr($candidate, 2);
+            }
+            if ($candidate === $etag) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * @param array<string, mixed> $payload
+     */
+    private static function json(ResponseInterface $response, int $status, array $payload): ResponseInterface
+    {
+        $response = $response->withStatus($status)->withHeader('Content-Type', 'application/json');
+        $response->getBody()->write((string) json_encode($payload));
+
+        return $response;
+    }
+
+    private static function error(ResponseInterface $response, int $status, string $message): ResponseInterface
+    {
+        return self::json($response, $status, ['error' => $message]);
+    }
+}

+ 60 - 0
api/src/Domain/Policy/EvaluationResult.php

@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Policy;
+
+/**
+ * Outcome of evaluating an IP against a policy. Discriminates the *reason*
+ * an IP is or isn't on the policy's blocklist so callers (UI ip-detail
+ * page, distribution endpoint) can render the explanation.
+ */
+enum EvaluationOutcome: string
+{
+    case ExcludedByAllowlist = 'excluded_by_allowlist';
+    case IncludedByManualBlock = 'included_by_manual_block';
+    case IncludedByScore = 'included_by_score';
+    case Excluded = 'excluded';
+}
+
+final class EvaluationResult
+{
+    /**
+     * @param list<int> $matchedCategoryIds Category ids whose score met the threshold (only set for IncludedByScore).
+     */
+    public function __construct(
+        public readonly EvaluationOutcome $outcome,
+        public readonly array $matchedCategoryIds,
+        public readonly ?float $maxScore,
+    ) {
+    }
+
+    public static function excludedByAllowlist(): self
+    {
+        return new self(EvaluationOutcome::ExcludedByAllowlist, [], null);
+    }
+
+    public static function includedByManualBlock(): self
+    {
+        return new self(EvaluationOutcome::IncludedByManualBlock, [], null);
+    }
+
+    /**
+     * @param list<int> $matchedCategoryIds
+     */
+    public static function includedByScore(array $matchedCategoryIds, float $maxScore): self
+    {
+        return new self(EvaluationOutcome::IncludedByScore, $matchedCategoryIds, $maxScore);
+    }
+
+    public static function excluded(): self
+    {
+        return new self(EvaluationOutcome::Excluded, [], null);
+    }
+
+    public function isIncluded(): bool
+    {
+        return $this->outcome === EvaluationOutcome::IncludedByScore
+            || $this->outcome === EvaluationOutcome::IncludedByManualBlock;
+    }
+}

+ 64 - 0
api/src/Domain/Policy/Policy.php

@@ -0,0 +1,64 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Policy;
+
+use DateTimeImmutable;
+
+/**
+ * Distribution policy. Each policy has a set of (category_id => threshold)
+ * entries: an IP appears on this policy's blocklist when **any** mapped
+ * category meets its threshold. Categories absent from `thresholds` are
+ * ignored entirely by the policy (per SPEC §4 / §5).
+ */
+final class Policy
+{
+    /**
+     * @param array<int, float> $thresholds category_id => threshold
+     */
+    public function __construct(
+        public readonly int $id,
+        public readonly string $name,
+        public readonly ?string $description,
+        public readonly bool $includeManualBlocks,
+        public readonly array $thresholds,
+        public readonly DateTimeImmutable $createdAt,
+    ) {
+    }
+
+    /**
+     * Returns the policy's threshold for a category, or null if the category
+     * is not part of this policy.
+     */
+    public function thresholdFor(int $categoryId): ?float
+    {
+        return $this->thresholds[$categoryId] ?? null;
+    }
+
+    /**
+     * @param array<int, string> $slugByCategoryId
+     * @return array<string, mixed>
+     */
+    public function toArray(array $slugByCategoryId = []): array
+    {
+        $thresholds = [];
+        foreach ($this->thresholds as $categoryId => $threshold) {
+            $slug = $slugByCategoryId[$categoryId] ?? null;
+            $thresholds[] = [
+                'category_id' => $categoryId,
+                'category_slug' => $slug,
+                'threshold' => $threshold,
+            ];
+        }
+
+        return [
+            'id' => $this->id,
+            'name' => $this->name,
+            'description' => $this->description,
+            'include_manual_blocks' => $this->includeManualBlocks,
+            'thresholds' => $thresholds,
+            'created_at' => $this->createdAt->format('Y-m-d\TH:i:s\Z'),
+        ];
+    }
+}

+ 70 - 0
api/src/Domain/Policy/PolicyEvaluator.php

@@ -0,0 +1,70 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Policy;
+
+use App\Domain\Ip\IpAddress;
+use App\Domain\Reputation\CidrEvaluator;
+
+/**
+ * Evaluates a single IP against a Policy + the current allowlist/manual-block
+ * snapshot.
+ *
+ * Resolution order (SPEC §5):
+ *   1. allowlist wins over everything → `ExcludedByAllowlist`
+ *   2. manual block (only if `policy.includeManualBlocks`) → `IncludedByManualBlock`
+ *   3. score-vs-threshold across the policy's mapped categories →
+ *      `IncludedByScore` if any category meets its threshold
+ *   4. otherwise → `Excluded`
+ *
+ * Stateless: the same evaluator instance can be reused across many IPs.
+ */
+final class PolicyEvaluator
+{
+    public function __construct(
+        private readonly Policy $policy,
+        private readonly CidrEvaluator $cidrEvaluator,
+    ) {
+    }
+
+    /**
+     * @param array<int, float> $scoresByCategory category_id => score
+     */
+    public function evaluate(IpAddress $ip, array $scoresByCategory): EvaluationResult
+    {
+        if ($this->cidrEvaluator->isAllowlisted($ip)) {
+            return EvaluationResult::excludedByAllowlist();
+        }
+
+        $matched = [];
+        $maxScore = null;
+        foreach ($this->policy->thresholds as $categoryId => $threshold) {
+            $score = $scoresByCategory[$categoryId] ?? null;
+            if ($score === null) {
+                continue;
+            }
+            if ($score >= $threshold) {
+                $matched[] = $categoryId;
+                if ($maxScore === null || $score > $maxScore) {
+                    $maxScore = $score;
+                }
+            }
+        }
+
+        if ($matched !== []) {
+            return EvaluationResult::includedByScore($matched, $maxScore ?? 0.0);
+        }
+
+        if ($this->policy->includeManualBlocks && $this->cidrEvaluator->isManuallyBlocked($ip)) {
+            return EvaluationResult::includedByManualBlock();
+        }
+
+        return EvaluationResult::excluded();
+    }
+
+    public function policy(): Policy
+    {
+        return $this->policy;
+    }
+}

+ 30 - 0
api/src/Domain/Reputation/Blocklist.php

@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Reputation;
+
+use DateTimeImmutable;
+
+/**
+ * The full result of evaluating a policy against the current data set.
+ * Wraps the entry list with metadata used by the distribution endpoint
+ * (count, generation timestamp, policy name).
+ */
+final class Blocklist
+{
+    /**
+     * @param list<BlocklistEntry> $entries
+     */
+    public function __construct(
+        public readonly array $entries,
+        public readonly string $policyName,
+        public readonly DateTimeImmutable $generatedAt,
+    ) {
+    }
+
+    public function count(): int
+    {
+        return count($this->entries);
+    }
+}

+ 325 - 0
api/src/Domain/Reputation/BlocklistBuilder.php

@@ -0,0 +1,325 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Reputation;
+
+use App\Domain\Category\Category;
+use App\Domain\Ip\Cidr;
+use App\Domain\Ip\IpAddress;
+use App\Domain\Policy\Policy;
+use App\Domain\Time\Clock;
+use App\Infrastructure\Category\CategoryRepository;
+use App\Infrastructure\Reputation\CidrEvaluatorFactory;
+use App\Infrastructure\Reputation\IpScoreRepository;
+
+/**
+ * Builds a `Blocklist` from current data for a given `Policy`.
+ *
+ * Algorithm (SPEC §M07.4):
+ *   1. Pull (ip_bin, category_id, score) rows whose score meets at least one
+ *      threshold of the policy. Single SQL with per-category clauses joined
+ *      by OR, indexed via `ip_scores(category_id)` + `ip_scores(score)`.
+ *   2. Group per ip_bin → matched categories + max score.
+ *   3. Drop allowlisted scored IPs.
+ *   4. If the policy has `include_manual_blocks=true`:
+ *        - emit one entry per manual subnet (filter subnets whose network
+ *          address is allowlisted; allowlist still wins)
+ *        - emit one entry per manual single-IP not on the allowlist
+ *        - drop scored single-IPs that are covered by a manual subnet
+ *          (broader entry covers it; SPEC dedup rule)
+ *        - if a single IP is both scored and manually-listed, prefer the
+ *          scored entry (keeps category attribution).
+ *   5. Sort: IPv4 first, then IPv6; binary order within each (stable so the
+ *      ETag on identical input is stable).
+ *
+ * Hot-path performance (50k scored IPs + 100 manual /24 subnets target
+ * <500 ms — SPEC §M07.5): subnet-containment checks are grouped by prefix
+ * length so a single `applyMask` per (ip, prefix) feeds an `isset()` hash
+ * lookup. This avoids the O(IPs × subnets) `Cidr::contains()` loop.
+ */
+final class BlocklistBuilder
+{
+    public function __construct(
+        private readonly IpScoreRepository $ipScores,
+        private readonly CategoryRepository $categories,
+        private readonly CidrEvaluatorFactory $cidrEvaluatorFactory,
+        private readonly Clock $clock,
+    ) {
+    }
+
+    public function build(Policy $policy): Blocklist
+    {
+        $now = $this->clock->now();
+        $evaluator = $this->cidrEvaluatorFactory->get();
+
+        $categoryIds = array_keys($policy->thresholds);
+        $slugByCategoryId = $this->slugByCategoryId();
+
+        // ---- 1. scored entries ----
+        // The SQL query in findExceedingThresholds already filters
+        // score >= threshold per category, so we trust each row here.
+        // Parallel hashes (ipText / categoriesByIp / maxScoreByIp) keyed
+        // on ip_bin avoid the nested-array allocations that dominated
+        // the 50k path; see PROGRESS.md M07 perf notes.
+        $rows = $this->ipScores->findExceedingThresholds($categoryIds, $policy->thresholds);
+
+        /** @var array<string, string> $ipText */
+        $ipText = [];
+        /** @var array<string, list<int>> $categoriesByIp */
+        $categoriesByIp = [];
+        /** @var array<string, float> $maxScoreByIp */
+        $maxScoreByIp = [];
+
+        foreach ($rows as $row) {
+            $bin = (string) $row['ip_bin'];
+            $catId = (int) $row['category_id'];
+            $score = (float) $row['score'];
+            if (!isset($ipText[$bin])) {
+                $ipText[$bin] = (string) $row['ip_text'];
+                $categoriesByIp[$bin] = [$catId];
+                $maxScoreByIp[$bin] = $score;
+
+                continue;
+            }
+            $categoriesByIp[$bin][] = $catId;
+            if ($score > $maxScoreByIp[$bin]) {
+                $maxScoreByIp[$bin] = $score;
+            }
+        }
+
+        // ---- 2. allowlist filtering on scored IPs ----
+        $allowIpHash = [];
+        foreach ($evaluator->allowlistedIpBins() as $b) {
+            $allowIpHash[$b] = true;
+        }
+        $allowSubnetsByPrefix = self::groupSubnetsByPrefix($evaluator->allowlistedSubnets());
+        if ($allowIpHash !== []) {
+            foreach ($ipText as $bin => $_) {
+                if (isset($allowIpHash[$bin])) {
+                    unset($ipText[$bin], $categoriesByIp[$bin], $maxScoreByIp[$bin]);
+                }
+            }
+        }
+        if ($allowSubnetsByPrefix !== []) {
+            foreach ($ipText as $bin => $_) {
+                if (self::binaryInSubnetIndex($bin, $allowSubnetsByPrefix)) {
+                    unset($ipText[$bin], $categoriesByIp[$bin], $maxScoreByIp[$bin]);
+                }
+            }
+        }
+
+        // ---- 3. manual blocks ----
+        /** @var list<array{ipOrCidr: string, isCidr: bool, isIpv4: bool, sortKey: string, ipBin: ?string}> $manualEntries */
+        $manualEntries = [];
+        $manualSubnets = $policy->includeManualBlocks ? $evaluator->manualBlockedSubnets() : [];
+        $manualSubnetsByPrefix = self::groupSubnetsByPrefix($manualSubnets);
+
+        if ($policy->includeManualBlocks) {
+            foreach ($manualSubnets as $cidr) {
+                // Allowlist precedence: drop the entry if the network
+                // address is allowlisted (covers the common case of a
+                // manual /24 inside a larger allowlist /16).
+                $netBin = $cidr->network();
+                if (
+                    isset($allowIpHash[$netBin])
+                    || self::binaryInSubnetIndex($netBin, $allowSubnetsByPrefix)
+                ) {
+                    continue;
+                }
+                $manualEntries[] = [
+                    'ipOrCidr' => $cidr->text(),
+                    'isCidr' => true,
+                    'isIpv4' => $cidr->isIpv4(),
+                    'sortKey' => $netBin,
+                    'ipBin' => null,
+                ];
+            }
+
+            foreach ($evaluator->manualBlockedIpBins() as $bin) {
+                if (isset($allowIpHash[$bin])) {
+                    continue;
+                }
+                if (self::binaryInSubnetIndex($bin, $allowSubnetsByPrefix)) {
+                    continue;
+                }
+                $ip = IpAddress::fromBinary($bin);
+                $manualEntries[] = [
+                    'ipOrCidr' => $ip->text(),
+                    'isCidr' => false,
+                    'isIpv4' => $ip->isIpv4(),
+                    'sortKey' => $bin,
+                    'ipBin' => $bin,
+                ];
+            }
+        }
+
+        // ---- 4. dedup ----
+        // Drop scored single-IPs covered by any manual subnet.
+        if ($manualSubnetsByPrefix !== [] && $ipText !== []) {
+            foreach ($ipText as $bin => $_) {
+                if (self::binaryInSubnetIndex($bin, $manualSubnetsByPrefix)) {
+                    unset($ipText[$bin], $categoriesByIp[$bin], $maxScoreByIp[$bin]);
+                }
+            }
+        }
+
+        // Drop manual single-IP entries that are also scored (prefer
+        // scored — it carries category attribution).
+        if ($ipText !== []) {
+            $manualEntries = array_values(array_filter(
+                $manualEntries,
+                static fn (array $e): bool => $e['ipBin'] === null || !isset($ipText[$e['ipBin']])
+            ));
+        }
+
+        // ---- 5. assemble + sort ----
+        // Build two parallel buckets keyed by binary network, then concat
+        // v4 first, then v6. `ksort` over the binary keys gives us the
+        // SPEC-mandated lexical order without paying the usort-with-
+        // closure overhead (which dominates the 50k path — see
+        // PROGRESS.md M07 perf notes).
+        /** @var array<string, BlocklistEntry> $v4Bucket */
+        $v4Bucket = [];
+        /** @var array<string, BlocklistEntry> $v6Bucket */
+        $v6Bucket = [];
+
+        foreach ($ipText as $bin => $text) {
+            $categories = [];
+            foreach ($categoriesByIp[$bin] as $catId) {
+                if (isset($slugByCategoryId[$catId])) {
+                    $categories[] = $slugByCategoryId[$catId];
+                }
+            }
+            sort($categories);
+            $isIpv4 = self::isV4Mapped($bin);
+            $entry = new BlocklistEntry(
+                ipOrCidr: $text,
+                isCidr: false,
+                isIpv4: $isIpv4,
+                categories: $categories,
+                score: round($maxScoreByIp[$bin], 4),
+                reason: 'scored',
+            );
+            if ($isIpv4) {
+                $v4Bucket[$bin] = $entry;
+            } else {
+                $v6Bucket[$bin] = $entry;
+            }
+        }
+
+        foreach ($manualEntries as $m) {
+            $entry = new BlocklistEntry(
+                ipOrCidr: $m['ipOrCidr'],
+                isCidr: $m['isCidr'],
+                isIpv4: $m['isIpv4'],
+                categories: [],
+                score: null,
+                reason: 'manual',
+            );
+            // Distinct keys: a manual subnet's network can collide with
+            // a scored single-IP that we already dropped via dedup, but
+            // not with another manual entry once dedup ran. Use a
+            // length-prefix to keep keys distinct between subnet/single
+            // entries that happen to share a network address.
+            $key = $m['sortKey'] . ($m['isCidr'] ? "\x01" : "\x00");
+            if ($m['isIpv4']) {
+                $v4Bucket[$key] = $entry;
+            } else {
+                $v6Bucket[$key] = $entry;
+            }
+        }
+
+        ksort($v4Bucket, SORT_STRING);
+        ksort($v6Bucket, SORT_STRING);
+
+        $entries = array_values($v4Bucket);
+        foreach ($v6Bucket as $entry) {
+            $entries[] = $entry;
+        }
+
+        return new Blocklist($entries, $policy->name, $now);
+    }
+
+    /**
+     * @return array<int, string>
+     */
+    private function slugByCategoryId(): array
+    {
+        $out = [];
+        foreach ($this->categories->listAll() as $cat) {
+            /** @var Category $cat */
+            $out[$cat->id] = $cat->slug;
+        }
+
+        return $out;
+    }
+
+    private static function isV4Mapped(string $bin): bool
+    {
+        return strlen($bin) === 16
+            && substr($bin, 0, 12) === "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff";
+    }
+
+    /**
+     * Group subnets into a `prefix_length => set<network_bin>` index so a
+     * single mask + isset() answers containment in O(1) per prefix length.
+     *
+     * @param list<Cidr> $subnets
+     * @return array<int, array<string, true>>
+     */
+    private static function groupSubnetsByPrefix(array $subnets): array
+    {
+        $index = [];
+        foreach ($subnets as $cidr) {
+            $index[$cidr->prefixLength()][$cidr->network()] = true;
+        }
+
+        return $index;
+    }
+
+    /**
+     * Walk each prefix length in `$index`, mask `$ipBin` to that prefix,
+     * and check the result against the indexed network set. True iff the
+     * IP is contained by any indexed subnet.
+     *
+     * @param array<int, array<string, true>> $index
+     */
+    private static function binaryInSubnetIndex(string $ipBin, array $index): bool
+    {
+        foreach ($index as $prefix => $networks) {
+            $masked = self::applyMaskFast($ipBin, $prefix);
+            if (isset($networks[$masked])) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Mask the lower (128 - prefix) bits to zero, returning a 16-byte
+     * string. Mirrors `Cidr::applyMask` but inlined here without the
+     * exception path so the hot loop avoids a method call.
+     */
+    private static function applyMaskFast(string $binary, int $prefix): string
+    {
+        if ($prefix >= 128) {
+            return $binary;
+        }
+        if ($prefix <= 0) {
+            return "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
+        }
+
+        $fullBytes = $prefix >> 3;
+        $extraBits = $prefix & 7;
+
+        if ($extraBits === 0) {
+            return substr($binary, 0, $fullBytes) . str_repeat("\x00", 16 - $fullBytes);
+        }
+
+        $byte = ord($binary[$fullBytes]) & ((0xff << (8 - $extraBits)) & 0xff);
+
+        return substr($binary, 0, $fullBytes) . chr($byte) . str_repeat("\x00", 15 - $fullBytes);
+    }
+}

+ 45 - 0
api/src/Domain/Reputation/BlocklistEntry.php

@@ -0,0 +1,45 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Reputation;
+
+/**
+ * One entry in a Blocklist. Either a single IP (as canonical text) or a
+ * CIDR (when the source is a manual subnet block). The text rendering
+ * for the public endpoint is the `ipOrCidr` field as-is.
+ *
+ * Reasons:
+ *   - `scored`: the entry comes from `ip_scores` and met at least one of the
+ *     policy's category thresholds.
+ *   - `manual`: the entry comes from `manual_blocks` (single or subnet) and
+ *     the policy has `include_manual_blocks=true`.
+ */
+final class BlocklistEntry
+{
+    /**
+     * @param list<string> $categories category slugs that matched (only for `scored`)
+     */
+    public function __construct(
+        public readonly string $ipOrCidr,
+        public readonly bool $isCidr,
+        public readonly bool $isIpv4,
+        public readonly array $categories,
+        public readonly ?float $score,
+        public readonly string $reason,
+    ) {
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function toJsonArray(): array
+    {
+        return [
+            'ip_or_cidr' => $this->ipOrCidr,
+            'categories' => $this->categories,
+            'score' => $this->score,
+            'reason' => $this->reason,
+        ];
+    }
+}

+ 14 - 0
api/src/Infrastructure/Category/CategoryRepository.php

@@ -44,6 +44,20 @@ final class CategoryRepository
         return $row === false ? null : self::hydrate($row);
     }
 
+    /**
+     * @return list<Category>
+     */
+    public function listAll(): array
+    {
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection->fetchAllAssociative(
+            'SELECT id, slug, name, description, decay_function, decay_param, is_active '
+            . 'FROM categories ORDER BY id ASC'
+        );
+
+        return array_map(static fn (array $r): Category => self::hydrate($r), $rows);
+    }
+
     /**
      * @param array<string, mixed> $row
      */

+ 217 - 0
api/src/Infrastructure/Policy/PolicyRepository.php

@@ -0,0 +1,217 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Policy;
+
+use App\Domain\Policy\Policy;
+use App\Infrastructure\Db\RepositoryBase;
+use DateTimeImmutable;
+use DateTimeZone;
+use Doctrine\DBAL\Connection;
+
+/**
+ * DBAL gateway for `policies` and `policy_category_thresholds`.
+ *
+ * Threshold rows live in a separate join table but the policy makes no
+ * sense without them: every read loads a policy together with all its
+ * thresholds in a small two-query fetch. Writes that touch thresholds
+ * happen inside a single transaction (see `replaceThresholds`).
+ */
+final class PolicyRepository extends RepositoryBase
+{
+    public function findById(int $id): ?Policy
+    {
+        /** @var array<string, mixed>|false $row */
+        $row = $this->connection()->fetchAssociative(
+            'SELECT id, name, description, include_manual_blocks, created_at FROM policies WHERE id = :id',
+            ['id' => $id]
+        );
+        if ($row === false) {
+            return null;
+        }
+
+        return $this->hydrate($row, $this->loadThresholds((int) $row['id']));
+    }
+
+    public function findByName(string $name): ?Policy
+    {
+        /** @var array<string, mixed>|false $row */
+        $row = $this->connection()->fetchAssociative(
+            'SELECT id, name, description, include_manual_blocks, created_at FROM policies WHERE name = :name',
+            ['name' => $name]
+        );
+        if ($row === false) {
+            return null;
+        }
+
+        return $this->hydrate($row, $this->loadThresholds((int) $row['id']));
+    }
+
+    /**
+     * @return list<Policy>
+     */
+    public function listAll(): array
+    {
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection()->fetchAllAssociative(
+            'SELECT id, name, description, include_manual_blocks, created_at FROM policies ORDER BY id ASC'
+        );
+        if ($rows === []) {
+            return [];
+        }
+
+        $thresholdsByPolicy = $this->loadAllThresholds();
+
+        return array_map(
+            fn (array $row): Policy => $this->hydrate($row, $thresholdsByPolicy[(int) $row['id']] ?? []),
+            $rows
+        );
+    }
+
+    /**
+     * Insert a policy + its thresholds atomically. Returns the new id.
+     *
+     * @param array<int, float> $thresholds category_id => threshold
+     */
+    public function create(string $name, ?string $description, bool $includeManualBlocks, array $thresholds): int
+    {
+        return (int) $this->connection()->transactional(function (Connection $conn) use ($name, $description, $includeManualBlocks, $thresholds): int {
+            $conn->insert('policies', [
+                'name' => $name,
+                'description' => $description,
+                'include_manual_blocks' => $includeManualBlocks ? 1 : 0,
+            ]);
+            $policyId = (int) $conn->lastInsertId();
+
+            foreach ($thresholds as $categoryId => $threshold) {
+                $conn->insert('policy_category_thresholds', [
+                    'policy_id' => $policyId,
+                    'category_id' => $categoryId,
+                    'threshold' => number_format($threshold, 4, '.', ''),
+                ]);
+            }
+
+            return $policyId;
+        });
+    }
+
+    /**
+     * Replace the policy's name/description/include_manual_blocks fields.
+     * Only the keys present in `$fields` are updated.
+     *
+     * @param array<string, mixed> $fields
+     */
+    public function update(int $id, array $fields): void
+    {
+        if ($fields === []) {
+            return;
+        }
+        $this->connection()->update('policies', $fields, ['id' => $id]);
+    }
+
+    /**
+     * Atomic threshold replacement: deletes the old set and inserts the new
+     * one inside a single transaction so concurrent updates can't observe a
+     * half-written state.
+     *
+     * @param array<int, float> $thresholds category_id => threshold
+     */
+    public function replaceThresholds(int $policyId, array $thresholds): void
+    {
+        $this->connection()->transactional(function (Connection $conn) use ($policyId, $thresholds): void {
+            $conn->executeStatement(
+                'DELETE FROM policy_category_thresholds WHERE policy_id = :pid',
+                ['pid' => $policyId]
+            );
+            foreach ($thresholds as $categoryId => $threshold) {
+                $conn->insert('policy_category_thresholds', [
+                    'policy_id' => $policyId,
+                    'category_id' => $categoryId,
+                    'threshold' => number_format($threshold, 4, '.', ''),
+                ]);
+            }
+        });
+    }
+
+    public function delete(int $id): void
+    {
+        $this->connection()->executeStatement('DELETE FROM policies WHERE id = :id', ['id' => $id]);
+    }
+
+    /**
+     * Returns active consumers (id + name) referencing this policy. The
+     * admin DELETE endpoint uses this list to refuse deletion with a 409
+     * response (per SPEC §M07: cascade is wrong here).
+     *
+     * @return list<array{id: int, name: string}>
+     */
+    public function consumersUsing(int $policyId): array
+    {
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection()->fetchAllAssociative(
+            'SELECT id, name FROM consumers WHERE policy_id = :pid ORDER BY id ASC',
+            ['pid' => $policyId]
+        );
+
+        return array_map(
+            static fn (array $r): array => ['id' => (int) $r['id'], 'name' => (string) $r['name']],
+            $rows
+        );
+    }
+
+    /**
+     * @return array<int, float> category_id => threshold
+     */
+    private function loadThresholds(int $policyId): array
+    {
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection()->fetchAllAssociative(
+            'SELECT category_id, threshold FROM policy_category_thresholds WHERE policy_id = :pid',
+            ['pid' => $policyId]
+        );
+        $out = [];
+        foreach ($rows as $row) {
+            $out[(int) $row['category_id']] = (float) $row['threshold'];
+        }
+
+        return $out;
+    }
+
+    /**
+     * @return array<int, array<int, float>> policy_id => (category_id => threshold)
+     */
+    private function loadAllThresholds(): array
+    {
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection()->fetchAllAssociative(
+            'SELECT policy_id, category_id, threshold FROM policy_category_thresholds'
+        );
+        $out = [];
+        foreach ($rows as $row) {
+            $out[(int) $row['policy_id']][(int) $row['category_id']] = (float) $row['threshold'];
+        }
+
+        return $out;
+    }
+
+    /**
+     * @param array<string, mixed> $row
+     * @param array<int, float>    $thresholds
+     */
+    private function hydrate(array $row, array $thresholds): Policy
+    {
+        $createdAt = isset($row['created_at']) && $row['created_at'] !== null
+            ? new DateTimeImmutable((string) $row['created_at'], new DateTimeZone('UTC'))
+            : new DateTimeImmutable('now', new DateTimeZone('UTC'));
+
+        return new Policy(
+            id: (int) $row['id'],
+            name: (string) $row['name'],
+            description: $row['description'] !== null ? (string) $row['description'] : null,
+            includeManualBlocks: (bool) $row['include_manual_blocks'],
+            thresholds: $thresholds,
+            createdAt: $createdAt,
+        );
+    }
+}

+ 70 - 0
api/src/Infrastructure/Reputation/BlocklistCache.php

@@ -0,0 +1,70 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Reputation;
+
+use App\Domain\Policy\Policy;
+use App\Domain\Reputation\Blocklist;
+use App\Domain\Reputation\BlocklistBuilder;
+use App\Domain\Time\Clock;
+
+/**
+ * Per-policy in-process cache of `Blocklist` results.
+ *
+ * SPEC §M07.3: 30-second TTL by default, keyed on `policy_id`. Cache stores
+ * the domain `Blocklist` object — text and JSON renderings happen
+ * downstream and share the same cached build (the build is the expensive
+ * part).
+ *
+ * Invalidation: mutation paths call `invalidate($policyId)` for policy-
+ * scoped changes and `invalidateAll()` for global changes (manual blocks,
+ * allowlist, "rebuild scores"). Tests can pass `ttlSeconds=0` to disable
+ * caching entirely.
+ *
+ * Multi-replica caveat: the cache is per-process. The cidr-evaluator
+ * factory has the same caveat (see PROGRESS.md M06).
+ */
+class BlocklistCache
+{
+    /**
+     * @var array<int, array{blocklist: Blocklist, expires_at: float}>
+     */
+    private array $cache = [];
+
+    public function __construct(
+        private readonly BlocklistBuilder $builder,
+        private readonly Clock $clock,
+        private readonly int $ttlSeconds = 30,
+    ) {
+    }
+
+    public function getOrBuild(Policy $policy): Blocklist
+    {
+        $now = (float) $this->clock->now()->getTimestamp();
+        $entry = $this->cache[$policy->id] ?? null;
+        if ($entry !== null && $now < $entry['expires_at']) {
+            return $entry['blocklist'];
+        }
+
+        $blocklist = $this->builder->build($policy);
+        if ($this->ttlSeconds > 0) {
+            $this->cache[$policy->id] = [
+                'blocklist' => $blocklist,
+                'expires_at' => $now + $this->ttlSeconds,
+            ];
+        }
+
+        return $blocklist;
+    }
+
+    public function invalidate(int $policyId): void
+    {
+        unset($this->cache[$policyId]);
+    }
+
+    public function invalidateAll(): void
+    {
+        $this->cache = [];
+    }
+}

+ 50 - 0
api/src/Infrastructure/Reputation/IpScoreRepository.php

@@ -142,4 +142,54 @@ final class IpScoreRepository extends RepositoryBase
             ],
         );
     }
+
+    /**
+     * Fetch (ip_bin, ip_text, category_id, score) rows whose category is in
+     * `$categoryIds` AND whose score meets that category's `$thresholds`
+     * entry. Single SQL query with a per-category WHERE clause UNION; the
+     * caller groups results per-IP.
+     *
+     * Returns an empty list when `$categoryIds` is empty.
+     *
+     * Performance note: at 50k rows we skip the per-row PHP-side type
+     * normalisation that the rest of the repo does — the caller treats
+     * the strings as opaque bytes (ip_bin) or casts on demand. Saves
+     * ~25 % of build time on the SPEC's perf budget.
+     *
+     * @param list<int>         $categoryIds
+     * @param array<int, float> $thresholds category_id => threshold
+     * @return list<array<string, mixed>>
+     */
+    public function findExceedingThresholds(array $categoryIds, array $thresholds): array
+    {
+        if ($categoryIds === [] || $thresholds === []) {
+            return [];
+        }
+
+        $clauses = [];
+        $params = [];
+        $i = 0;
+        foreach ($categoryIds as $categoryId) {
+            if (!isset($thresholds[$categoryId])) {
+                continue;
+            }
+            $key = 't' . $i;
+            $catKey = 'c' . $i;
+            $clauses[] = "(category_id = :{$catKey} AND score >= :{$key})";
+            $params[$catKey] = $categoryId;
+            $params[$key] = number_format($thresholds[$categoryId], 4, '.', '');
+            ++$i;
+        }
+        if ($clauses === []) {
+            return [];
+        }
+
+        $sql = 'SELECT ip_bin, ip_text, category_id, score FROM ip_scores WHERE '
+            . implode(' OR ', $clauses);
+
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection()->fetchAllAssociative($sql, $params);
+
+        return $rows;
+    }
 }

+ 184 - 0
api/tests/Integration/Admin/PoliciesControllerTest.php

@@ -0,0 +1,184 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Admin;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use App\Tests\Integration\Support\AppTestCase;
+
+/**
+ * Covers SPEC §M07.2: policy CRUD + preview, RBAC split (Viewer reads,
+ * Admin writes), threshold replacement on PATCH, 409 on delete with
+ * referencing consumers.
+ */
+final class PoliciesControllerTest extends AppTestCase
+{
+    public function testViewerCanListSeededPolicies(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $response = $this->request('GET', '/api/v1/admin/policies', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertSame(3, $body['total']); // strict, moderate, paranoid
+        $names = array_map(static fn (array $p): string => $p['name'], $body['items']);
+        self::assertContains('strict', $names);
+        self::assertContains('moderate', $names);
+        self::assertContains('paranoid', $names);
+    }
+
+    public function testShowIncludesThresholdsKeyedBySlug(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :n', ['n' => 'moderate']);
+
+        $response = $this->request('GET', "/api/v1/admin/policies/{$policyId}", [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertSame('moderate', $body['name']);
+        self::assertNotEmpty($body['thresholds']);
+        $slugs = array_map(static fn (array $t): string => $t['category_slug'], $body['thresholds']);
+        self::assertContains('brute_force', $slugs);
+    }
+
+    public function testViewerCannotCreate(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $response = $this->request(
+            'POST',
+            '/api/v1/admin/policies',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode([
+                'name' => 'aggressive',
+                'thresholds' => ['brute_force' => 0.5],
+            ]) ?: null,
+        );
+        self::assertSame(403, $response->getStatusCode());
+    }
+
+    public function testAdminCanCreatePolicyWithThresholds(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $response = $this->request(
+            'POST',
+            '/api/v1/admin/policies',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode([
+                'name' => 'aggressive',
+                'description' => 'block everything',
+                'include_manual_blocks' => true,
+                'thresholds' => [
+                    'brute_force' => 0.1,
+                    'spam' => 0.2,
+                ],
+            ]) ?: null,
+        );
+        self::assertSame(201, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertSame('aggressive', $body['name']);
+        self::assertCount(2, $body['thresholds']);
+    }
+
+    public function testCreateRejectsDuplicateName(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $response = $this->request(
+            'POST',
+            '/api/v1/admin/policies',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['name' => 'moderate', 'thresholds' => ['brute_force' => 1.0]]) ?: null,
+        );
+        self::assertSame(400, $response->getStatusCode());
+        $details = $this->decode($response)['details'];
+        self::assertArrayHasKey('name', $details);
+    }
+
+    public function testCreateRejectsUnknownCategorySlug(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $response = $this->request(
+            'POST',
+            '/api/v1/admin/policies',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['name' => 'bogus', 'thresholds' => ['unknown_slug' => 1.0]]) ?: null,
+        );
+        self::assertSame(400, $response->getStatusCode());
+        $details = $this->decode($response)['details'];
+        self::assertStringContainsString('unknown_slug', (string) $details['thresholds']);
+    }
+
+    public function testPatchReplacesThresholdsWholesale(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :n', ['n' => 'moderate']);
+
+        $response = $this->request(
+            'PATCH',
+            "/api/v1/admin/policies/{$policyId}",
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['thresholds' => ['brute_force' => 5.0]]) ?: null,
+        );
+        self::assertSame(200, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertCount(1, $body['thresholds']);
+        self::assertSame('brute_force', $body['thresholds'][0]['category_slug']);
+        self::assertEqualsWithDelta(5.0, $body['thresholds'][0]['threshold'], 0.0001);
+    }
+
+    public function testDeleteWithReferencingConsumerReturns409(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :n', ['n' => 'moderate']);
+        $this->db->insert('consumers', [
+            'name' => 'fw-edge',
+            'policy_id' => $policyId,
+            'is_active' => 1,
+        ]);
+
+        $response = $this->request('DELETE', "/api/v1/admin/policies/{$policyId}", [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(409, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertSame('policy_in_use', $body['error']);
+        self::assertNotEmpty($body['consumers']);
+    }
+
+    public function testDeleteSucceedsWithoutReferences(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $createResp = $this->request(
+            'POST',
+            '/api/v1/admin/policies',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['name' => 'tmp', 'thresholds' => ['brute_force' => 1.0]]) ?: null,
+        );
+        $newId = (int) $this->decode($createResp)['id'];
+
+        $deleteResp = $this->request('DELETE', "/api/v1/admin/policies/{$newId}", [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(204, $deleteResp->getStatusCode());
+    }
+
+    public function testPreviewReturnsCountSampleAndPolicyName(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :n', ['n' => 'paranoid']);
+
+        $response = $this->request('GET', "/api/v1/admin/policies/{$policyId}/preview", [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertArrayHasKey('count', $body);
+        self::assertArrayHasKey('sample', $body);
+        self::assertSame('paranoid', $body['policy']);
+        self::assertIsArray($body['sample']);
+    }
+}

+ 114 - 0
api/tests/Integration/Perf/BlocklistPerfTest.php

@@ -0,0 +1,114 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Perf;
+
+use App\Domain\Ip\Cidr;
+use App\Domain\Reputation\BlocklistBuilder;
+use App\Infrastructure\Policy\PolicyRepository;
+use App\Tests\Integration\Support\AppTestCase;
+use Doctrine\DBAL\ParameterType;
+use PHPUnit\Framework\Attributes\Group;
+
+/**
+ * SPEC §M07.5: 50k scored IPs build a blocklist in <500 ms.
+ *
+ * Skipped from the default test run via the `perf` group; run with
+ * `composer test-perf`. Times the warm build (cache disabled in
+ * AppTestCase) on SQLite. MySQL number is captured separately in CI.
+ */
+#[Group('perf')]
+final class BlocklistPerfTest extends AppTestCase
+{
+    public function test50kEntriesUnder500Ms(): void
+    {
+        $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
+        $spamId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'spam']);
+
+        $this->seedScores(50_000, [$bruteForceId, $spamId]);
+        $this->seedManualSubnets(100);
+
+        /** @var PolicyRepository $policies */
+        $policies = $this->container->get(PolicyRepository::class);
+        $paranoid = $policies->findByName('paranoid');
+        self::assertNotNull($paranoid);
+
+        /** @var BlocklistBuilder $builder */
+        $builder = $this->container->get(BlocklistBuilder::class);
+
+        $start = hrtime(true);
+        $blocklist = $builder->build($paranoid);
+        $elapsedMs = (hrtime(true) - $start) / 1_000_000.0;
+        self::assertGreaterThan(0, $blocklist->count());
+        // Second run to measure warm path (SPEC's <500ms is the warm budget).
+        $start = hrtime(true);
+        $blocklist = $builder->build($paranoid);
+        $elapsedMs = (hrtime(true) - $start) / 1_000_000.0;
+
+        self::assertLessThan(
+            500.0,
+            $elapsedMs,
+            sprintf('blocklist build took %.2f ms; budget 500 ms', $elapsedMs)
+        );
+        // Surface the measured number for the PROGRESS notes.
+        fwrite(STDERR, sprintf("\n[perf] BlocklistBuilder@50k = %.1f ms (entries=%d)\n", $elapsedMs, $blocklist->count()));
+    }
+
+    /**
+     * @param list<int> $categoryIds
+     */
+    private function seedScores(int $count, array $categoryIds): void
+    {
+        // Mix of v4 (75%) and v6 (25%) addresses, varied scores so that
+        // some are above and some below the 0.3 paranoid threshold.
+        $this->db->beginTransaction();
+        $stmt = $this->db->prepare(
+            'INSERT INTO ip_scores (ip_bin, ip_text, category_id, score, report_count_30d, last_report_at, recomputed_at) '
+            . 'VALUES (:b, :t, :c, :s, 1, :now, :now)'
+        );
+        $now = (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s');
+        for ($i = 0; $i < $count; ++$i) {
+            $isV4 = ($i % 4) !== 0;
+            if ($isV4) {
+                $a = ($i >> 16) & 0xff;
+                $b = ($i >> 8) & 0xff;
+                $c = $i & 0xff;
+                // Use 10.x.x.x to keep these well outside any allowlist concern in the test DB.
+                $text = sprintf('10.%d.%d.%d', $a, $b, $c);
+            } else {
+                $text = sprintf('2001:db8::%x', $i);
+            }
+            $bin = $isV4
+                ? "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff" . pack('C4', 10, ($i >> 16) & 0xff, ($i >> 8) & 0xff, $i & 0xff)
+                : (string) inet_pton($text);
+            $score = number_format(0.5 + (($i % 5) * 0.5), 4, '.', ''); // 0.5 .. 2.5
+            $catId = $categoryIds[$i % count($categoryIds)];
+
+            $stmt->bindValue('b', $bin, ParameterType::LARGE_OBJECT);
+            $stmt->bindValue('t', $text);
+            $stmt->bindValue('c', $catId, ParameterType::INTEGER);
+            $stmt->bindValue('s', $score);
+            $stmt->bindValue('now', $now);
+            $stmt->executeStatement();
+        }
+        $this->db->commit();
+    }
+
+    private function seedManualSubnets(int $count): void
+    {
+        $this->db->beginTransaction();
+        $stmt = $this->db->prepare(
+            'INSERT INTO manual_blocks (kind, network_bin, prefix_length, reason) VALUES (:kind, :net, :pl, :reason)'
+        );
+        for ($i = 0; $i < $count; ++$i) {
+            $cidr = Cidr::fromString(sprintf('192.0.%d.0/24', $i));
+            $stmt->bindValue('kind', 'subnet');
+            $stmt->bindValue('net', $cidr->network(), ParameterType::LARGE_OBJECT);
+            $stmt->bindValue('pl', $cidr->prefixLength(), ParameterType::INTEGER);
+            $stmt->bindValue('reason', 'perf');
+            $stmt->executeStatement();
+        }
+        $this->db->commit();
+    }
+}

+ 225 - 0
api/tests/Integration/Public/BlocklistControllerTest.php

@@ -0,0 +1,225 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Public;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use App\Domain\Ip\Cidr;
+use App\Domain\Ip\IpAddress;
+use App\Tests\Integration\Support\AppTestCase;
+use Doctrine\DBAL\ParameterType;
+
+/**
+ * Covers SPEC §M07.3: distribution endpoint.
+ *  - kind=consumer required (admin token forbidden)
+ *  - text/plain default; ?format=json
+ *  - allowlist excludes; manual blocks emit
+ *  - ETag round-trip → 304
+ *  - response headers populated
+ */
+final class BlocklistControllerTest extends AppTestCase
+{
+    public function testAdminTokenIsRejected(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $response = $this->request('GET', '/api/v1/blocklist', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(401, $response->getStatusCode());
+    }
+
+    public function testConsumerTokenReturnsEmptyTextByDefault(): void
+    {
+        $token = $this->setupConsumerToken('moderate');
+
+        $response = $this->request('GET', '/api/v1/blocklist', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $response->getStatusCode());
+        self::assertSame('text/plain', $response->getHeaderLine('Content-Type'));
+        self::assertSame('', (string) $response->getBody());
+        self::assertSame('0', $response->getHeaderLine('X-Blocklist-Entries'));
+        self::assertSame('moderate', $response->getHeaderLine('X-Blocklist-Policy'));
+        self::assertNotSame('', $response->getHeaderLine('ETag'));
+        self::assertNotSame('', $response->getHeaderLine('X-Blocklist-Generated-At'));
+    }
+
+    public function testManualBlockAppearsInTextBlocklist(): void
+    {
+        $token = $this->setupConsumerToken('moderate');
+        $this->insertManualSubnet('198.51.100.0/24');
+
+        $response = $this->request('GET', '/api/v1/blocklist', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $response->getStatusCode());
+        self::assertStringContainsString('198.51.100.0/24', (string) $response->getBody());
+        self::assertSame('1', $response->getHeaderLine('X-Blocklist-Entries'));
+    }
+
+    public function testJsonFormatReturnsStructuredEntries(): void
+    {
+        $token = $this->setupConsumerToken('moderate');
+        $this->insertManualIp('203.0.113.7');
+
+        $response = $this->request('GET', '/api/v1/blocklist?format=json', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $response->getStatusCode());
+        self::assertSame('application/json', $response->getHeaderLine('Content-Type'));
+        $body = json_decode((string) $response->getBody(), true);
+        self::assertIsArray($body);
+        self::assertCount(1, $body);
+        self::assertSame('203.0.113.7', $body[0]['ip_or_cidr']);
+        self::assertSame('manual', $body[0]['reason']);
+        self::assertNull($body[0]['score']);
+    }
+
+    public function testEtagRoundTripReturns304(): void
+    {
+        $token = $this->setupConsumerToken('moderate');
+        $this->insertManualSubnet('198.51.100.0/24');
+
+        $first = $this->request('GET', '/api/v1/blocklist', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        $etag = $first->getHeaderLine('ETag');
+        self::assertNotSame('', $etag);
+
+        $second = $this->request('GET', '/api/v1/blocklist', [
+            'Authorization' => 'Bearer ' . $token,
+            'If-None-Match' => $etag,
+        ]);
+        self::assertSame(304, $second->getStatusCode());
+        self::assertSame('', (string) $second->getBody());
+    }
+
+    public function testEtagDiffersBetweenTextAndJson(): void
+    {
+        $token = $this->setupConsumerToken('moderate');
+        $this->insertManualSubnet('198.51.100.0/24');
+
+        $text = $this->request('GET', '/api/v1/blocklist', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        $json = $this->request('GET', '/api/v1/blocklist?format=json', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertNotSame($text->getHeaderLine('ETag'), $json->getHeaderLine('ETag'));
+    }
+
+    public function testAllowlistedManualSubnetIsExcluded(): void
+    {
+        $token = $this->setupConsumerToken('moderate');
+        $this->insertManualSubnet('198.51.100.0/24');
+        // Allowlist a subnet that fully contains the manual block.
+        $this->insertAllowSubnet('198.51.100.0/16');
+
+        $response = $this->request('GET', '/api/v1/blocklist', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $response->getStatusCode());
+        self::assertStringNotContainsString('198.51.100.0/24', (string) $response->getBody());
+    }
+
+    /**
+     * Three policies with distinct thresholds yield distinct entry counts
+     * over the same scored data — SPEC §M07 acceptance.
+     */
+    public function testThreePoliciesProduceDifferentBlocklists(): void
+    {
+        $strict = $this->setupConsumerToken('strict', 'consumer-strict');
+        $moderate = $this->setupConsumerToken('moderate', 'consumer-moderate');
+        $paranoid = $this->setupConsumerToken('paranoid', 'consumer-paranoid');
+
+        $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
+        // Score chosen to fall between paranoid (0.3) and moderate (1.0).
+        $this->insertScore('203.0.113.10', $bruteForceId, 0.5);
+        // Score that paranoid + moderate catch but strict (2.5) misses.
+        $this->insertScore('203.0.113.20', $bruteForceId, 1.5);
+        // Score that all three policies catch.
+        $this->insertScore('203.0.113.30', $bruteForceId, 3.0);
+
+        $strictResp = $this->request('GET', '/api/v1/blocklist', [
+            'Authorization' => 'Bearer ' . $strict,
+        ]);
+        $moderateResp = $this->request('GET', '/api/v1/blocklist', [
+            'Authorization' => 'Bearer ' . $moderate,
+        ]);
+        $paranoidResp = $this->request('GET', '/api/v1/blocklist', [
+            'Authorization' => 'Bearer ' . $paranoid,
+        ]);
+
+        self::assertSame('1', $strictResp->getHeaderLine('X-Blocklist-Entries'));
+        self::assertSame('2', $moderateResp->getHeaderLine('X-Blocklist-Entries'));
+        self::assertSame('3', $paranoidResp->getHeaderLine('X-Blocklist-Entries'));
+    }
+
+    private function setupConsumerToken(string $policyName, string $consumerName = 'fw-test'): string
+    {
+        $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :n', ['n' => $policyName]);
+        $this->db->insert('consumers', [
+            'name' => $consumerName,
+            'policy_id' => $policyId,
+            'is_active' => 1,
+        ]);
+        $consumerId = (int) $this->db->lastInsertId();
+
+        return $this->createToken(TokenKind::Consumer, consumerId: $consumerId);
+    }
+
+    private function insertManualIp(string $ip): void
+    {
+        $bin = IpAddress::fromString($ip)->binary();
+        $stmt = $this->db->prepare(
+            'INSERT INTO manual_blocks (kind, ip_bin, reason) VALUES (:kind, :ip_bin, :reason)'
+        );
+        $stmt->bindValue('kind', 'ip');
+        $stmt->bindValue('ip_bin', $bin, ParameterType::LARGE_OBJECT);
+        $stmt->bindValue('reason', 'test');
+        $stmt->executeStatement();
+    }
+
+    private function insertManualSubnet(string $cidr): void
+    {
+        $c = Cidr::fromString($cidr);
+        $stmt = $this->db->prepare(
+            'INSERT INTO manual_blocks (kind, network_bin, prefix_length, reason) VALUES (:kind, :net, :pl, :reason)'
+        );
+        $stmt->bindValue('kind', 'subnet');
+        $stmt->bindValue('net', $c->network(), ParameterType::LARGE_OBJECT);
+        $stmt->bindValue('pl', $c->prefixLength(), ParameterType::INTEGER);
+        $stmt->bindValue('reason', 'test');
+        $stmt->executeStatement();
+    }
+
+    private function insertAllowSubnet(string $cidr): void
+    {
+        $c = Cidr::fromString($cidr);
+        $stmt = $this->db->prepare(
+            'INSERT INTO allowlist (kind, network_bin, prefix_length, reason) VALUES (:kind, :net, :pl, :reason)'
+        );
+        $stmt->bindValue('kind', 'subnet');
+        $stmt->bindValue('net', $c->network(), ParameterType::LARGE_OBJECT);
+        $stmt->bindValue('pl', $c->prefixLength(), ParameterType::INTEGER);
+        $stmt->bindValue('reason', 'test');
+        $stmt->executeStatement();
+    }
+
+    private function insertScore(string $ip, int $categoryId, float $score): void
+    {
+        $ipObj = IpAddress::fromString($ip);
+        $stmt = $this->db->prepare(
+            'INSERT INTO ip_scores (ip_bin, ip_text, category_id, score, report_count_30d, last_report_at, recomputed_at) '
+            . 'VALUES (:b, :t, :c, :s, 1, :now, :now)'
+        );
+        $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
+        $stmt->bindValue('t', $ipObj->text());
+        $stmt->bindValue('c', $categoryId, ParameterType::INTEGER);
+        $stmt->bindValue('s', number_format($score, 4, '.', ''));
+        $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
+        $stmt->executeStatement();
+    }
+}

+ 151 - 0
api/tests/Integration/Reputation/BlocklistBuilderTest.php

@@ -0,0 +1,151 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Reputation;
+
+use App\Domain\Ip\Cidr;
+use App\Domain\Ip\IpAddress;
+use App\Domain\Reputation\BlocklistBuilder;
+use App\Infrastructure\Policy\PolicyRepository;
+use App\Tests\Integration\Support\AppTestCase;
+use Doctrine\DBAL\ParameterType;
+
+/**
+ * Integration tests for the per-policy `BlocklistBuilder`.
+ *  - allowlist filters scored entries
+ *  - manual subnet covering a scored IP suppresses the single entry
+ *  - sort: IPv4 before IPv6, stable across rebuilds
+ *  - dedup: scored single + manual single → scored wins (carries categories)
+ */
+final class BlocklistBuilderTest extends AppTestCase
+{
+    public function testScoredIpAppearsInBlocklistWithCategorySlugs(): void
+    {
+        $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
+        $this->insertScore('203.0.113.5', $bruteForceId, 2.0);
+
+        $blocklist = $this->buildFor('paranoid');
+
+        self::assertCount(1, $blocklist->entries);
+        $entry = $blocklist->entries[0];
+        self::assertSame('203.0.113.5', $entry->ipOrCidr);
+        self::assertSame('scored', $entry->reason);
+        self::assertContains('brute_force', $entry->categories);
+    }
+
+    public function testAllowlistedScoredIpIsExcluded(): void
+    {
+        $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
+        $this->insertScore('203.0.113.5', $bruteForceId, 2.0);
+        $this->insertAllowIp('203.0.113.5');
+
+        $blocklist = $this->buildFor('paranoid');
+
+        self::assertCount(0, $blocklist->entries);
+    }
+
+    public function testManualSubnetSuppressesScoredSingleIpInside(): void
+    {
+        $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
+        $this->insertScore('198.51.100.42', $bruteForceId, 2.0);
+        $this->insertManualSubnet('198.51.100.0/24');
+
+        $blocklist = $this->buildFor('paranoid');
+
+        self::assertCount(1, $blocklist->entries);
+        self::assertSame('198.51.100.0/24', $blocklist->entries[0]->ipOrCidr);
+        self::assertTrue($blocklist->entries[0]->isCidr);
+        self::assertSame('manual', $blocklist->entries[0]->reason);
+    }
+
+    public function testV4EntriesSortedBeforeV6(): void
+    {
+        $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
+        $this->insertScore('2001:db8::1', $bruteForceId, 2.0);
+        $this->insertScore('203.0.113.5', $bruteForceId, 2.0);
+
+        $blocklist = $this->buildFor('paranoid');
+
+        self::assertCount(2, $blocklist->entries);
+        self::assertTrue($blocklist->entries[0]->isIpv4);
+        self::assertFalse($blocklist->entries[1]->isIpv4);
+    }
+
+    public function testManualSingleIpAndScoredSamePrefersScored(): void
+    {
+        $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
+        $this->insertScore('203.0.113.7', $bruteForceId, 2.0);
+        $this->insertManualIp('203.0.113.7');
+
+        $blocklist = $this->buildFor('paranoid');
+
+        self::assertCount(1, $blocklist->entries);
+        self::assertSame('scored', $blocklist->entries[0]->reason);
+    }
+
+    private function buildFor(string $policyName): \App\Domain\Reputation\Blocklist
+    {
+        /** @var PolicyRepository $policies */
+        $policies = $this->container->get(PolicyRepository::class);
+        $policy = $policies->findByName($policyName);
+        self::assertNotNull($policy);
+
+        /** @var BlocklistBuilder $builder */
+        $builder = $this->container->get(BlocklistBuilder::class);
+
+        return $builder->build($policy);
+    }
+
+    private function insertManualIp(string $ip): void
+    {
+        $bin = IpAddress::fromString($ip)->binary();
+        $stmt = $this->db->prepare(
+            'INSERT INTO manual_blocks (kind, ip_bin, reason) VALUES (:kind, :ip_bin, :reason)'
+        );
+        $stmt->bindValue('kind', 'ip');
+        $stmt->bindValue('ip_bin', $bin, ParameterType::LARGE_OBJECT);
+        $stmt->bindValue('reason', 't');
+        $stmt->executeStatement();
+    }
+
+    private function insertManualSubnet(string $cidr): void
+    {
+        $c = Cidr::fromString($cidr);
+        $stmt = $this->db->prepare(
+            'INSERT INTO manual_blocks (kind, network_bin, prefix_length, reason) VALUES (:kind, :net, :pl, :reason)'
+        );
+        $stmt->bindValue('kind', 'subnet');
+        $stmt->bindValue('net', $c->network(), ParameterType::LARGE_OBJECT);
+        $stmt->bindValue('pl', $c->prefixLength(), ParameterType::INTEGER);
+        $stmt->bindValue('reason', 't');
+        $stmt->executeStatement();
+    }
+
+    private function insertAllowIp(string $ip): void
+    {
+        $bin = IpAddress::fromString($ip)->binary();
+        $stmt = $this->db->prepare(
+            'INSERT INTO allowlist (kind, ip_bin, reason) VALUES (:kind, :ip_bin, :reason)'
+        );
+        $stmt->bindValue('kind', 'ip');
+        $stmt->bindValue('ip_bin', $bin, ParameterType::LARGE_OBJECT);
+        $stmt->bindValue('reason', 't');
+        $stmt->executeStatement();
+    }
+
+    private function insertScore(string $ip, int $categoryId, float $score): void
+    {
+        $ipObj = IpAddress::fromString($ip);
+        $stmt = $this->db->prepare(
+            'INSERT INTO ip_scores (ip_bin, ip_text, category_id, score, report_count_30d, last_report_at, recomputed_at) '
+            . 'VALUES (:b, :t, :c, :s, 1, :now, :now)'
+        );
+        $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
+        $stmt->bindValue('t', $ipObj->text());
+        $stmt->bindValue('c', $categoryId, ParameterType::INTEGER);
+        $stmt->bindValue('s', number_format($score, 4, '.', ''));
+        $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
+        $stmt->executeStatement();
+    }
+}

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

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

+ 114 - 0
api/tests/Unit/Reputation/PolicyEvaluatorTest.php

@@ -0,0 +1,114 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Reputation;
+
+use App\Domain\Ip\IpAddress;
+use App\Domain\Policy\EvaluationOutcome;
+use App\Domain\Policy\Policy;
+use App\Domain\Policy\PolicyEvaluator;
+use App\Domain\Reputation\CidrEvaluator;
+use DateTimeImmutable;
+use DateTimeZone;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Unit-level coverage for the score-vs-policy evaluator. Hand-built
+ * `CidrEvaluator` snapshots avoid touching the DB.
+ */
+final class PolicyEvaluatorTest extends TestCase
+{
+    public function testAllowlistShortCircuitsEverything(): void
+    {
+        $ip = IpAddress::fromString('203.0.113.5');
+        $cidr = new CidrEvaluator(
+            manualIpBins: [$ip->binary()],
+            manualSubnets: [],
+            allowlistIpBins: [$ip->binary()],
+            allowlistSubnets: [],
+        );
+        $policy = $this->makePolicy([1 => 0.5], includeManual: true);
+        $evaluator = new PolicyEvaluator($policy, $cidr);
+
+        $result = $evaluator->evaluate($ip, [1 => 5.0]);
+
+        self::assertSame(EvaluationOutcome::ExcludedByAllowlist, $result->outcome);
+    }
+
+    public function testIncludedByScoreWhenAnyCategoryMeetsThreshold(): void
+    {
+        $ip = IpAddress::fromString('203.0.113.5');
+        $cidr = new CidrEvaluator([], [], [], []);
+        $policy = $this->makePolicy([1 => 1.0, 2 => 5.0]);
+        $evaluator = new PolicyEvaluator($policy, $cidr);
+
+        $result = $evaluator->evaluate($ip, [1 => 1.5, 2 => 0.1]);
+
+        self::assertSame(EvaluationOutcome::IncludedByScore, $result->outcome);
+        self::assertSame([1], $result->matchedCategoryIds);
+        self::assertEqualsWithDelta(1.5, $result->maxScore, 1e-6);
+    }
+
+    public function testIncludedByScoreWithMultipleMatches(): void
+    {
+        $ip = IpAddress::fromString('203.0.113.5');
+        $cidr = new CidrEvaluator([], [], [], []);
+        $policy = $this->makePolicy([1 => 1.0, 2 => 0.5]);
+        $evaluator = new PolicyEvaluator($policy, $cidr);
+
+        $result = $evaluator->evaluate($ip, [1 => 2.0, 2 => 3.0]);
+
+        self::assertSame(EvaluationOutcome::IncludedByScore, $result->outcome);
+        self::assertSame([1, 2], $result->matchedCategoryIds);
+        self::assertEqualsWithDelta(3.0, $result->maxScore, 1e-6);
+    }
+
+    public function testIncludedByManualBlockOnlyWhenPolicyIncludesIt(): void
+    {
+        $ip = IpAddress::fromString('203.0.113.5');
+        $cidr = new CidrEvaluator(
+            manualIpBins: [$ip->binary()],
+            manualSubnets: [],
+            allowlistIpBins: [],
+            allowlistSubnets: [],
+        );
+        $policyIncluding = $this->makePolicy([1 => 5.0], includeManual: true);
+        $policyExcluding = $this->makePolicy([1 => 5.0], includeManual: false);
+
+        $including = (new PolicyEvaluator($policyIncluding, $cidr))->evaluate($ip, [1 => 0.0]);
+        $excluding = (new PolicyEvaluator($policyExcluding, $cidr))->evaluate($ip, [1 => 0.0]);
+
+        self::assertSame(EvaluationOutcome::IncludedByManualBlock, $including->outcome);
+        self::assertSame(EvaluationOutcome::Excluded, $excluding->outcome);
+    }
+
+    public function testCategoryAbsentFromPolicyIsIgnored(): void
+    {
+        $ip = IpAddress::fromString('203.0.113.5');
+        $cidr = new CidrEvaluator([], [], [], []);
+        // policy only cares about category 1; even a sky-high score on
+        // category 2 must be ignored.
+        $policy = $this->makePolicy([1 => 1.0]);
+        $evaluator = new PolicyEvaluator($policy, $cidr);
+
+        $result = $evaluator->evaluate($ip, [2 => 999.0]);
+
+        self::assertSame(EvaluationOutcome::Excluded, $result->outcome);
+    }
+
+    /**
+     * @param array<int, float> $thresholds
+     */
+    private function makePolicy(array $thresholds, bool $includeManual = true): Policy
+    {
+        return new Policy(
+            id: 1,
+            name: 'test',
+            description: null,
+            includeManualBlocks: $includeManual,
+            thresholds: $thresholds,
+            createdAt: new DateTimeImmutable('now', new DateTimeZone('UTC')),
+        );
+    }
+}