---
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"
    - "The classifier makes zero LLM/Anthropic/OpenRouter calls — pure deterministic rule evaluation only, same style as robotic-classifier.ts (D-01)"
    - "Each classify call appends exactly one new classifications row via INSERT with no ON CONFLICT — history is never overwritten, matching the on-demand-only trigger model (D-02)"
    - "Any recommendedActions entry in the destructive set (block_sender, purge_message, reset_password, isolate_endpoint) forces requiresApproval:true; disable_forwarding_rule alone does NOT (D-08)"
    - "Incomplete evidence lowers confidence below 1.0 and names each specific missing source in reasons (D-05)"
    - "A message whose From/Return-Path domain matches KNOWN_SIMULATION_SENDERS is never classified THREAT even when authResults shows a hard fail (D-06)"
    - "THREAT-tier auth-fail check reads authResultsOriginal first, falling back to authResults only when null"
    - "A non-simulation campaign with blastRadius.status==='ok', delivered>0, and an effective hard auth fail is classified THREAT and recommends destructive actions (positive-path THREAT — D-03 both-conditions-met, proven by a real-signal fixture)"
    - "The known-bad-indicator OR-branch of D-03 fires THREAT when the same attachment_hash/url value spans >=2 distinct messages in the campaign (even without a hard auth fail), given delivered>0 and a non-allowlist sender"
    - "A clean non-simulation campaign (auth pass, no indicators, delivery contained / no delivery-or-click signal) is classified SPAM; a suspicious-but-contained campaign (one indicator match, or delivery contained to the reporter only) is classified UNWANTED — proving the D-04 SPAM/UNWANTED boundary is actually implemented (an always-SPAM stub must fail this)"
    - "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, including positive-path THREAT + SPAM/UNWANTED-split verdict assertions"
      contains: "requires_approval invariant"
    - path: "lib/services/campaign-classifier.fixtures.ts"
      provides: "Synthetic KnowBe4 (it-support.care) + BSN (breachsecurenow.com) simulation fixtures AND non-simulation THREAT / clean-SPAM / suspicious-UNWANTED fixtures"
      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"
---

<objective>
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`.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.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

<interfaces>
<!-- Contracts consumed by this plan. Use directly — no codebase exploration needed. -->

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<BlastRadiusResult>;
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)
```
</interfaces>
</context>

<tasks>

