Fresh Claude Code agent prompt. M06 must be complete and committed. Estimated effort: medium.
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.
Verify M06:
git log --oneline -6
cd api && composer test && composer stan && cd ..
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).
Confirm the seed policies from M02 exist with sensible thresholds.
In api/src/Domain/Policy/:
Policy.php — value object: id, name, description, includeManualBlocks, thresholds: array<int, float> (categoryId => threshold).PolicyEvaluator.php:
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.policy_category_thresholds rows define inclusion; absent rows mean "this category is ignored by this policy."In api/src/Infrastructure/Db/PolicyRepository.php:
policies and policy_category_thresholds (the join is small; load thresholds eagerly with each policy).byName(string): ?Policy, byId(int): ?Policy.In api/src/Application/Admin/PoliciesController.php:
GET /api/v1/admin/policiesGET /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.
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.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.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.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.In api/src/Domain/Reputation/BlocklistBuilder.php:
build(Policy $policy): Blocklist — returns a list of entries with metadata.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.CidrEvaluator::isAllowlisted).include_manual_blocks, append all manual block entries (single IPs and CIDRs), filtering allowlisted ones.Blocklist value object: a list of BlocklistEntry { ipOrCidr, isCidr, categories?, score?, reason }.
Add a perf test in api/tests/Integration/Perf/BlocklistPerfTest.php:
ip_scores rows (mixed v4 and v6, varied scores) plus 100 manual subnet blocks.paranoid policy.@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:
ip_scores(category_id, score DESC) so threshold-filter scans are cheap.ip_scores (mild denormalization). Out of scope unless 500ms is unreachable; document if you take this route.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.[] in JSON. Still emit ETag.generated_at in the body.If-None-Match: parse standard format including weak validators (W/"..."). Strict comparison on the strong hash is fine.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.text/plain output is universal; per-firewall transformation is a client-side concern, with examples shipped in M13's examples/consumers/.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
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)
Append to PROGRESS.md:
## 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.
Stop. Do not start M08.