---
phase: 19-classification-engine
plan: 01
subsystem: security
tags: [phishing-triage, deterministic-classifier, vitest, postgres, mimecast]

# Dependency graph
requires:
  - phase: 16-eml-mime-evidence-parser
    provides: "NormalizedMessage / AuthResults shapes, messages.headers JSONB (eml-parser.ts)"
  - phase: 17-mimecast-blast-radius-lookup
    provides: "getBlastRadius() delivered/held/rejected/clicked lookup (mimecast-blast-radius.ts)"
  - phase: 18 (campaign grouping)
    provides: "campaigns/reports linkage, ORDER BY created_at ASC 'earliest is canonical' convention"
provides:
  - "classifyCampaign(campaignId) — deterministic SPAM/UNWANTED/THREAT verdict engine, no LLM calls"
  - "KNOWN_SIMULATION_SENDERS allowlist (it-support.care, breachsecurenow.com) + domainMatchesAllowlist/isKnownSimulationSender"
  - "gatherCampaignEvidence(campaignId) — bounded evidence assembly (reports/messages/indicators + one getBlastRadius call)"
  - "classifications table INSERT (append-only, D-02) with verdict/confidence/reasons/recommendedActions/requiresApproval"
affects: ["20 (remediation actions)", "phishing/campaigns/{id}/classify route"]

# Tech tracking
tech-stack:
  added: []
  patterns:
    - "Pure evidence-in / rule-eval / verdict-out module shape (mirrors robotic-classifier.ts, campaign-grouping-service.ts)"
    - "vi.mock query-router-by-SQL-substring test pattern (mirrors campaign-grouping-service.test.ts)"

key-files:
  created:
    - lib/services/campaign-classifier.ts
    - lib/services/campaign-classifier.test.ts
    - lib/services/campaign-classifier.fixtures.ts
  modified: []

key-decisions:
  - "D-06 allowlist stored as a TypeScript constant (KNOWN_SIMULATION_SENDERS), not a DB table — logic-drift mitigation (T-19-02) via unit-tested pure code"
  - "domainMatchesAllowlist uses exact-or-proper-subdomain match only (d===allowed || d.endsWith('.'+allowed)) — never .includes() substring matching (T-19-01)"
  - "D-03 THREAT gate requires BOTH delivered>0/clicked>0 AND (hard auth fail OR known-bad-indicator match spanning >=2 messages) — either signal alone stays at UNWANTED"
  - "D-04 UNWANTED fires on ANY suspicious signal (single attachment/url indicator, or delivery contained to the reporter(s) only); SPAM only when neither is present"
  - "ASSUMPTION FLAG (surfaced per plan instruction, needs user confirmation): THREAT recommendedActions escalate to reset_password/isolate_endpoint/disable_forwarding_rule only when blastRadius.clicked>0 — this is a reasoned research proposal (19-RESEARCH.md Open Question #1), NOT an explicit D-08 decision. Does not contradict any locked decision; materially affects what Phase 20 gates approval on."

requirements-completed: [CLASSIFY-01, CLASSIFY-02, CLASSIFY-03, CLASSIFY-04, CLASSIFY-06]

# Metrics
duration: 15min
completed: 2026-07-16
---

# Phase 19 Plan 01: Classification Engine — Pure Rule Functions + classifyCampaign Orchestrator Summary

**Deterministic SPAM/UNWANTED/THREAT classifier with a KnowBe4/Breach-Secure-Now simulation allowlist, D-03 THREAT gate (delivery + malicious signal), D-04 SPAM/UNWANTED split, D-05 confidence scoring, and an append-only `classifications` INSERT — zero LLM calls.**

## Performance

- **Duration:** ~15 min (first commit 08:14:51 → last commit 08:20:58)
- **Started:** 2026-07-16T08:14:51-04:00
- **Completed:** 2026-07-16T08:20:58-04:00
- **Tasks:** 2 completed
- **Files modified:** 3 (all new)

## Accomplishments

- Pure rule functions (`domainMatchesAllowlist`, `isKnownSimulationSender`, `effectiveAuthResults`, `hasHardAuthFail`, `computeConfidence`, `mapVerdictToActions`, `computeRequiresApproval`) — each independently unit-tested, no DB/Mimecast dependency
- `classifyCampaign(campaignId)` orchestrator: gathers campaign evidence, applies D-06 simulation short-circuit → D-03 THREAT tier → D-04 SPAM/UNWANTED split → D-05 confidence → D-08 action mapping, and appends one `classifications` row (no `ON CONFLICT`)
- Positive-path THREAT proven with a real (non-simulation) signal fixture — closes the "always-SPAM stub" blocker the plan explicitly flagged
- D-03's known-bad-indicator OR-branch proven independently of the auth-fail path (auth PASS + shared indicator across 2 messages → THREAT)
- D-04's SPAM/UNWANTED boundary proven with two distinct fixtures (not just a single always-one-verdict stub)
- Synthetic KnowBe4 (`it-support.care`) and Breach Secure Now (`breachsecurenow.com`) simulation fixtures reproduce the Pitfall-1 forwarding-induced auth-verdict inversion, proving both the D-06 allowlist short-circuit and the `authResultsOriginal` precedence in one test

## Task Commits

Each task followed the TDD RED → GREEN cycle with a `test(...)` commit before its `feat(...)` commit:

