## 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.