---
phase: quick-260721-n49
plan: 01
subsystem: phishing-triage
tags: [mimecast, classifier, multi-tenant, campaign-classifier, blast-radius]

requires:
  - phase: 260721-mmf
    provides: "getBlastRadius() tenant-wide fan-out fix (query-scope, unrelated to this tenant-resolution fix)"
provides:
  - "gatherCampaignEvidence() resolves the reporting company's own Mimecast tenant before calling getBlastRadius(), mirroring the already-shipped route-level fix"
affects: [phishing-triage, classifications, mimecast]

tech-stack:
  added: []
  patterns:
    - "Per-company Mimecast tenant resolution: SELECT client_id/client_secret/base_url FROM mimecast_tenants WHERE company_id = $1 AND enabled = true ORDER BY id LIMIT 1, then getMimecastClientForTenant() + { client, cacheScope: companyId } passed as getBlastRadius()'s second argument. Falls back to no second argument (global env client) when absent."

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

key-decisions:
  - "Mirrored app/api/phishing/campaigns/[id]/route.ts's 'Bug 2 (D-05)' tenant-resolution block inline in the classifier rather than extracting a shared helper — the plan's judgment note allowed extraction only if it was a clean, behavior-preserving drop-in for both call sites; the route's block was already correct/tested, so leaving it untouched and duplicating the small inline block in the classifier was the safer choice."

requirements-completed: [FIX-CLASSIFIER-TENANT]

duration: 12min
completed: 2026-07-21
---

# Quick Task 260721-n49: Fix classifier Mimecast tenant scope Summary

**Auto-classification (`classifyCampaign`/`gatherCampaignEvidence`) now queries the reporting company's own registered Mimecast tenant instead of always falling back to the global env-configured (Wulf) tenant.**

## Performance

- **Duration:** 12 min
- **Tasks:** 2 completed
- **Files modified:** 2

## Accomplishments

- `gatherCampaignEvidence()` now threads `company_id` from the `reports` table through `CampaignReportSummary.companyId`.
- Before calling `getBlastRadius()`, the classifier queries `mimecast_tenants` for an enabled row matching the primary report's `companyId` and, if found, builds a tenant-scoped client via `getMimecastClientForTenant()` and passes `{ client, cacheScope: companyId }` as the second argument — exactly mirroring the already-shipped, already-tested block in `app/api/phishing/campaigns/[id]/route.ts` ("Bug 2 (D-05)").
- When no `companyId` is present, or no enabled tenant row exists for it, `getBlastRadius()` is still called with no second argument — the global env-configured Mimecast client fallback is unchanged from before this fix.
- Root cause was verified live against ticket 700716 / company 29683407 ("Seubert and Associates"): before this fix, that company's auto-classification was being computed against the wrong (Wulf) tenant's Mimecast data, producing a silent false-"clean" signal.

## Task Commits

Each task was committed atomically:

1. **Task 1: Resolve per-company Mimecast tenant in gatherCampaignEvidence()** - `9f75f21` (fix)
2. **Task 2: Extend campaign-classifier tests for tenant resolution vs. global fallback** - `9f12cd6` (test)

_Note: `git log --oneline -3` from HEAD: `9f12cd6` (test), `9f75f21` (fix), `f58856e` (docs: pre-dispatch plan)._

## Files Created/Modified

- `lib/services/campaign-classifier.ts` - Added `MimecastTenantRow` interface, `company_id` on `ReportDbRow`/`CampaignReportSummary`, and the per-company tenant-resolution block (query + `getMimecastClientForTenant` + `{ client, cacheScope }`) before the existing `getBlastRadius()` call in `gatherCampaignEvidence()`.
- `lib/services/campaign-classifier.test.ts` - Added a `mimecast_tenants` routing branch to `stageQueries`/`StagedRows`, `company_id` on `ReportFixtureRow` (and all existing report fixtures, set to `null` where not under test), plus two new test cases: one asserting tenant-scoped `{ client, cacheScope }` when an enabled tenant row exists, and a parameterized test asserting no second argument (`undefined`) when `companyId` is null or has no enabled tenant row.

## Decisions Made

- Kept the tenant-resolution block inline in the classifier (not extracted into a shared helper) per the plan's explicit judgment note — the route's existing block is already correct and tested, and extracting a shared helper would have required editing it, which the plan disallowed unless the extraction was a clean behavior-preserving drop-in for both call sites.

## Deviations from Plan

None - plan executed exactly as written.

## Issues Encountered

None.

## User Setup Required

None - no external service configuration required.

## Verification

- `npx vitest run lib/services/campaign-classifier.test.ts` — 49/49 tests passed (including the 2 new tenant-resolution/global-fallback tests; all 47 pre-existing tests remained green).
- `npx tsc --noEmit --pretty` — clean, no errors.
- Manual re-read confirmed the classifier's new tenant-resolution block is a line-for-line behavior mirror of `app/api/phishing/campaigns/[id]/route.ts`'s "Bug 2 (D-05)" block (same query, same `getMimecastClientForTenant` build, same `{ client, cacheScope }` shape); the route file itself was not modified.

## Next Phase Readiness

- Fix is self-contained to the evidence-gathering path of the campaign classifier; no follow-up work required.
- Companies with their own `mimecast_tenants` row (e.g. Seubert and Associates / 29683407) will now get correct tenant-scoped blast-radius evidence on the next auto-classification run (webhook-triggered ticket creation, or any future manual `classifyCampaign()` invocation) — no backfill of historical `classifications` rows was in scope for this fix.

---
*Phase: quick-260721-n49*
*Completed: 2026-07-21*

## Self-Check: PASSED

- FOUND: lib/services/campaign-classifier.ts
- FOUND: lib/services/campaign-classifier.test.ts
- FOUND: .planning/quick/260721-n49-fix-classifier-mimecast-tenant-scope/260721-n49-SUMMARY.md
- FOUND commit: 9f75f21 (fix task)
- FOUND commit: 9f12cd6 (test task)