1. **Task 1: Failing tests + fixtures + pure rule functions**
   - `f4e6baf` (test) — failing tests + synthetic fixtures for classifier pure rule functions
   - `3ea6c95` (feat) — implemented pure rule functions (D-05/D-06/D-08); all 30 tests green
2. **Task 2: classifyCampaign orchestrator + evidence gathering + append-only INSERT**
   - `f6c954a` (test) — classifyCampaign orchestrator tests (mocked postgres-client + mimecast-blast-radius)
   - `38c1ae4` (feat) — implemented `gatherCampaignEvidence` + `classifyCampaign`; all 39 tests green

**Plan metadata:** this SUMMARY.md commit (see below)

## Files Created/Modified

- `lib/services/campaign-classifier.ts` — `classifyCampaign` orchestrator, `gatherCampaignEvidence`, pure rule functions, `KNOWN_SIMULATION_SENDERS` constant, `ClassifyResult`/`CampaignEvidence`/`ParsedMessage` types
- `lib/services/campaign-classifier.test.ts` — 39 tests across 9 pure-function describe blocks + `classifyCampaign` orchestration describe block (mocked `postgres-client`/`mimecast-blast-radius`)
- `lib/services/campaign-classifier.fixtures.ts` — synthetic KnowBe4/BSN simulation fixtures + non-simulation threat/clean-spam/suspicious-unwanted fixtures (all invented content, no real customer email)

## Decisions Made

- Kept `isKnownSimulationSender`'s parameter type narrowed to a new `SenderIdentity` interface (`{ from: { domain }, returnPath }`) instead of the full `NormalizedMessage`, so both the Task 1 fixtures (typed as full `NormalizedMessage`) and Task 2's bounded `ParsedMessage` (built from `messages.headers` JSONB) can share the same allowlist-check function without type friction.
- `evaluateSpamVsUnwanted`'s "suspicious signal" definition (any attachment/url indicator present OR delivery contained to the reporter(s) only) was Claude's Discretion per CONTEXT.md D-04 — implemented as an OR of the two conditions so either alone is enough to clear SPAM into UNWANTED, while requiring the stronger D-03 gate (delivery + malicious signal) to reach THREAT.
- `gatherCampaignEvidence` caps only the human-readable `reportSample` at 10 (T-19-03/CLASSIFY-06 DoS guard for anything embedded in output); the full `messages`/`indicators` arrays are used internally for correctness-critical verdict computation (D-03's cross-message indicator correlation needs the complete set, not a sample) — the bounding requirement is satisfied by keeping `reasons` short and named (never one line per indicator), not by truncating rule-evaluation inputs.

## Deviations from Plan

None — plan executed exactly as written. The ASSUMPTION FLAG called out in the plan's Task 1 action block (click-driven escalation to `reset_password`/`isolate_endpoint`/`disable_forwarding_rule`) was implemented exactly as the plan's code example specified and is documented above under Key Decisions for user visibility, per the plan's own instruction to surface it — it is not a deviation, since the plan explicitly directed this implementation.

## Issues Encountered

- Worktree branch (`worktree-agent-af5d279be12eefb03`) was created from a commit predating this milestone's Phase 16-18 work (missing `eml-parser.ts`, `mimecast-blast-radius.ts`, `campaign-grouping-service.ts`, migration 097, etc.) — confirmed the worktree had zero commits of its own ahead of `master` (`git log HEAD..master` = 333 commits, `master..HEAD` = 0), so `git reset --hard master` was safe and used to bring the worktree in sync with all prerequisite phases before starting.
- Full `npm test` surfaced 2 pre-existing, unrelated failures in `lib/services/analyzer/itglue-search.test.ts` (`client.getFlexibleAssetsForOrganization is not a function`) — confirmed via `git log` that this test file predates this phase and was not touched by this plan. Logged to `.planning/phases/19-classification-engine/deferred-items.md` per the executor's scope-boundary rule; not fixed.

## User Setup Required

None - no external service configuration required.

## Next Phase Readiness

- `classifyCampaign(campaignId)` is ready to be wired into `POST /api/phishing/campaigns/{id}/classify` (Plan 19-02 / CLASSIFY-05) — the route only needs to call it and return the `ClassifyResult` shape.
- Phase 20 (remediation actions) can consume `recommendedActions`/`requiresApproval` directly — the D-08 action vocabulary (`no_action`, `warn_user`, `disable_forwarding_rule`, `block_sender`, `purge_message`, `reset_password`, `isolate_endpoint`) is exactly what's produced.
- **Needs a quick user confirmation** (flagged in the plan, not a blocker): whether `clicked>0`-driven escalation to `reset_password`/`isolate_endpoint`/`disable_forwarding_rule` is the right trigger for Phase 20's approval gating, since it's a reasoned proposal rather than a locked CONTEXT.md decision.

---
*Phase: 19-classification-engine*
*Completed: 2026-07-16*

## Self-Check: PASSED

- FOUND: lib/services/campaign-classifier.ts
- FOUND: lib/services/campaign-classifier.test.ts
- FOUND: lib/services/campaign-classifier.fixtures.ts
- FOUND: .planning/phases/19-classification-engine/deferred-items.md
- FOUND commit: f4e6baf (test: pure rule function tests + fixtures)
- FOUND commit: 3ea6c95 (feat: pure rule functions)
- FOUND commit: f6c954a (test: classifyCampaign orchestrator tests)
- FOUND commit: 38c1ae4 (feat: classifyCampaign orchestrator + evidence gathering)
