diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index c07c521..4388502 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -358,7 +358,7 @@ summarizes classification, blast radius, and recommended/approved remediation st
- [x] **Phase 15: Data Model, Detection & Ticket Evidence** — New phishing schema (migration 097) + idempotent Autotask ticket scanner + base ticket evidence capture (completed 2026-07-15)
- [x] **Phase 16: EML/MIME Evidence Parser** — Pure RFC822/MIME parser: `.eml` selection (`rfc.eml` over `OriginatingEmail.eml`), normalized headers/URLs/attachments, sanitized body preview, synthetic-fixture tests (completed 2026-07-15)
- [x] **Phase 17: Mimecast Blast Radius Lookup** — Blast-radius abstraction with graceful `unavailable` degradation when Mimecast isn't configured (completed 2026-07-15)
-- [ ] **Phase 18: Campaign Grouping & Phishing Analysis API** — Message-ID-first dedupe/grouping, on-demand single-ticket analysis, and the first `/api/phishing/*` routes with the ACCESS-01 auth convention (blocking gap CR-03 found via live verification 2026-07-16 — duplicate campaign on single-report re-analyze — see 18-VERIFICATION.md)
+- [x] **Phase 18: Campaign Grouping & Phishing Analysis API** — Message-ID-first dedupe/grouping, on-demand single-ticket analysis, and the first `/api/phishing/*` routes with the ACCESS-01 auth convention (blocking gap CR-03 found via live verification 2026-07-16 — duplicate campaign on single-report re-analyze — see 18-VERIFICATION.md) (completed 2026-07-16)
- [ ] **Phase 19: Classification Engine** — Deterministic SPAM/UNWANTED/THREAT rule classifier over bounded structured evidence, KnowBe4-simulation guard, (re-)trigger API
- [ ] **Phase 20: Remediation, Approval & Audit Safety** — Proposed-only remediation actions, approve/remediate/mark-false-positive APIs, idempotent re-run, full audit trail
- [ ] **Phase 21: Autotask Triage Note** — Sanitized internal triage note posted via existing safe note-write path, or returned via API if no such path exists
@@ -434,7 +434,9 @@ summarizes classification, blast radius, and recommended/approved remediation st
3. Classifying a campaign with incomplete evidence (no Mimecast data, no `.eml`) lowers confidence and names the specific missing evidence in the reasons
4. A synthetic KnowBe4 security-awareness-simulation fixture is not classified as `THREAT` absent contrary evidence
5. `POST /api/phishing/campaigns/{id}/classify` (re-)triggers classification, enforces the Phase 18 auth convention, and the classifier only ever receives structured, size-bounded evidence — long bodies are redacted/truncated before reaching any AI layer, and IT Glue-sourced evidence (if referenced) goes through the existing redacted `lib/services/analyzer/itglue-search.ts` path
-**Plans**: TBD
+**Plans**: 2 plans (2 waves)
+- [ ] 19-01-PLAN.md — campaign-classifier.ts deterministic rule engine (evidence gather + D-03/D-04/D-06 rules + D-05 confidence + D-08 actions + append-only INSERT) + vitest suite + synthetic KnowBe4/BSN fixtures (CLASSIFY-01, CLASSIFY-02, CLASSIFY-03, CLASSIFY-04, CLASSIFY-06)
+- [ ] 19-02-PLAN.md — POST /api/phishing/campaigns/[id]/classify route (requirePermission analyze + UUID guard + classifyCampaign delegation) (CLASSIFY-05)
**UI hint**: no
### Phase 20: Remediation, Approval & Audit Safety
@@ -487,7 +489,7 @@ Phases execute in numeric order. v1.0 (Phases 1-9.1) shipped 2026-07-10. v2.0 (P
| 15. Data Model, Detection & Ticket Evidence | v3.0 | 3/3 | Complete | 2026-07-15 |
| 16. EML/MIME Evidence Parser | v3.0 | 3/3 | Complete | 2026-07-15 |
| 17. Mimecast Blast Radius Lookup | v3.0 | 1/1 | Complete | 2026-07-15 |
-| 18. Campaign Grouping & Phishing Analysis API | v3.0 | 4/4 | Complete | 2026-07-16 |
+| 18. Campaign Grouping & Phishing Analysis API | v3.0 | 5/5 | Complete | 2026-07-16 |
| 19. Classification Engine | v3.0 | 0/TBD | Not started | - |
| 20. Remediation, Approval & Audit Safety | v3.0 | 0/TBD | Not started | - |
| 21. Autotask Triage Note | v3.0 | 0/TBD | Not started | - |
diff --git a/.planning/phases/19-classification-engine/19-01-PLAN.md b/.planning/phases/19-classification-engine/19-01-PLAN.md
new file mode 100644
index 0000000..c02afc9
--- /dev/null
+++ b/.planning/phases/19-classification-engine/19-01-PLAN.md
@@ -0,0 +1,274 @@
+---
+phase: 19-classification-engine
+plan: 01
+type: tdd
+wave: 1
+depends_on: []
+files_modified:
+ - lib/services/campaign-classifier.ts
+ - lib/services/campaign-classifier.test.ts
+ - lib/services/campaign-classifier.fixtures.ts
+autonomous: true
+requirements: [CLASSIFY-01, CLASSIFY-02, CLASSIFY-03, CLASSIFY-04, CLASSIFY-06]
+
+must_haves:
+ truths:
+ - "classifyCampaign(campaignId) returns exactly one of SPAM/UNWANTED/THREAT with confidence, summary, reasons[], recommendedActions[], requiresApproval"
+ - "Any recommendedActions entry in the destructive set (block_sender, purge_message, reset_password, isolate_endpoint) forces requiresApproval:true; disable_forwarding_rule alone does NOT"
+ - "Incomplete evidence lowers confidence below 1.0 and names each specific missing source in reasons"
+ - "A message whose From/Return-Path domain matches KNOWN_SIMULATION_SENDERS is never classified THREAT even when authResults shows a hard fail"
+ - "THREAT-tier auth-fail check reads authResultsOriginal first, falling back to authResults only when null"
+ - "The classifier reads structured columns/headers only — no raw unbounded email body reaches the verdict logic, reasons, or persisted row"
+ artifacts:
+ - path: "lib/services/campaign-classifier.ts"
+ provides: "classifyCampaign orchestrator, gatherCampaignEvidence, pure rule functions, KNOWN_SIMULATION_SENDERS constant, ClassifyResult type"
+ exports: ["classifyCampaign", "KNOWN_SIMULATION_SENDERS", "ClassifyResult"]
+ min_lines: 200
+ - path: "lib/services/campaign-classifier.test.ts"
+ provides: "Unit + mocked-integration coverage for CLASSIFY-01/02/03/04/06"
+ contains: "requires_approval invariant"
+ - path: "lib/services/campaign-classifier.fixtures.ts"
+ provides: "Synthetic KnowBe4 (it-support.care) and BSN (breachsecurenow.com) NormalizedMessage fixtures with authResultsOriginal=pass / authResults=fail"
+ contains: "it-support.care"
+ key_links:
+ - from: "lib/services/campaign-classifier.ts"
+ to: "getBlastRadius"
+ via: "import from ./mimecast-blast-radius"
+ pattern: "getBlastRadius"
+ - from: "lib/services/campaign-classifier.ts"
+ to: "classifications table"
+ via: "postgresClient INSERT (append-only, no ON CONFLICT)"
+ pattern: "INSERT INTO classifications"
+ - from: "lib/services/campaign-classifier.ts"
+ to: "messages.headers.authResultsOriginal"
+ via: "effectiveAuthResults precedence"
+ pattern: "authResultsOriginal"
+---
+
+
+Build `lib/services/campaign-classifier.ts` — a pure, deterministic SPAM/UNWANTED/THREAT
+rule engine over bounded structured phishing-triage evidence, plus its full vitest suite
+and synthetic fixtures. No LLM/Anthropic/OpenRouter calls (D-01). One exported orchestrator
+`classifyCampaign(campaignId)` that gathers campaign evidence, applies the D-06 → D-03 → D-04
+rule order, computes D-05 confidence, maps D-08 actions, and INSERTs one append-only
+`classifications` row.
+
+Purpose: Every campaign gets a deterministic, self-explaining verdict that flags destructive
+recommendations for approval and never cries wolf on KnowBe4/BSN simulations.
+
+Output: `campaign-classifier.ts`, `campaign-classifier.test.ts`, `campaign-classifier.fixtures.ts`.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/19-classification-engine/19-CONTEXT.md
+@.planning/phases/19-classification-engine/19-RESEARCH.md
+@.planning/phases/19-classification-engine/19-PATTERNS.md
+@.planning/phases/19-classification-engine/19-VALIDATION.md
+
+
+
+
+From lib/services/eml-parser.ts:
+```typescript
+export type AuthVerdict = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
+export interface AuthResults { spf?: AuthVerdict; dkim?: AuthVerdict; dmarc?: AuthVerdict; }
+export interface NormalizedMessage {
+ from: { displayName: string | null; email: string | null; domain: string | null };
+ returnPath: string | null;
+ // subject, urls, attachments, ... plus:
+ authResults: AuthResults; // primary Authentication-Results header (post-forward)
+ authResultsOriginal: AuthResults | null; // Authentication-Results-Original (pre-forward), may be null
+}
+```
+NOTE: in the DB, the full NormalizedMessage header object is stored in `messages.headers` JSONB.
+`headers->>'subject'` gives subject; `headers->'from'->>'domain'`, `headers->>'returnPath'`,
+`headers->'authResults'`, `headers->'authResultsOriginal'` give the rest.
+
+From lib/services/mimecast-blast-radius.ts:
+```typescript
+export async function getBlastRadius(input: BlastRadiusInput): Promise;
+export type BlastRadiusResult =
+ | { status: 'unavailable'; reason: 'not_configured' | 'lookup_failed'; error?: string }
+ | { status: 'ok'; matched: number; delivered: number; held: number; rejected: number;
+ clicked: number; perRecipient: Array<{ recipient: string; status: 'delivered'|'held'|'rejected'|'unknown' }>; source: 'fan-out' };
+// BlastRadiusInput = { sender, recipient, subject, dateWindow: { start: Date; end: Date } }
+```
+
+classifications table (migrations/097_phishing_triage_schema.sql):
+```sql
+classifications (id UUID PK, campaign_id UUID, verdict TEXT, confidence NUMERIC,
+ summary TEXT, reasons JSONB, recommended_actions JSONB,
+ requires_approval BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ)
+```
+
+messages/indicators/reports/campaigns columns:
+```sql
+messages (id, report_id, message_id, headers JSONB, urls JSONB, attachments JSONB, body_preview, raw_ref, created_at)
+indicators (id, message_id, indicator_type TEXT, value TEXT, created_at) -- indicator_type ∈ 'attachment_hash'|'url'|'sender'
+reports (id, ticket_id, ticket_number, company_name, title, requester_contact_id, campaign_id, created_at)
+campaigns (id, campaign_key, group_method, first_seen_at, last_seen_at, report_count, status, created_at, updated_at)
+```
+
+
+
+
+
+
+ Task 1: Failing tests + fixtures + pure rule functions (RED→GREEN)
+ lib/services/campaign-classifier.test.ts, lib/services/campaign-classifier.fixtures.ts, lib/services/campaign-classifier.ts
+
+ - lib/services/robotic-classifier.ts (evaluateContains: .toLowerCase().includes() only, no regex/eval — the D-01 rule-eval style to mirror)
+ - lib/services/phishing-detector.ts (KNOWN_PHISHING_PATTERNS: the locked readonly TS-constant shape to mirror for KNOWN_SIMULATION_SENDERS — do NOT reuse/extend that constant, it is a different signal)
+ - lib/services/eml-parser.ts (AuthResults, AuthVerdict, NormalizedMessage exact shapes)
+ - lib/services/mimecast-blast-radius.ts (BlastRadiusResult discriminated union — the evidence-completeness precedent)
+ - lib/services/campaign-grouping-service.test.ts (vi.mock('./postgres-client') factory + eslint-disable import/first hoisting discipline)
+ - lib/services/mimecast-blast-radius.test.ts (vi.mock of a sibling service module — pattern for mocking getBlastRadius)
+ - lib/services/eml-parser.fixtures.ts (synthetic-fixture convention — no real customer data)
+ - .planning/phases/19-classification-engine/19-RESEARCH.md (Code Examples: confidence weights, action mapping, allowlist, auth precedence)
+
+
+ - computeRequiresApproval(['disable_forwarding_rule']) === false (non-destructive alone)
+ - computeRequiresApproval(['disable_forwarding_rule','block_sender']) === true (OR'd across actions)
+ - computeRequiresApproval(['block_sender']) / ['purge_message'] / ['reset_password'] / ['isolate_endpoint'] each === true
+ - computeRequiresApproval(['no_action']) === false; (['warn_user']) === false
+ - domainMatchesAllowlist('it-support.care') === true; ('sub.it-support.care') === true; ('em8721.breachsecurenow.com') === true (proper subdomain suffix)
+ - domainMatchesAllowlist('it-support.care.attacker.net') === false; ('evil-it-support.care') === false (no bare substring match — T-19-01)
+ - isKnownSimulationSender matches when From.domain is null but Return-Path domain matches (Pitfall 3 — check BOTH)
+ - effectiveAuthResults returns authResultsOriginal when present, authResults when authResultsOriginal is null (Pitfall 1)
+ - hasHardAuthFail true iff spf==='fail' OR dkim==='fail' OR dmarc==='fail' (never on 'none'/'neutral'/undefined)
+ - computeConfidence: baseline 1.0; -0.4 no message parsed; -0.3 blastRadius.status!=='ok'; -0.2 no attachment/url indicators; each deduction pushes a named reason; all-missing floors at 0.10; rounds to 2 decimals
+ - mapVerdictToActions('SPAM',_)===['no_action']; ('UNWANTED',_)===['warn_user']; ('THREAT',{clicked:0})===['block_sender','purge_message']; ('THREAT',{clicked:1}) additionally includes 'reset_password','isolate_endpoint','disable_forwarding_rule'
+
+
+ Write `campaign-classifier.test.ts` FIRST (RED), then `campaign-classifier.fixtures.ts`, then implement the pure functions in `campaign-classifier.ts` until green (GREEN).
+
+ Fixtures (`campaign-classifier.fixtures.ts`, synthetic only — no real customer email): export a KnowBe4 fixture message with `from.domain='it-support.care'` and a BSN fixture with `from.domain=null, returnPath='bounces...@em8721.breachsecurenow.com'`. EACH fixture sets `authResults = { spf:'fail', dkim:'fail', dmarc:'fail' }` but `authResultsOriginal = { spf:'pass', dkim:'pass', dmarc:'pass' }` — this reproduces the forwarding-induced auth-verdict inversion (Pitfall 1) so the simulation-not-THREAT test proves both the allowlist short-circuit AND the authResultsOriginal precedence.
+
+ Implement in `campaign-classifier.ts` as bare exported functions (no class, no getInstance — mirror groupReportIntoCampaign's module shape). Use the named import `import { postgresClient } from './postgres-client';` and sibling imports `import { getBlastRadius, type BlastRadiusResult } from './mimecast-blast-radius';` / `import type { NormalizedMessage, AuthResults } from './eml-parser';`.
+
+ Constants and functions to define:
+ - `export const KNOWN_SIMULATION_SENDERS: readonly { vendor: string; domains: readonly string[] }[]` seeded with `{ vendor:'knowbe4', domains:['it-support.care'] }` and `{ vendor:'breach-secure-now', domains:['breachsecurenow.com'] }`. Include a doc-comment citing 19-RESEARCH.md D-07 findings and stating the list is NOT exhaustive and should be refreshed from new ticket evidence (Pitfall 4).
+ - `domainMatchesAllowlist(domain)`: lowercases, matches `d === allowed || d.endsWith('.' + allowed)` ONLY — never `.includes()` (T-19-01 spoofing guard).
+ - `isKnownSimulationSender(msg)`: collect `msg.from.domain` and the domain-part of `msg.returnPath` (local `.split('@')[1] ?? null`), filter nulls, return true if any matches the allowlist (Pitfall 3).
+ - `effectiveAuthResults(headers)`: `return headers.authResultsOriginal ?? headers.authResults` (Pitfall 1).
+ - `hasHardAuthFail(auth)`: `auth.spf==='fail' || auth.dkim==='fail' || auth.dmarc==='fail'`.
+ - `computeConfidence(flags)`: additive-from-1.0 per D-05 with weights 0.4 / 0.3 / 0.2, each named in reasons, `Math.round(c*100)/100`.
+ - `const DESTRUCTIVE_ACTIONS = new Set(['block_sender','purge_message','reset_password','isolate_endpoint'])`.
+ - `mapVerdictToActions(verdict, evidence)` per the behavior block. `computeRequiresApproval(actions)` = `actions.some(a => DESTRUCTIVE_ACTIONS.has(a))`.
+
+ Each pure function gets its own `describe` block with direct input/output assertions (no DB/Mimecast mocks needed for these). Do NOT implement `classifyCampaign`/`gatherCampaignEvidence` yet — Task 2 covers those; leave stubs or omit until then.
+
+
+ npx vitest run lib/services/campaign-classifier.test.ts -t "requires_approval invariant"
+ npx vitest run lib/services/campaign-classifier.test.ts -t "confidence deduction"
+ npx tsc --noEmit --pretty
+
+
+ - `npx vitest run lib/services/campaign-classifier.test.ts` passes for all pure-function describe blocks
+ - Test asserts `computeRequiresApproval(['disable_forwarding_rule']) === false` AND `computeRequiresApproval(['disable_forwarding_rule','block_sender']) === true` (CLASSIFY-02 invariant + counter-case)
+ - Test asserts `domainMatchesAllowlist('it-support.care.attacker.net') === false` (T-19-01 no-substring-match)
+ - Test asserts `effectiveAuthResults` returns the `authResultsOriginal` object when present (Pitfall 1)
+ - Test asserts all-three-missing confidence === 0.10 with 3 named reason strings (CLASSIFY-03)
+ - `grep -n "KNOWN_SIMULATION_SENDERS" lib/services/campaign-classifier.ts` shows the constant with both `it-support.care` and `breachsecurenow.com`
+ - `grep -c "\.includes(" lib/services/campaign-classifier.ts` shows domain matching does NOT use `.includes(` for allowlist comparison (endsWith only)
+ - `npx tsc --noEmit --pretty` exits 0
+
+ All pure rule functions implemented and unit-tested green; fixtures exist with the authResultsOriginal-pass/authResults-fail inversion; tsc clean.
+
+
+
+ Task 2: classifyCampaign orchestrator + evidence gathering + append-only INSERT (RED→GREEN)
+ lib/services/campaign-classifier.ts, lib/services/campaign-classifier.test.ts
+
+ - lib/services/campaign-classifier.ts (current state from Task 1 — pure functions to compose)
+ - lib/services/campaign-grouping-service.ts (evidence gather + ORDER BY r.created_at ASC "earliest report is canonical" convention; catch-log-rethrow orchestrator boundary; [CAMPAIGN-GROUPING] log prefix style)
+ - app/api/phishing/campaigns/[id]/route.ts (bulk-fetch-by-id-array shape: reportIds → messages via report_id=ANY, messageIds → indicators via message_id=ANY; headers->>'subject')
+ - migrations/097_phishing_triage_schema.sql (classifications INSERT columns; messages/indicators/reports/campaigns columns)
+ - lib/services/eml-parser.ts (NormalizedMessage header shape stored in messages.headers JSONB)
+ - lib/services/mimecast-blast-radius.ts (getBlastRadius input shape)
+ - .planning/phases/19-classification-engine/19-RESEARCH.md (gatherCampaignEvidence Code Example, evaluation order, known-bad-indicator interpretation)
+
+
+ - classifyCampaign returns an object with verdict ∈ {'SPAM','UNWANTED','THREAT'}, numeric confidence, string summary, string[] reasons, string[] recommendedActions, boolean requiresApproval, string id, string campaignId, string createdAt (CLASSIFY-01)
+ - Simulation fixture (it-support.care OR breachsecurenow.com sender) with authResults fail but authResultsOriginal pass + delivered>0: verdict is SPAM or UNWANTED, NEVER THREAT (CLASSIFY-04 — allowlist short-circuits before THREAT tier)
+ - THREAT only when blastRadius.status==='ok' AND (delivered>0 OR clicked>0) AND (hasHardAuthFail(effectiveAuthResults) on some message OR a known-bad indicator match) AND sender NOT on allowlist (D-03)
+ - Known-bad indicator match = same attachment_hash or url value on ≥2 distinct messages in the campaign (cross-report correlation — no external reputation lookup) (D-03 interpretation, research A4)
+ - When blastRadius.status!=='ok', or no message parsed, or no indicators: confidence < 1.0 and the specific source named in reasons (CLASSIFY-03)
+ - Persisted reasons/summary contain no raw email body text; evidence arrays are capped (CLASSIFY-06)
+ - Exactly one new classifications row INSERTed per call (append-only, no ON CONFLICT) — verified via the mocked query call args (D-02)
+
+
+ Extend `campaign-classifier.test.ts` with a `describe('classifyCampaign', ...)` block using `vi.mock('./postgres-client')` (queryMock, matching campaign-grouping-service.test.ts) and `vi.mock('./mimecast-blast-radius', () => ({ getBlastRadius: (...) => getBlastRadiusMock(...) }))`. Route mock query responses by distinguishing SQL substring per call (`FROM campaigns`, `FROM reports`, `FROM messages`, `FROM indicators`, `INSERT INTO classifications`) — NOT by call order. Add the named tests: "returns exactly one verdict", "simulation allowlist" (both it-support.care and breachsecurenow.com fixtures, delivered>0, authResults fail → assert verdict !== 'THREAT'), "evidence bounding" (feed a campaign with many indicators; assert persisted reasons is a short bounded array and no reason string contains a raw body). Write these RED first.
+
+ Then implement in `campaign-classifier.ts`:
+ - `interface ClassifyResult { id: string; campaignId: string; verdict: 'SPAM'|'UNWANTED'|'THREAT'; confidence: number; summary: string; reasons: string[]; recommendedActions: string[]; requiresApproval: boolean; createdAt: string }` (export it).
+ - `gatherCampaignEvidence(campaignId)`: SELECT campaign row; SELECT reports `WHERE campaign_id=$1 ORDER BY created_at ASC` (LEFT JOIN contacts for requester_email); bulk-fetch messages `WHERE report_id = ANY($1::uuid[])` selecting `id, report_id, headers, message_id`; bulk-fetch indicators `WHERE message_id = ANY($1::uuid[])` selecting `id, message_id, indicator_type, value`. Parse `headers` JSONB into the NormalizedMessage-shaped fields needed (from.domain, returnPath, authResults, authResultsOriginal, subject). Use the EARLIEST report as canonical sender/subject/date-window for one `getBlastRadius()` call (±24h window around report.created_at); when no report exists, synthesize `{ status:'unavailable', reason:'not_configured' }` without calling Mimecast (research A6). Return a BOUNDED payload: counts + capped sample arrays (cap indicator/report samples at 10), never raw body text (CLASSIFY-06 / T-19-03 DoS guard).
+ - `classifyCampaign(campaignId)`: gather evidence → `if isKnownSimulationSender(any message) → skip THREAT, run evaluateSpamVsUnwanted only (D-06 short-circuit)` → else `evaluateThreatTier` (D-03) → else `evaluateSpamVsUnwanted` (D-04, SPAM if no suspicious signal, UNWANTED if a suspicious signal present but below THREAT bar) → `computeConfidence` → `mapVerdictToActions` → `computeRequiresApproval` → build a short `summary` string naming the verdict and top reason → INSERT one classifications row `RETURNING id::text AS id, created_at::text AS created_at` with `reasons`/`recommended_actions` as `JSON.stringify(...)::jsonb` → return ClassifyResult. Wrap the orchestrator body in try/catch that logs `[CAMPAIGN-CLASSIFIER]` + err.message (never full payloads — T-17-style) and rethrows.
+ - Known-bad indicator match computed over the gathered indicators: group by (indicator_type, value) for attachment_hash/url types, flag true if any value spans ≥2 distinct message_id.
+
+
+ npx vitest run lib/services/campaign-classifier.test.ts -t "returns exactly one verdict"
+ npx vitest run lib/services/campaign-classifier.test.ts -t "simulation allowlist"
+ npx vitest run lib/services/campaign-classifier.test.ts -t "evidence bounding"
+ npx vitest run lib/services/campaign-classifier.test.ts
+ npx tsc --noEmit --pretty
+
+
+ - `npx vitest run lib/services/campaign-classifier.test.ts` passes all describe blocks including classifyCampaign
+ - "simulation allowlist" test passes for BOTH it-support.care (From match) and breachsecurenow.com (Return-Path match, From.domain null) fixtures, each asserting `result.verdict !== 'THREAT'` despite `authResults` hard-fail and delivered>0
+ - "returns exactly one verdict" asserts result.verdict is one of the three literals and all six payload fields are present with correct types (CLASSIFY-01)
+ - Mock asserts exactly one `INSERT INTO classifications` query and that it contains no `ON CONFLICT` (append-only, D-02)
+ - `grep -n "authResultsOriginal" lib/services/campaign-classifier.ts` shows the precedence is applied inside the THREAT-tier path
+ - `grep -n "getBlastRadius" lib/services/campaign-classifier.ts` shows exactly one call site guarded by "earliest report exists"
+ - `npm test` full suite green; `npx tsc --noEmit --pretty` exits 0
+
+ classifyCampaign orchestrates evidence→rules→confidence→actions→append-only INSERT; simulation short-circuit and authResultsOriginal precedence proven by tests; full suite + tsc green.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| stored evidence → classifier | Attacker-controlled email content (headers, sender domain) already persisted in messages/indicators crosses into verdict logic |
+| classifier → classifications table | Verdict/approval flag written; a wrong flag could later let Phase 20 gate a destructive action incorrectly |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-19-01 | Spoofing | domainMatchesAllowlist | mitigate | Match exact-domain-or-proper-subdomain only (`d===allowed \|\| d.endsWith('.'+allowed)`); never `.includes()` substring — asserted by a test that `it-support.care.attacker.net` does NOT match |
+| T-19-02 | Tampering (logic drift) | D-03/D-04/D-06 rule functions | mitigate | Pure, unit-tested TS functions + TS-constant allowlist (not a live-editable DB table); every rule covered by a dedicated test |
+| T-19-03 | Denial of Service | gatherCampaignEvidence / persisted reasons | mitigate | Bounded evidence payload — counts + sample arrays capped at 10, never full report/message/indicator sets or raw body text embedded verbatim (doubles as CLASSIFY-06) |
+| T-19-06 | Spoofing (auth-verdict inversion) | effectiveAuthResults | mitigate | Prefer authResultsOriginal over post-forward authResults so a forwarded genuine simulation isn't pushed to THREAT on an invalidated DKIM signature (Pitfall 1) |
+| T-19-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed this phase (classifier consumes only shipped project code + Node built-ins); Package Legitimacy Gate N/A per 19-RESEARCH.md |
+
+
+
+- `npx vitest run lib/services/campaign-classifier.test.ts` — all describe blocks green
+- `npm test` — full suite green (no regression in campaign-grouping / mimecast / eml-parser)
+- `npx tsc --noEmit --pretty` — clean
+- CLASSIFY-01/02/03/04/06 each proven by a named test per 19-VALIDATION.md Per-Task Verification Map
+
+
+
+- classifyCampaign returns exactly one verdict with confidence + summary + reasons + recommendedActions + requiresApproval (CLASSIFY-01)
+- destructive action ⇒ requiresApproval:true; disable_forwarding_rule alone ⇒ false (CLASSIFY-02)
+- incomplete evidence lowers confidence and names the missing source (CLASSIFY-03)
+- it-support.care / breachsecurenow.com simulation fixtures never classified THREAT absent contrary evidence (CLASSIFY-04)
+- evidence is bounded/structured — no raw unbounded body reaches classifier/reasons (CLASSIFY-06)
+
+
+
diff --git a/.planning/phases/19-classification-engine/19-02-PLAN.md b/.planning/phases/19-classification-engine/19-02-PLAN.md
new file mode 100644
index 0000000..7d718da
--- /dev/null
+++ b/.planning/phases/19-classification-engine/19-02-PLAN.md
@@ -0,0 +1,156 @@
+---
+phase: 19-classification-engine
+plan: 02
+type: execute
+wave: 2
+depends_on: ["19-01"]
+files_modified:
+ - app/api/phishing/campaigns/[id]/classify/route.ts
+autonomous: true
+requirements: [CLASSIFY-05]
+
+must_haves:
+ truths:
+ - "POST /api/phishing/campaigns/{id}/classify calls requirePermission('phishing','analyze') and rejects unauthenticated (401) / unauthorized (403) requests"
+ - "A malformed (non-UUID) campaign id returns 400 before any DB query"
+ - "A valid authorized request runs classifyCampaign and returns the verdict payload (id, campaignId, verdict, confidence, summary, reasons, recommendedActions, requiresApproval)"
+ - "An unknown campaign id returns 404"
+ artifacts:
+ - path: "app/api/phishing/campaigns/[id]/classify/route.ts"
+ provides: "POST classify route handler"
+ exports: ["POST"]
+ min_lines: 30
+ key_links:
+ - from: "app/api/phishing/campaigns/[id]/classify/route.ts"
+ to: "requirePermission('phishing','analyze')"
+ via: "auth-utils early-return"
+ pattern: "requirePermission\\('phishing', 'analyze'\\)"
+ - from: "app/api/phishing/campaigns/[id]/classify/route.ts"
+ to: "classifyCampaign"
+ via: "import from @/lib/services/campaign-classifier"
+ pattern: "classifyCampaign"
+---
+
+
+Add the `POST /api/phishing/campaigns/{id}/classify` route — the on-demand (re-)trigger for
+campaign classification (D-02). It enforces the Phase 18 auth convention (`requirePermission('phishing','analyze')`,
+the SAME action as `/analyze`, not a new permission), validates the campaign id as a UUID (V5),
+delegates to `classifyCampaign(id)` from Plan 01, and returns the flat camelCase verdict payload.
+
+Purpose: An operator can (re-)classify a campaign through a properly access-controlled endpoint.
+Output: `app/api/phishing/campaigns/[id]/classify/route.ts`.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/19-classification-engine/19-CONTEXT.md
+@.planning/phases/19-classification-engine/19-PATTERNS.md
+@.planning/phases/19-classification-engine/19-VALIDATION.md
+
+
+
+From lib/services/campaign-classifier.ts:
+```typescript
+export interface ClassifyResult {
+ id: string; campaignId: string;
+ verdict: 'SPAM' | 'UNWANTED' | 'THREAT';
+ confidence: number; summary: string;
+ reasons: string[]; recommendedActions: string[];
+ requiresApproval: boolean; createdAt: string;
+}
+export function classifyCampaign(campaignId: string): Promise;
+```
+
+Auth (lib/permissions.ts): `phishing: ["read","analyze","approve","remediate"]`; `analyze`
+is granted to super-admin + admin only (a plain `user` role has `read` only → expect 403).
+
+UUID guard (from app/api/phishing/campaigns/[id]/route.ts):
+```typescript
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+```
+
+
+
+
+
+
+ Task 1: POST /api/phishing/campaigns/[id]/classify route handler
+ app/api/phishing/campaigns/[id]/classify/route.ts
+
+ - app/api/phishing/tickets/[ticket_id]/analyze/route.ts (exact auth early-return: `const { error } = await requirePermission('phishing','analyze'); if (error) return error;`; try/catch → 500 with message; [PHISHING-ANALYZE] log-prefix convention)
+ - app/api/phishing/campaigns/[id]/route.ts (UUID_RE guard + 400; params is Promise<{ id: string }>; flat camelCase response shape; [PHISHING-CAMPAIGN-DETAIL] prefix; 404 when campaign not found)
+ - lib/permissions.ts (confirm phishing 'analyze' action already exists — do NOT add a new action)
+ - lib/services/campaign-classifier.ts (classifyCampaign signature + ClassifyResult from Plan 01)
+ - .planning/phases/19-classification-engine/19-PATTERNS.md (route/controller section — verbatim analog mapping)
+
+
+ Create `app/api/phishing/campaigns/[id]/classify/route.ts` exporting `async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> })`.
+
+ Body, in order:
+ 1. `const { error } = await requirePermission('phishing', 'analyze'); if (error) return error;` — same `'analyze'` action as the existing `/analyze` route (Phase 18 D-06; T-19-04 access-control). Import `requirePermission` from `@/lib/auth-utils`.
+ 2. `const { id } = await params;` then the `UUID_RE` guard copied verbatim from `campaigns/[id]/route.ts` — return `NextResponse.json({ error: 'Invalid campaign id' }, { status: 400 })` on failure, BEFORE any DB access (V5 / T-19-05 tampering guard). Use `campaigns/[id]/route.ts`'s UUID regex, NOT `analyze/route.ts`'s numeric `Number.isFinite` check (this id is a campaign UUID, not a ticket_id).
+ 3. `try { ... } catch (err) { console.error('[PHISHING-CLASSIFY] Failed to classify campaign', id, err); return NextResponse.json({ error: 'Failed to classify campaign', message: err instanceof Error ? err.message : 'Unknown error' }, { status: 500 }); }`.
+ 4. Inside try: verify the campaign exists first — `SELECT id FROM campaigns WHERE id = $1` (postgresClient default import from `@/lib/services/postgres-client`); if no row, return `NextResponse.json({ error: 'Campaign not found' }, { status: 404 })` (mirrors campaigns/[id]/route.ts 404). Then `const result = await classifyCampaign(id);` (import from `@/lib/services/campaign-classifier`) and `return NextResponse.json(result);` — `ClassifyResult` is already flat camelCase, no re-mapping needed.
+
+ Use path-alias `@/lib/...` imports (route-handler convention), NOT the sibling-filename imports used inside lib/services. Do not add the route to middleware public paths — `/api/phishing/*` is authenticated (this is intentional, T-19-04).
+
+
+ npx tsc --noEmit --pretty
+ test -f "app/api/phishing/campaigns/[id]/classify/route.ts" && grep -q "requirePermission('phishing', 'analyze')" "app/api/phishing/campaigns/[id]/classify/route.ts" && echo AUTH_OK
+
+ Against a dev server (npm run dev, port 3100) with a known campaign UUID:
+ 1. `curl -X POST http://localhost:3100/api/phishing/campaigns/{uuid}/classify` with a valid admin/super-admin session cookie → 200 with verdict/confidence/summary/reasons/recommendedActions/requiresApproval
+ 2. Same with NO session cookie → 401
+ 3. Same with a plain `user`-role session cookie → 403 (analyze not granted to user)
+ 4. `curl -X POST http://localhost:3100/api/phishing/campaigns/not-a-uuid/classify` (authed) → 400
+ 5. Valid UUID that is not a real campaign → 404
+
+
+
+ - `npx tsc --noEmit --pretty` exits 0
+ - `grep -c "requirePermission('phishing', 'analyze')" "app/api/phishing/campaigns/[id]/classify/route.ts"` === 1 (T-19-04)
+ - `grep -q "UUID_RE" "app/api/phishing/campaigns/[id]/classify/route.ts"` — UUID guard present before DB access (T-19-05)
+ - `grep -q "classifyCampaign" "app/api/phishing/campaigns/[id]/classify/route.ts"` — delegates to the Plan 01 service
+ - Manual curl (human-check): 200 for authed admin, 401 no-session, 403 user-role, 400 malformed id, 404 unknown campaign
+
+ Route compiles, enforces analyze permission + UUID validation, delegates to classifyCampaign, and returns the verdict payload; manual auth curl matrix passes.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| client → API route | Unauthenticated/unauthorized HTTP request + attacker-controlled `id` path param crosses into the handler |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-19-04 | Elevation of Privilege | POST /classify handler | mitigate | `requirePermission('phishing','analyze')` early-return before any work; `analyze` granted only to admin/super-admin (V4 access control, server-side not just middleware cookie check) |
+| T-19-05 | Tampering | `id` path param | mitigate | `UUID_RE` shape validation returns 400 before any query, preventing a malformed id from surfacing as an uncaught Postgres 500 (V5 input validation) |
+| T-19-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed this phase; Package Legitimacy Gate N/A per 19-RESEARCH.md |
+
+
+
+- `npx tsc --noEmit --pretty` — clean (route-handler tests are not this repo's convention; vitest.config does not scan app/**)
+- Manual curl auth matrix (401 / 403 / 400 / 404 / 200) per 19-VALIDATION.md Manual-Only Verifications
+- Route follows the ACCESS-01 carry-forward convention established in Phase 18
+
+
+
+- POST /api/phishing/campaigns/{id}/classify enforces requirePermission('phishing','analyze'), validates UUID, delegates to classifyCampaign, returns the verdict payload; rejects unauthenticated/unauthorized/malformed requests (CLASSIFY-05)
+
+
+