Built: repo layout per SPEC §11, both Dockerfiles, compose stack, toolchain.
Notes for next milestone:
entrypoint.sh for api supports migrate mode and calls vendor/bin/phinx.api_tokens table first)../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.
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).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.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.App\Infrastructure\Db\Migrations\BaseMigration for adapter-aware timestamp/binary column helpers. The phinxlog table is unaffected.Decisions made:
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.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.
Built: token kinds, hashing, RBAC, impersonation pattern, auth endpoints, service token bootstrap.
API contract decisions:
{"error":"unauthorized"})X-Acting-User-Id headerlast_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_id / consumer_id only. Reading principal->reporterId from request attrs is how M04's report endpoint will identify the reporter.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.