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