From 0d7974cdd9d2425a6bec311260c7a7bbf666bb91 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 19:45:04 -0400 Subject: [PATCH 1/4] test(23-05): add failing tests for getCompanyAutomationGate reader - covers absent-row, present-row mapping, null/NaN companyId short-circuit --- lib/services/phishing-automation-gate.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 lib/services/phishing-automation-gate.test.ts diff --git a/lib/services/phishing-automation-gate.test.ts b/lib/services/phishing-automation-gate.test.ts new file mode 100644 index 0000000..05b9d8c --- /dev/null +++ b/lib/services/phishing-automation-gate.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock postgresClient BEFORE importing the module under test — mirrors +// campaign-classifier.test.ts's vi.mock() factory-mocking discipline. +const queryMock = vi.fn(); +vi.mock('./postgres-client', () => ({ + postgresClient: { + query: (...args: unknown[]) => queryMock(...args), + }, +})); + +// eslint-disable-next-line import/first -- imported after vi.mock hoisting +import { getCompanyAutomationGate } from './phishing-automation-gate'; + +describe('getCompanyAutomationGate', () => { + beforeEach(() => { + queryMock.mockReset(); + }); + + it('resolves to all-false when no row exists for the company', async () => { + queryMock.mockResolvedValueOnce({ rows: [] }); + + const gate = await getCompanyAutomationGate(123); + + expect(gate).toEqual({ autoParse: false, autoClassify: false, autoReport: false }); + expect(queryMock).toHaveBeenCalledTimes(1); + }); + + it('resolves to the mapped camelCase values when a row exists', async () => { + queryMock.mockResolvedValueOnce({ + rows: [{ auto_parse: true, auto_classify: false, auto_report: true }], + }); + + const gate = await getCompanyAutomationGate(456); + + expect(gate).toEqual({ autoParse: true, autoClassify: false, autoReport: true }); + }); + + it('resolves to all-false without querying when companyId is null', async () => { + const gate = await getCompanyAutomationGate(null); + + expect(gate).toEqual({ autoParse: false, autoClassify: false, autoReport: false }); + expect(queryMock).not.toHaveBeenCalled(); + }); + + it('resolves to all-false without querying when companyId is NaN', async () => { + const gate = await getCompanyAutomationGate(NaN); + + expect(gate).toEqual({ autoParse: false, autoClassify: false, autoReport: false }); + expect(queryMock).not.toHaveBeenCalled(); + }); +}); From e0f22f27c93712a42f6fb503a3436fbc0b110f11 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 19:45:41 -0400 Subject: [PATCH 2/4] feat(23-05): implement getCompanyAutomationGate reader - COALESCE(..., false) query keyed on company_id; absent row/null/NaN -> all-false - never throws; type-check and vitest suite pass --- lib/services/phishing-automation-gate.ts | 58 ++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 lib/services/phishing-automation-gate.ts diff --git a/lib/services/phishing-automation-gate.ts b/lib/services/phishing-automation-gate.ts new file mode 100644 index 0000000..c6cd297 --- /dev/null +++ b/lib/services/phishing-automation-gate.ts @@ -0,0 +1,58 @@ +/** + * Phase 23 D-06/D-07: per-company phishing automation gate reader. + * + * Opt-in model — a company with no `phishing_automation_gate` row has all + * three stages OFF. Mirrors the COALESCE(..., false)-over-LEFT-JOIN pattern + * used by `app/api/admin/phishing-automation/route.ts`, but scoped to a + * single company for the webhook's per-ticket gate check. + */ + +import { postgresClient } from './postgres-client'; + +export interface CompanyAutomationGate { + autoParse: boolean; + autoClassify: boolean; + autoReport: boolean; +} + +const ALL_FALSE: CompanyAutomationGate = { + autoParse: false, + autoClassify: false, + autoReport: false, +}; + +/** + * Reads the automation gate flags for a company. Absent row, null, or NaN + * companyId all resolve to all-false — never throws. + */ +export async function getCompanyAutomationGate( + companyId: number | null +): Promise { + if (companyId === null || Number.isNaN(companyId)) { + return { ...ALL_FALSE }; + } + + const result = await postgresClient.query<{ + auto_parse: boolean; + auto_classify: boolean; + auto_report: boolean; + }>( + `SELECT COALESCE(auto_parse, false) AS auto_parse, + COALESCE(auto_classify, false) AS auto_classify, + COALESCE(auto_report, false) AS auto_report + FROM phishing_automation_gate + WHERE company_id = $1`, + [companyId] + ); + + const row = result.rows[0]; + if (!row) { + return { ...ALL_FALSE }; + } + + return { + autoParse: row.auto_parse, + autoClassify: row.auto_classify, + autoReport: row.auto_report, + }; +} From e1193bf47662c317f5449fff7d91499b2df331c8 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 19:46:15 -0400 Subject: [PATCH 3/4] feat(23-05): wire gated parse->classify->acknowledge chain into webhook - triggerPhishingDetection now captures groupReportIntoCampaign's result and, when a campaignId exists, calls new runGatedPhishingStages - runGatedPhishingStages reads the per-company automation gate and conditionally runs parseAndStoreMessage, classifyCampaign, and (only for USER_AWARENESS verdicts) generateAndPostAcknowledgment - each stage isolated in its own try/catch (T-23-09); detection + grouping remain unconditional (D-07); auto_report never posts any other action (D-04, T-23-08) --- lib/services/webhook-service.ts | 74 ++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/lib/services/webhook-service.ts b/lib/services/webhook-service.ts index f523396..cc49846 100644 --- a/lib/services/webhook-service.ts +++ b/lib/services/webhook-service.ts @@ -16,6 +16,10 @@ import '../services/workflow-steps'; // Register all workflow step executors import { WorkflowEvent, TicketData } from '../types/workflow'; import { detectPhishingTicket, DetectableTicket } from './phishing-detector'; import { groupReportIntoCampaign } from './campaign-grouping-service'; +import { getCompanyAutomationGate } from './phishing-automation-gate'; +import { parseAndStoreMessage } from './phishing-eml-service'; +import { classifyCampaign, Verdict } from './campaign-classifier'; +import { generateAndPostAcknowledgment } from './triage-note-service'; export class WebhookService { private _autotaskClient: AutotaskClient | null = null; @@ -490,7 +494,75 @@ export class WebhookService { const detection = await detectPhishingTicket(ticket); // D-01/D-08: automatic path short-circuits if already grouped. if (detection.flagged && detection.reportId) { - await groupReportIntoCampaign(detection.reportId, { skipIfAlreadyGrouped: true }); + const grouped = await groupReportIntoCampaign(detection.reportId, { skipIfAlreadyGrouped: true }); + if (grouped?.campaignId) { + await this.runGatedPhishingStages({ + campaignId: grouped.campaignId, + companyId: r.company_id, + reportId: detection.reportId, + ticketId: Number(r.id), + }); + } + } + } + + /** + * Phase 23 D-04/D-06/D-07: runs the opted-in parse -> classify -> report + * chain for a company after detection + grouping have already run + * unconditionally. Each stage is independently gated by + * `phishing_automation_gate` and isolated in its own try/catch so a + * failure in one stage never blocks the webhook response or aborts a + * later stage (T-23-09). + * + * auto_report auto-posts EXCLUSIVELY the acknowledge_user thank-you note, + * and only when the campaign's current verdict is USER_AWARENESS (D-04). + * Every other verdict/action remains proposed-only and manual-approval + * gated — this method never calls approve/remediate/block/purge/warn_user. + */ + private async runGatedPhishingStages(input: { + campaignId: string; + companyId: number | null; + reportId: string; + ticketId: number; + }): Promise { + const { campaignId, companyId, reportId, ticketId } = input; + const gate = await getCompanyAutomationGate(companyId); + + if (gate.autoParse) { + try { + await parseAndStoreMessage({ reportId, ticketId }); + } catch (err) { + console.error('[WEBHOOK] auto_parse stage error', err); + } + } + + let verdict: Verdict | null = null; + + if (gate.autoClassify) { + try { + const result = await classifyCampaign(campaignId); + verdict = result.verdict; + } catch (err) { + console.error('[WEBHOOK] auto_classify stage error', err); + } + } + + if (gate.autoReport) { + try { + if (verdict === null) { + const latest = await postgresClient.query<{ verdict: string | null }>( + `SELECT verdict FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC LIMIT 1`, + [campaignId] + ); + verdict = (latest.rows[0]?.verdict as Verdict | undefined) ?? null; + } + + if (verdict === 'USER_AWARENESS') { + await generateAndPostAcknowledgment(campaignId); + } + } catch (err) { + console.error('[WEBHOOK] auto_report stage error', err); + } } } } From 7869e0bcfd6afb2811395daefd8c80f37909604b Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 19:47:21 -0400 Subject: [PATCH 4/4] docs(23-05): complete gated automation pipeline plan Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY --- .../23-05-SUMMARY.md | 108 ++++++++++++++++++ .../deferred-items.md | 11 ++ 2 files changed, 119 insertions(+) create mode 100644 .planning/phases/23-classification-disposition-per-client-automation-gate/23-05-SUMMARY.md create mode 100644 .planning/phases/23-classification-disposition-per-client-automation-gate/deferred-items.md diff --git a/.planning/phases/23-classification-disposition-per-client-automation-gate/23-05-SUMMARY.md b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-05-SUMMARY.md new file mode 100644 index 0000000..cb71353 --- /dev/null +++ b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-05-SUMMARY.md @@ -0,0 +1,108 @@ +--- +phase: 23-classification-disposition-per-client-automation-gate +plan: 05 +subsystem: phishing-triage-automation +tags: [webhook, phishing, postgres, vitest, autotask, gate-pattern] + +# Dependency graph +requires: + - phase: 23-01 + provides: "USER_AWARENESS verdict on Verdict union, classifyCampaign, generateAndPostAcknowledgment (acknowledge_user note writer)" + - phase: 23-03 + provides: "phishing_automation_gate table (migration 100) + admin CRUD routes for per-company auto_parse/auto_classify/auto_report toggles" +provides: + - "getCompanyAutomationGate(companyId) reusable COALESCE-false reader" + - "Automatic parse -> classify -> acknowledge_user pipeline wired into the live Autotask webhook, gated per-company" +affects: [23-remaining-plans, phishing-triage-runbook] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Per-company opt-in settings reader (COALESCE(..., false), absent row = all-false) as a standalone testable module, mirroring the admin route's LEFT JOIN pattern but scoped to a single company_id" + - "Gated webhook stage chain: each automation stage wrapped in its own try/catch so a stage failure logs-and-continues without blocking the fire-and-forget webhook response or aborting later stages" + +key-files: + created: + - lib/services/phishing-automation-gate.ts + - lib/services/phishing-automation-gate.test.ts + modified: + - lib/services/webhook-service.ts + +key-decisions: + - "auto_report's automatic behavior is narrowly scoped to generateAndPostAcknowledgment for USER_AWARENESS verdicts only — no other action/verdict is ever auto-posted or auto-approved (D-04 carve-out, T-23-08)" + - "When auto_report is enabled but auto_classify did not run in the same pass (or failed), the current verdict is read from the most recent classifications row for the campaign rather than re-running classification" + - "Detection + grouping remain unconditional for every company regardless of gate state (D-07) — only the three post-grouping stages are gated" + +patterns-established: + - "runGatedPhishingStages(input) as the single gated-stage entry point, called only when groupReportIntoCampaign resolves a campaignId" + +requirements-completed: [AUTOGATE-03] + +# Metrics +duration: 15min +completed: 2026-07-16 +--- + +# Phase 23 Plan 05: Wire Gated Automation Pipeline Into Webhook Summary + +**Adds a tested `getCompanyAutomationGate` reader and wires the opted-in parse -> classify -> acknowledge_user chain into the live Autotask webhook, with detection/grouping staying always-on and every other remediation action staying manual-approval-gated.** + +## Performance + +- **Duration:** ~15 min +- **Started:** 2026-07-16T23:31:00Z (approx) +- **Completed:** 2026-07-16T23:46:41Z +- **Tasks:** 2/2 completed +- **Files modified:** 3 (2 created, 1 modified) + +## Accomplishments +- `getCompanyAutomationGate(companyId)` reusable reader: absent row / null / NaN companyId all resolve to all-false without throwing; present row maps snake_case -> camelCase. +- `triggerPhishingDetection` now captures `groupReportIntoCampaign`'s result and, when a campaign exists, runs the new `runGatedPhishingStages` method — automatically parsing, classifying, and (only for USER_AWARENESS) posting the acknowledge_user note for opted-in companies, entirely within the existing fire-and-forget call site. +- Detection and grouping remain unconditional (D-07); no other verdict/action (block_sender, purge_message, warn_user, reset_password, etc.) is ever auto-posted or auto-approved by this change. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Create getCompanyAutomationGate reader + tests** - `0d7974c` (test, RED) -> `e0f22f2` (feat, GREEN) +2. **Task 2: Wire gated parse->classify->acknowledge chain into triggerPhishingDetection** - `e1193bf` (feat) + +_TDD gate sequence for Task 1 confirmed in git log: test commit `0d7974c` precedes feat commit `e0f22f2`._ + +## Files Created/Modified +- `lib/services/phishing-automation-gate.ts` - `getCompanyAutomationGate(companyId)`: COALESCE(..., false)-backed per-company gate reader, never throws. +- `lib/services/phishing-automation-gate.test.ts` - 4 vitest cases: absent row, present row mapping, null companyId, NaN companyId. +- `lib/services/webhook-service.ts` - `triggerPhishingDetection` now captures the grouping result and calls new private `runGatedPhishingStages`, which reads the gate and conditionally runs `parseAndStoreMessage`, `classifyCampaign`, and `generateAndPostAcknowledgment` (guarded by `verdict === 'USER_AWARENESS'`), each in its own try/catch. + +## Decisions Made +- Verdict-for-report lookup: when `auto_classify` didn't run in the same pass (either disabled or its try/catch caught an error), `auto_report`'s USER_AWARENESS check falls back to `SELECT verdict FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC LIMIT 1` rather than skipping the report stage entirely — this lets a company with only `auto_report` enabled (classification done via the existing manual route) still get the automatic acknowledge_user post once a human/prior automated pass has classified the campaign as USER_AWARENESS. +- Kept `runGatedPhishingStages` as a new private method (not inlined into `triggerPhishingDetection`) to keep the always-on detect+group logic visually and structurally separate from the gated stages, per the plan's D-07 emphasis. + +## Deviations from Plan + +None - plan executed exactly as written. Both tasks matched the plan's `` and interface contracts (`ParseAndStoreResult`, `ClassifyResult.verdict`, `TriageNoteResult`, `GroupReportResult`) exactly as they already existed on disk from plans 23-01 and 23-03. + +## Known Stubs + +None. + +## Threat Flags + +None — this plan's threat model (T-23-08 through T-23-11) was fully addressed as designed; no new unmodeled surface was introduced. `runGatedPhishingStages` adds no new inbound endpoint, only conditional internal calls to existing, already-reviewed service functions. + +## Verification + +- `npx vitest run lib/services/phishing-automation-gate.test.ts` — 4/4 passed. +- `npx tsc --noEmit --pretty` — clean, no errors. +- Full `npx vitest run` — 434 passed, 2 failed. The 2 failures are pre-existing and unrelated (`lib/services/analyzer/itglue-search.test.ts`, files never touched by this plan) — logged to `.planning/phases/23-classification-disposition-per-client-automation-gate/deferred-items.md` per the scope-boundary rule rather than fixed here. +- Acceptance-criteria greps for Task 2 all pass: `getCompanyAutomationGate` called with `companyId` (the ticket's `company_id`), `USER_AWARENESS` guards the `generateAndPostAcknowledgment` call, no auto-call to approve/remediate/block/purge/warn_user services was added, `detectPhishingTicket`/`groupReportIntoCampaign` remain unconditional, and each of the three gated stages has its own try/catch. + +## Self-Check: PASSED + +- FOUND: lib/services/phishing-automation-gate.ts +- FOUND: lib/services/phishing-automation-gate.test.ts +- FOUND: lib/services/webhook-service.ts (modified) +- FOUND commit 0d7974c +- FOUND commit e0f22f2 +- FOUND commit e1193bf diff --git a/.planning/phases/23-classification-disposition-per-client-automation-gate/deferred-items.md b/.planning/phases/23-classification-disposition-per-client-automation-gate/deferred-items.md new file mode 100644 index 0000000..70d162c --- /dev/null +++ b/.planning/phases/23-classification-disposition-per-client-automation-gate/deferred-items.md @@ -0,0 +1,11 @@ +# Deferred Items — Phase 23 + +## Pre-existing test failures (out of scope for 23-05) + +`lib/services/analyzer/itglue-search.test.ts` has 2 pre-existing failures +(`tolerates per-call failures` test cases) unrelated to plan 23-05's changes. +Plan 23-05 only touched `lib/services/phishing-automation-gate.ts`, +`lib/services/phishing-automation-gate.test.ts`, and +`lib/services/webhook-service.ts` — no analyzer/itglue files were modified. +Full suite: `434 passed, 2 failed` both in this one unrelated file. Logged +per SCOPE BOUNDARY rule — not fixed here.