<task type="tdd" tdd="true">
  <name>Task 1: Failing tests + fixtures + pure rule functions (RED→GREEN)</name>
  <files>lib/services/campaign-classifier.test.ts, lib/services/campaign-classifier.fixtures.ts, lib/services/campaign-classifier.ts</files>
  <read_first>
    - 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)
  </read_first>
  <behavior>
    - 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'
  </behavior>
  <action>
    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 the following named NormalizedMessage-shaped fixtures (all synthetic; Task 2's classifyCampaign tests re-use them by feeding them through the mocked `messages.headers` JSONB path):
    - `knowbe4SimMessage`: `from.domain='it-support.care'`, `authResults = { spf:'fail', dkim:'fail', dmarc:'fail' }`, `authResultsOriginal = { spf:'pass', dkim:'pass', dmarc:'pass' }`.
    - `bsnSimMessage`: `from.domain=null`, `returnPath='bounces...@em8721.breachsecurenow.com'`, same authResults-fail / authResultsOriginal-pass inversion.
      (Both simulation fixtures reproduce the forwarding-induced auth-verdict inversion (Pitfall 1) so the simulation-not-THREAT test proves BOTH the allowlist short-circuit AND the authResultsOriginal precedence.)
    - `threatMessage`: a NON-allowlist attacker-style sender (e.g. `from.domain='mlcrosoft.live'` — a real typosquat flavor per 19-RESEARCH.md D-07 finding #3), `authResults = { spf:'fail', dkim:'fail', dmarc:'fail' }`, `authResultsOriginal = null` (so effectiveAuthResults still yields the hard fail). Used with a blast-radius `{status:'ok', delivered:3, clicked:0}` to drive the positive-path THREAT verdict.
    - `cleanSpamMessage`: a NON-allowlist bulk/newsletter sender (e.g. `from.domain='mail.example-newsletter.com'`), `authResults = { spf:'pass', dkim:'pass', dmarc:'pass' }`, `authResultsOriginal = null`, no attachment/url indicators. Used with a blast-radius showing NO delivery/click signal (e.g. `{status:'ok', delivered:0, held:1, clicked:0}`) → expected SPAM.
    - `suspiciousUnwantedMessage`: a NON-allowlist sender (e.g. `from.domain='promo.some-vendor.net'`) with `authResults = { spf:'pass', dkim:'pass', dmarc:'pass' }`, `authResultsOriginal = null`, paired in Task 2 with exactly ONE url indicator (a single suspicious signal, NOT shared across ≥2 messages) and blast-radius delivery contained to the reporter only (`{status:'ok', delivered:1, clicked:0}` with a single perRecipient entry) → expected UNWANTED (suspicious-but-contained, below the THREAT bar per D-04).

    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))`.

    ASSUMPTION FLAG (WARNING from checker — surface to user at/after execution): the `clicked > 0` trigger that escalates a THREAT verdict's actions to include `reset_password` / `isolate_endpoint` / `disable_forwarding_rule` is a REASONED research proposal (19-RESEARCH.md Open Question #1 + Assumptions A-tier), NOT an explicit CONTEXT.md decision — CONTEXT.md D-08 only locks the vocabulary and the OR'd approval invariant, and delegated finer action-mapping to "Claude's Discretion". This choice materially affects what Phase 20 gates approval on, so the executor should note it in the SUMMARY and it is worth a quick user confirmation. It does NOT contradict any locked decision or the CLASSIFY-02 invariant, so it is not a blocker.

    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.
  </action>
  <verify>
    <automated>npx vitest run lib/services/campaign-classifier.test.ts -t "requires_approval invariant"</automated>
    <automated>npx vitest run lib/services/campaign-classifier.test.ts -t "confidence deduction"</automated>
    <automated>npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - `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)
    - Fixtures file exports `threatMessage`, `cleanSpamMessage`, and `suspiciousUnwantedMessage` (non-simulation fixtures) in addition to the two simulation fixtures — verify via `grep -n "threatMessage\|cleanSpamMessage\|suspiciousUnwantedMessage" lib/services/campaign-classifier.fixtures.ts`
    - `npx tsc --noEmit --pretty` exits 0
  </acceptance_criteria>
  <done>All pure rule functions implemented and unit-tested green; simulation + non-simulation (threat/clean/suspicious) fixtures exist with the authResultsOriginal precedence coverage; tsc clean.</done>
</task>

