docs(19): add pattern map
This commit is contained in:
parent
0be109d4cd
commit
f2b3602ca1
1 changed files with 333 additions and 0 deletions
333
.planning/phases/19-classification-engine/19-PATTERNS.md
Normal file
333
.planning/phases/19-classification-engine/19-PATTERNS.md
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
# Phase 19: Classification Engine - Pattern Map
|
||||
|
||||
**Mapped:** 2026-07-16
|
||||
**Files analyzed:** 3 (2 new source files, 1 new test file — no existing files modified)
|
||||
**Analogs found:** 3 / 3
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|--------------------|------|-----------|-----------------|----------------|
|
||||
| `lib/services/campaign-classifier.ts` | service | transform (evidence-in / rule-eval / verdict-out, CRUD-adjacent: reads + one INSERT) | `lib/services/robotic-classifier.ts` (rule-eval style) + `lib/services/mimecast-blast-radius.ts` (pure-function module shape, discriminated-union result) + `lib/services/campaign-grouping-service.ts` (evidence-gather-then-orchestrate shape) | exact (composite of 3 role-adjacent analogs — no single file covers both "deterministic rule evaluation" and "gather campaign evidence") |
|
||||
| `app/api/phishing/campaigns/[id]/classify/route.ts` | route (controller) | request-response | `app/api/phishing/tickets/[ticket_id]/analyze/route.ts` | exact |
|
||||
| `lib/services/campaign-classifier.test.ts` | test | transform (unit, mocked I/O) | `lib/services/campaign-grouping-service.test.ts` + `lib/services/mimecast-blast-radius.test.ts` | exact |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `lib/services/campaign-classifier.ts` (service, transform/rule-eval)
|
||||
|
||||
**Analogs:** `lib/services/robotic-classifier.ts` (D-01's named architectural analog — rule-eval style only, NOT the DB-driven rule-cache wrapper), `lib/services/mimecast-blast-radius.ts` (pure-function module doc-comment style + discriminated-union result), `lib/services/campaign-grouping-service.ts` (evidence-gather-then-pure-logic module shape, same schema tables)
|
||||
|
||||
**File header/doc-comment pattern** (mirrors `mimecast-blast-radius.ts` lines 1-35 and `campaign-grouping-service.ts` lines 1-21) — a substantial doc-comment stating load-bearing invariants up front (ephemerality, known limitations, D-06 rationale) is this codebase's convention for phishing-triage service modules, not just a JSDoc blurb:
|
||||
```typescript
|
||||
// Source: lib/services/mimecast-blast-radius.ts lines 1-35 (style to follow)
|
||||
/**
|
||||
* Mimecast blast-radius lookup abstraction (Phase 17).
|
||||
*
|
||||
* ...
|
||||
* Three load-bearing facts:
|
||||
* (a) EPHEMERAL (D-03): ...
|
||||
* (b) ... IS BEST-EFFORT (D-02): ...
|
||||
* (c) KNOWN LIMITATION — ... (D-05): ...
|
||||
*/
|
||||
```
|
||||
|
||||
**Imports pattern** (mirrors `campaign-grouping-service.ts` lines 22-25 and `mimecast-blast-radius.ts` lines 37-44 — no relative `../` chains, sibling `lib/services/*` imports directly by filename, no path alias needed inside `lib/services/`):
|
||||
```typescript
|
||||
// Source: lib/services/campaign-grouping-service.ts lines 22-25
|
||||
import type { PoolClient } from 'pg';
|
||||
import { postgresClient } from './postgres-client';
|
||||
// (this phase additionally needs, following the same sibling-import convention:)
|
||||
// import { getBlastRadius, type BlastRadiusResult } from './mimecast-blast-radius';
|
||||
// import type { NormalizedMessage, AuthResults } from './eml-parser';
|
||||
```
|
||||
|
||||
**Deterministic rule-evaluation style (D-01)** — `evaluateContains()` precedent: `.toLowerCase()` + string comparison ONLY, no regex/eval, small pure functions each doing exactly one check:
|
||||
```typescript
|
||||
// Source: lib/services/robotic-classifier.ts lines 206-219 (evaluateContains)
|
||||
private evaluateContains(
|
||||
fieldValue: string | number,
|
||||
matchValue: any,
|
||||
caseSensitive: boolean
|
||||
): boolean {
|
||||
const text = String(fieldValue);
|
||||
const searchText = caseSensitive ? text : text.toLowerCase();
|
||||
const patterns = Array.isArray(matchValue) ? matchValue : [matchValue];
|
||||
|
||||
return patterns.some((pattern: string) => {
|
||||
const searchPattern = caseSensitive ? String(pattern) : String(pattern).toLowerCase();
|
||||
return searchText.includes(searchPattern);
|
||||
});
|
||||
}
|
||||
```
|
||||
Adapt to a bare exported function (no class, per D-01/Pattern 2 below) — e.g. `isKnownSimulationSender()`, `hasHardAuthFail()`, `evaluateThreatTier()` each following this one-check-per-function, string-only-comparison shape. Domain-suffix matching (not substring) is required for the sender-domain allowlist — see Security Domain note below; do NOT reuse `evaluateContains`'s bare `.includes()` for domain matching.
|
||||
|
||||
**Single-exported-orchestrator shape (D-01/D-02, Pattern 2)** — no class, no singleton instance, mirrors `groupReportIntoCampaign()` and `detectPhishingTicket()`:
|
||||
```typescript
|
||||
// Source: lib/services/campaign-grouping-service.ts lines 160-163 (signature shape to mirror)
|
||||
export async function groupReportIntoCampaign(
|
||||
reportId: string,
|
||||
opts?: { skipIfAlreadyGrouped?: boolean }
|
||||
): Promise<GroupReportResult | null> {
|
||||
```
|
||||
This phase's equivalent: `export async function classifyCampaign(campaignId: string): Promise<ClassifyResult>` — single entry point the new route calls directly (no class, no `getInstance()`).
|
||||
|
||||
**Evidence-gathering / bulk-fetch pattern** (bulk-fetch-by-id-array shape, reportIds → messagesRes → messageIds → indicatorsRes) — copy verbatim shape from `app/api/phishing/campaigns/[id]/route.ts` lines 86-116 (already used in RESEARCH.md's Code Examples section) and `campaign-grouping-service.ts`'s `ORDER BY r.created_at ASC` "earliest report is canonical" convention (lines 176-181, 217-218):
|
||||
```typescript
|
||||
// Source: app/api/phishing/campaigns/[id]/route.ts lines 96-116 (bulk-fetch-by-id-array shape)
|
||||
const reportIds = reportsRes.rows.map((r) => r.id);
|
||||
const messagesRes = reportIds.length
|
||||
? await postgresClient.query<MessageRow>(
|
||||
`SELECT id::text, report_id::text, message_id, headers->>'subject' AS subject
|
||||
FROM messages WHERE report_id = ANY($1::uuid[])`,
|
||||
[reportIds]
|
||||
)
|
||||
: { rows: [] as MessageRow[] };
|
||||
const messageIds = messagesRes.rows.map((m) => m.id);
|
||||
const indicatorsRes = messageIds.length
|
||||
? await postgresClient.query<IndicatorRow>(
|
||||
`SELECT id::text, message_id::text, indicator_type, value, metadata
|
||||
FROM indicators WHERE message_id = ANY($1::uuid[])`,
|
||||
[messageIds]
|
||||
)
|
||||
: { rows: [] as IndicatorRow[] };
|
||||
```
|
||||
|
||||
**Discriminated-union result shape** (mirrors `BlastRadiusResult`, the precedent CONTEXT.md explicitly names for shaping this phase's own evidence-completeness signal):
|
||||
```typescript
|
||||
// Source: lib/services/mimecast-blast-radius.ts lines 60-71
|
||||
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';
|
||||
};
|
||||
```
|
||||
|
||||
**Error handling pattern** (mirrors `getBlastRadius()`'s try/catch — log message only, never full payloads; and `groupReportIntoCampaign()`'s catch-log-rethrow at the orchestrator boundary):
|
||||
```typescript
|
||||
// Source: lib/services/mimecast-blast-radius.ts lines 188-194
|
||||
} catch (err) {
|
||||
// T-17-02: log err.message ONLY, never full Mimecast response bodies
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error('[MIMECAST-BLAST-RADIUS] lookup failed', message);
|
||||
return { status: 'unavailable', reason: 'lookup_failed', error: message };
|
||||
}
|
||||
```
|
||||
```typescript
|
||||
// Source: lib/services/campaign-grouping-service.ts lines 475-478
|
||||
} catch (error) {
|
||||
console.error('[CAMPAIGN-GROUPING] Failed to group report into campaign', reportId, error);
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
Use a `[CAMPAIGN-CLASSIFIER]` log prefix, matching the `[MIMECAST-BLAST-RADIUS]`/`[CAMPAIGN-GROUPING]`/`[PHISHING-DETECT]` bracketed-module-name convention.
|
||||
|
||||
**Append-only INSERT pattern** (`classifications` row, migration 097 exact columns — confirmed exact column list/types from `migrations/097_phishing_triage_schema.sql` lines 114-124: `verdict TEXT`, `confidence NUMERIC`, `summary TEXT`, `reasons JSONB`, `recommended_actions JSONB`, `requires_approval BOOLEAN NOT NULL DEFAULT false`):
|
||||
```typescript
|
||||
// Adapt from lib/services/phishing-detector.ts lines 210-229's INSERT-with-RETURNING shape
|
||||
await postgresClient.query(
|
||||
`INSERT INTO classifications (
|
||||
campaign_id, verdict, confidence, summary, reasons, recommended_actions, requires_approval
|
||||
) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7)
|
||||
RETURNING id::text AS id, created_at::text AS created_at`,
|
||||
[campaignId, verdict, confidence, summary, JSON.stringify(reasons), JSON.stringify(recommendedActions), requiresApproval]
|
||||
);
|
||||
```
|
||||
Note: no `ON CONFLICT` — this is append-only (D-02), unlike `phishing-detector.ts`'s `ON CONFLICT (ticket_id) DO UPDATE` upsert, which does NOT apply here.
|
||||
|
||||
**Auth-verdict precedence (Pitfall 1, load-bearing)** — reads `NormalizedMessage.authResultsOriginal` (from `eml-parser.ts` lines 104, 269-271) FIRST, falling back to `authResults`:
|
||||
```typescript
|
||||
function effectiveAuthResults(message: { headers: { authResults: AuthResults; authResultsOriginal: AuthResults | null } }): AuthResults {
|
||||
return message.headers.authResultsOriginal ?? message.headers.authResults;
|
||||
}
|
||||
function hasHardAuthFail(effective: AuthResults): boolean {
|
||||
return effective.spf === 'fail' || effective.dkim === 'fail' || effective.dmarc === 'fail';
|
||||
}
|
||||
```
|
||||
|
||||
**Named-pattern-constant precedent (D-06/D-07 allowlist shape)** — mirrors `KNOWN_PHISHING_PATTERNS` (`phishing-detector.ts` lines 26-35): a locked, exported, commented `readonly` constant, NOT a DB table:
|
||||
```typescript
|
||||
// Source: lib/services/phishing-detector.ts lines 26-35 (shape to mirror for KNOWN_SIMULATION_SENDERS)
|
||||
export const KNOWN_PHISHING_PATTERNS: readonly string[] = [
|
||||
'Phishing Report',
|
||||
'Spam Alert',
|
||||
'Phishing Alert - Email Security Report',
|
||||
'KnowBe4 Phish Alert Report',
|
||||
'Source: KnowBe4 Phish Alert Button',
|
||||
'userSubmissionsReportMessage',
|
||||
'reported message destinations',
|
||||
'Microsoft directly',
|
||||
];
|
||||
```
|
||||
Do NOT reuse or extend this constant — it is the REPORT-pattern list (proves how a ticket was reported), a different signal from the NEW sender-domain allowlist this phase adds (proves who sent the original message). See RESEARCH.md's exact `KNOWN_SIMULATION_SENDERS` proposal (vendor-keyed array of domain arrays) — same "locked TS constant with a doc-comment citing ticket evidence" shape.
|
||||
|
||||
---
|
||||
|
||||
### `app/api/phishing/campaigns/[id]/classify/route.ts` (route/controller, request-response)
|
||||
|
||||
**Analog:** `app/api/phishing/tickets/[ticket_id]/analyze/route.ts` (full file, 91 lines — read in full, reproduced below in relevant part)
|
||||
|
||||
**Imports pattern** (lines 11-16 — path-alias `@/lib/...` imports, unlike `lib/services/*` sibling imports):
|
||||
```typescript
|
||||
// Source: app/api/phishing/tickets/[ticket_id]/analyze/route.ts lines 11-16
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { detectPhishingTicket, type DetectableTicket } from '@/lib/services/phishing-detector';
|
||||
import { parseAndStoreMessage } from '@/lib/services/phishing-eml-service';
|
||||
import { groupReportIntoCampaign } from '@/lib/services/campaign-grouping-service';
|
||||
```
|
||||
This phase's route imports `classifyCampaign` from `@/lib/services/campaign-classifier` instead.
|
||||
|
||||
**Auth pattern (exact, reuse verbatim — Phase 18 D-06 convention, same `'analyze'` action, NOT a new permission)** (lines 18-23):
|
||||
```typescript
|
||||
// Source: app/api/phishing/tickets/[ticket_id]/analyze/route.ts lines 18-23
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ ticket_id: string }> }
|
||||
) {
|
||||
const { error } = await requirePermission('phishing', 'analyze');
|
||||
if (error) return error;
|
||||
```
|
||||
For `.../campaigns/[id]/classify/route.ts`, `params` is `Promise<{ id: string }>` instead of `{ ticket_id: string }` (matches `campaigns/[id]/route.ts`'s param name).
|
||||
|
||||
**UUID validation guard (V5, copy verbatim from the campaign detail route since it's a campaign id, not a ticket_id)**:
|
||||
```typescript
|
||||
// Source: app/api/phishing/campaigns/[id]/route.ts lines 11, 66-70
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
// ...
|
||||
const { id } = await params;
|
||||
if (!UUID_RE.test(id)) {
|
||||
return NextResponse.json({ error: 'Invalid campaign id' }, { status: 400 });
|
||||
}
|
||||
```
|
||||
Note: `analyze/route.ts` validates a numeric `ticket_id` (`Number.isFinite`), which does NOT apply here — this route's id is a campaign UUID, so follow `campaigns/[id]/route.ts`'s `UUID_RE` guard instead, not `analyze/route.ts`'s numeric check.
|
||||
|
||||
**Core request-response + 404 pattern** (lines 44-48, adapted — campaign-not-found mirrors ticket-not-found):
|
||||
```typescript
|
||||
// Source: app/api/phishing/tickets/[ticket_id]/analyze/route.ts lines 44-48 (shape)
|
||||
const r = row.rows[0];
|
||||
if (!r) {
|
||||
return NextResponse.json({ error: 'Ticket not found' }, { status: 404 });
|
||||
}
|
||||
```
|
||||
Adapt: `if (!campaign) return NextResponse.json({ error: 'Campaign not found' }, { status: 404 });` — same shape as `campaigns/[id]/route.ts` lines 79-82.
|
||||
|
||||
**Success response shape** (lines 77-82 — flat camelCase JSON, no wrapper envelope):
|
||||
```typescript
|
||||
// Source: app/api/phishing/tickets/[ticket_id]/analyze/route.ts lines 77-82
|
||||
return NextResponse.json({
|
||||
reportId: detection.reportId,
|
||||
campaignId: grouped?.campaignId ?? null,
|
||||
groupMethod: grouped?.groupMethod ?? null,
|
||||
created: grouped?.created ?? false,
|
||||
});
|
||||
```
|
||||
This phase's equivalent: `{ id, campaignId, verdict, confidence, summary, reasons, recommendedActions, requiresApproval }` (per RESEARCH.md's response shape).
|
||||
|
||||
**Error handling pattern** (lines 83-89 — try/catch wraps the whole handler body, 500 with `message`):
|
||||
```typescript
|
||||
// Source: app/api/phishing/tickets/[ticket_id]/analyze/route.ts lines 83-89
|
||||
} catch (err) {
|
||||
console.error('[PHISHING-ANALYZE] Failed to analyze ticket', ticketId, err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to analyze ticket', message: err instanceof Error ? err.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
```
|
||||
Use a `[PHISHING-CLASSIFY]` log prefix for this route, matching `[PHISHING-ANALYZE]`/`[PHISHING-CAMPAIGN-DETAIL]`'s per-route bracketed-name convention.
|
||||
|
||||
---
|
||||
|
||||
### `lib/services/campaign-classifier.test.ts` (test, transform)
|
||||
|
||||
**Analogs:** `lib/services/campaign-grouping-service.test.ts` (mocking `postgresClient` via `vi.mock` factory, hoisting discipline) + `lib/services/mimecast-blast-radius.test.ts` (mocking a sibling service module entirely)
|
||||
|
||||
**Mock-before-import pattern** (lines 1-14 — `vi.mock` factory MUST be declared before the module-under-test import; `// eslint-disable-next-line import/first` comment documents why):
|
||||
```typescript
|
||||
// Source: lib/services/campaign-grouping-service.test.ts lines 1-14
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock postgresClient BEFORE importing the module under test.
|
||||
const queryMock = vi.fn();
|
||||
const transactionMock = vi.fn();
|
||||
vi.mock('./postgres-client', () => ({
|
||||
postgresClient: {
|
||||
query: (...args: unknown[]) => queryMock(...args),
|
||||
transaction: (...args: unknown[]) => transactionMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first -- imported after vi.mock hoisting
|
||||
import { normalizeSubject, extractUrlDomain, groupReportIntoCampaign } from './campaign-grouping-service';
|
||||
```
|
||||
This phase's test additionally needs `vi.mock('./mimecast-blast-radius', ...)` (per RESEARCH.md's Wave 0 Gaps section) — follow `mimecast-blast-radius.test.ts`'s own `vi.mock('./mimecast-client', ...)` / `vi.mock('./redis-client', ...)` sibling-module-mocking shape (see lines 25-32 of that file) for how to mock `getBlastRadius` itself as a dependency of `campaign-classifier.ts`.
|
||||
|
||||
**Per-call routed mock rows (matches SQL-substring discipline)** — when a module makes multiple sequential/branching queries (as `campaign-classifier.ts`'s evidence-gathering will), route mock responses by a distinguishing SQL substring per call rather than by call order:
|
||||
```typescript
|
||||
// Source: lib/services/campaign-grouping-service.test.ts lines 81-90 (pattern)
|
||||
function makeClient(rows: MockRows) {
|
||||
return {
|
||||
query: vi.fn(async (sql: string, params?: unknown[]) => {
|
||||
clientCalls.push({ sql, params: params ?? [] });
|
||||
if (sql.includes('requester_contact_id, company_id, created_at')) {
|
||||
return { rows: rows.ownReport ?? [], rowCount: rows.ownReport?.length ?? 0 };
|
||||
}
|
||||
if (sql.includes('FROM messages') && sql.includes('WHERE report_id = $1')) {
|
||||
return { rows: rows.ownMessage ?? [], rowCount: rows.ownMessage?.length ?? 0 };
|
||||
}
|
||||
// ... one branch per distinct query
|
||||
}),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Pure-function unit tests** (mirrors `describe('normalizeSubject', ...)` / `describe('extractUrlDomain', ...)` blocks, lines 16-50) — this phase's pure rule functions (`hasHardAuthFail`, `isKnownSimulationSender`, `computeConfidence`, `mapVerdictToActions`, `computeRequiresApproval`) should each get their own `describe` block with direct input/output assertions, no mocking needed for these (only `classifyCampaign()` itself needs the DB/Mimecast mocks).
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Auth/Permission (all `/api/phishing/*` routes)
|
||||
**Source:** `lib/auth-utils.ts` `requirePermission(resource, action)`, established Phase 18 D-06, reused verbatim from `app/api/phishing/tickets/[ticket_id]/analyze/route.ts` lines 22-23.
|
||||
**Apply to:** `app/api/phishing/campaigns/[id]/classify/route.ts` — same `'analyze'` action, not a new permission (confirmed in `lib/permissions.ts` line 34: `phishing: ["read", "analyze", "approve", "remediate"]`).
|
||||
```typescript
|
||||
const { error } = await requirePermission('phishing', 'analyze');
|
||||
if (error) return error;
|
||||
```
|
||||
|
||||
### Error Handling (route + service boundary)
|
||||
**Source:** every `app/api/phishing/**/route.ts` file — try/catch around the full handler body, `NextResponse.json({ error, message }, { status: 500 })` on unexpected failure; every `lib/services/*.ts` orchestrator function — catch-log-with-context-then-rethrow (service layer never swallows into a 200; the route layer converts to the JSON error envelope).
|
||||
**Apply to:** both new files.
|
||||
|
||||
### Bracketed module-name logging
|
||||
**Source:** `[MIMECAST-BLAST-RADIUS]`, `[CAMPAIGN-GROUPING]`, `[PHISHING-DETECT]`, `[PHISHING-ANALYZE]`, `[PHISHING-CAMPAIGN-DETAIL]` — every phishing-triage module/route prefixes `console.error`/`console.debug` calls with its own bracketed name.
|
||||
**Apply to:** use `[CAMPAIGN-CLASSIFIER]` in the service, `[PHISHING-CLASSIFY]` in the route.
|
||||
|
||||
### UUID path-param validation (V5)
|
||||
**Source:** `app/api/phishing/campaigns/[id]/route.ts` lines 11, 66-70 — `UUID_RE` regex guard before any query, returns 400 on malformed input rather than letting an unhandled Postgres error surface as a 500.
|
||||
**Apply to:** `app/api/phishing/campaigns/[id]/classify/route.ts` (this route's `id` is a campaign UUID, not a ticket_id — do NOT copy `analyze/route.ts`'s numeric `Number.isFinite(ticketId)` check, which validates a different id type).
|
||||
|
||||
### camelCase API response transform
|
||||
**Source:** every `app/api/phishing/**/route.ts` GET/POST handler — snake_case DB columns manually mapped to camelCase JSON keys at the response boundary (e.g. `campaigns/[id]/route.ts` lines 126-165: `campaign_key` → `campaignKey`, `first_seen_at` → `firstSeenAt`).
|
||||
**Apply to:** the classify route's JSON response (`campaign_id` → `campaignId`, `requires_approval` → `requiresApproval`, `recommended_actions` → `recommendedActions`).
|
||||
|
||||
### Discriminated-union "evidence completeness" signal
|
||||
**Source:** `BlastRadiusResult` (`lib/services/mimecast-blast-radius.ts` lines 60-71) — `status: 'ok' | 'unavailable'` shape, explicitly cited in CONTEXT.md as precedent for D-05's confidence-deduction triggers.
|
||||
**Apply to:** `campaign-classifier.ts`'s internal evidence-completeness flags (`hasAnyMessage`, `blastRadiusStatus`, `hasAttachmentOrUrlIndicators`).
|
||||
|
||||
## No Analog Found
|
||||
|
||||
None. Every file this phase creates has at least one close, directly-applicable analog already in the codebase (all from Phases 15-18 of the same phishing-triage milestone). No RESEARCH.md-only patterns were needed as a fallback.
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `lib/services/*.ts` (phishing-triage modules: `robotic-classifier.ts`, `mimecast-blast-radius.ts`, `campaign-grouping-service.ts`, `phishing-detector.ts`, `eml-parser.ts`, `phishing-eml-service.ts`), `app/api/phishing/**/route.ts`, `lib/permissions.ts`, `migrations/097_phishing_triage_schema.sql`, existing `*.test.ts` files under `lib/services/`
|
||||
**Files scanned:** 11 read in full (or targeted section) + 1 migration + 1 permissions file
|
||||
**Pattern extraction date:** 2026-07-16
|
||||
Loading…
Add table
Add a link
Reference in a new issue