## 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__<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_`) 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`).