<task type="tdd" tdd="true">
  <name>Task 2: classifyCampaign orchestrator + evidence gathering + append-only INSERT (RED→GREEN)</name>
  <files>lib/services/campaign-classifier.ts, lib/services/campaign-classifier.test.ts</files>
  <read_first>
    - lib/services/campaign-classifier.ts (current state from Task 1 — pure functions + fixtures to compose)
    - lib/services/campaign-classifier.fixtures.ts (simulation + threat/clean/suspicious fixtures from Task 1)
    - 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)
  </read_first>
  <behavior>
    - 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)
    - POSITIVE-PATH THREAT (D-03 both conditions): non-allowlist `threatMessage` sender + blastRadius.status==='ok' with delivered>0 + effective hard auth fail → verdict === 'THREAT' AND recommendedActions includes at least 'block_sender' and 'purge_message' (a broken/always-SPAM stub MUST fail this)
    - THREAT known-bad-indicator OR-branch: non-allowlist sender + delivered>0 + NO hard auth fail (auth pass) BUT the same attachment_hash/url value on ≥2 distinct messages → verdict === 'THREAT' (D-03 second signal, research A4)
    - SPAM vs UNWANTED split (D-04): `cleanSpamMessage` (auth pass, no indicators, no delivery/click signal) → verdict === 'SPAM'; `suspiciousUnwantedMessage` (auth pass, exactly one url indicator not shared across messages, delivery contained to the reporter only) → verdict === 'UNWANTED'. Two distinct fixtures prove the boundary is implemented, not stubbed.
    - 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)
  </behavior>
  <action>
    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. Build a small local helper that turns a fixture NormalizedMessage into a `messages` row (`{ id, report_id, headers: <fixture> }`) so the same fixtures drive the orchestrator.

    Write these named tests RED first, then implement until green:
    - `"returns exactly one verdict"` — assert result.verdict is one of the three literals and all payload fields present with correct types (CLASSIFY-01).
    - `"simulation allowlist"` — BOTH it-support.care (From match) and breachsecurenow.com (Return-Path match, From.domain null) fixtures, delivered>0, authResults fail → assert `result.verdict !== 'THREAT'` for each.
    - `"threat tier"` — feed `threatMessage` with `getBlastRadiusMock` → `{status:'ok', delivered:3, clicked:0}`; assert `result.verdict === 'THREAT'` and `result.recommendedActions` includes 'block_sender' and 'purge_message' and `result.requiresApproval === true` (positive-path THREAT — closes the blocker: proves classifyCampaign CAN return THREAT for a real signal).
    - `"threat tier known-bad indicator"` — non-allowlist sender across 2 messages sharing one url indicator value, auth PASS, delivered>0, no hard auth fail → assert `result.verdict === 'THREAT'` (D-03 OR-branch, no auth-fail path).
    - `"spam vs unwanted tier"` — TWO sub-cases: `cleanSpamMessage` + no-delivery/click blast radius → `result.verdict === 'SPAM'`; `suspiciousUnwantedMessage` + single url indicator + delivery-contained-to-reporter blast radius → `result.verdict === 'UNWANTED'` (proves D-04 boundary is real, not an always-SPAM stub).
    - `"evidence bounding"` — feed a campaign with many indicators; assert persisted reasons is a short bounded array and no reason string contains a raw body (CLASSIFY-06).

    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: blastRadius.status==='ok' AND (delivered>0 OR clicked>0) AND (hasHardAuthFail on effectiveAuthResults of some message OR known-bad-indicator match)) → else `evaluateSpamVsUnwanted` (D-04, SPAM if no suspicious signal, UNWANTED if a suspicious signal present — one indicator match, a mild auth issue, or delivery contained to the reporter — but below the 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.
  </action>
  <verify>
    <automated>npx vitest run lib/services/campaign-classifier.test.ts -t "returns exactly one verdict"</automated>
    <automated>npx vitest run lib/services/campaign-classifier.test.ts -t "simulation allowlist"</automated>
    <automated>npx vitest run lib/services/campaign-classifier.test.ts -t "threat tier"</automated>
    <automated>npx vitest run lib/services/campaign-classifier.test.ts -t "spam vs unwanted tier"</automated>
    <automated>npx vitest run lib/services/campaign-classifier.test.ts -t "evidence bounding"</automated>
    <automated>npx vitest run lib/services/campaign-classifier.test.ts</automated>
    <automated>npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - `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
    - "threat tier" test asserts `result.verdict === 'THREAT'`, `recommendedActions` includes 'block_sender' + 'purge_message', and `requiresApproval === true` for the non-allowlist real-signal fixture (positive-path THREAT — the previously-missing coverage; an always-SPAM stub fails here)
    - "threat tier known-bad indicator" asserts THREAT fires on the ≥2-message shared-indicator OR-branch with auth PASS (D-03 second signal)
    - "spam vs unwanted tier" asserts `cleanSpamMessage → 'SPAM'` AND `suspiciousUnwantedMessage → 'UNWANTED'` (D-04 boundary proven; an always-SPAM stub fails the UNWANTED case)
    - "returns exactly one verdict" asserts result.verdict is one of the three literals and all 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
  </acceptance_criteria>
  <done>classifyCampaign orchestrates evidence→rules→confidence→actions→append-only INSERT; simulation short-circuit, positive-path THREAT, D-03 indicator OR-branch, and the D-04 SPAM/UNWANTED split all proven by named tests; full suite + tsc green.</done>
</task>

</tasks>

<threat_model>
## 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 — including positive-path THREAT and the SPAM/UNWANTED split, so a silently-weakened verdict path is caught |
| 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 |
</threat_model>

<verification>
- `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; verdict logic (D-03 THREAT positive path + D-04 SPAM/UNWANTED split) proven by the "threat tier" / "threat tier known-bad indicator" / "spam vs unwanted tier" named tests
</verification>

<success_criteria>
- 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)
- a real-signal non-simulation campaign IS classified THREAT with destructive actions, and the SPAM-vs-UNWANTED boundary is exercised by distinct fixtures (positive verdict-logic coverage — D-03/D-04)
- evidence is bounded/structured — no raw unbounded body reaches classifier/reasons (CLASSIFY-06)
</success_criteria>

<output>
Create `.planning/phases/19-classification-engine/19-01-SUMMARY.md` when done
</output>
