---
phase: 23-classification-disposition-per-client-automation-gate
plan: 05
type: execute
wave: 2
depends_on: ["23-01", "23-03"]
files_modified:
  - lib/services/phishing-automation-gate.ts
  - lib/services/phishing-automation-gate.test.ts
  - lib/services/webhook-service.ts
autonomous: true
requirements: [AUTOGATE-03]
must_haves:
  truths:
    - "getCompanyAutomationGate returns all-false for a company with no gate row and the stored values when a row exists"
    - "On a phishing-flagged ticket webhook, after the always-on detect+group, the pipeline runs parse (if auto_parse), then classify (if auto_classify), then acknowledge_user note (if auto_report AND verdict is USER_AWARENESS) — for that ticket's company"
    - "auto_report auto-posts ONLY the acknowledge_user thank-you note for USER_AWARENESS verdicts — no other verdict/action is auto-posted or auto-approved"
    - "Detection + grouping still run unconditionally for every company regardless of gate state (D-07)"
    - "A stage failure logs and does not block the webhook response or abort earlier successful stages"
  artifacts:
    - path: "lib/services/phishing-automation-gate.ts"
      provides: "getCompanyAutomationGate(companyId) COALESCE-false reader"
      contains: "getCompanyAutomationGate"
    - path: "lib/services/webhook-service.ts"
      provides: "gated parse->classify->report chain after grouping"
      contains: "getCompanyAutomationGate"
  key_links:
    - from: "triggerPhishingDetection (after group)"
      to: "getCompanyAutomationGate(r.company_id)"
      via: "gate lookup then conditional stage calls"
      pattern: "getCompanyAutomationGate"
    - from: "auto_report branch"
      to: "generateAndPostAcknowledgment(campaignId)"
      via: "guarded by verdict === 'USER_AWARENESS'"
      pattern: "USER_AWARENESS"
---

<objective>
Wire the previously-manual-only pipeline stages (parse, classify, report) into the automatic Autotask webhook path, gated per-company by the Plan 03 table. After the always-on detect+group (D-07, unchanged), the webhook looks up the ticket's company gate and conditionally runs `parseAndStoreMessage` → `classifyCampaign` → (only for USER_AWARENESS) `generateAndPostAcknowledgment`. This is the narrow D-04 carve-out: the acknowledge_user thank-you note is the ONLY action that auto-posts without manual approval, and only when the company opted into report-to-ticket automation.

Purpose: Turn the now-live webhook into an actual auto-triage pipeline for opted-in clients, while preserving the proposed-only/manual-approval safety model for every real remediation action.
Output: A testable gate reader + gated stage chain in webhook-service.
</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/23-classification-disposition-per-client-automation-gate/23-CONTEXT.md
@.planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md

<interfaces>
<!-- Consumed contracts -->
lib/services/phishing-eml-service.ts: `parseAndStoreMessage(input: { reportId: string; ticketId: number }): Promise<ParseAndStoreResult>` — never throws for expected no-op cases.
lib/services/campaign-classifier.ts (Plan 01): `classifyCampaign(campaignId: string): Promise<ClassifyResult>`; ClassifyResult.verdict is `Verdict` including the new `'USER_AWARENESS'`.
lib/services/triage-note-service.ts (Plan 01): `generateAndPostAcknowledgment(campaignId: string): Promise<...>` — posts the customer-visible acknowledge_user note (noteType 18).
lib/services/campaign-grouping-service.ts: `groupReportIntoCampaign(reportId, opts?): Promise<{ campaignId: string; groupMethod: string; created: boolean }>`.
Plan 03 table: `phishing_automation_gate(company_id, auto_parse, auto_classify, auto_report, ...)`, absent row = all false.

<!-- webhook-service.ts current shape -->
triggerPhishingDetection (lines 458-495): reads the ticket row (r.id, r.company_id, ...), runs detectPhishingTicket, and on `detection.flagged && detection.reportId` calls `groupReportIntoCampaign(detection.reportId, { skipIfAlreadyGrouped: true })` (return currently discarded).
Fire-and-forget call site (lines 119-121): `this.triggerPhishingDetection(payload).catch(err => console.error('[WEBHOOK] Phishing detection error:', err));`

<!-- Manual precedent (app/api/phishing/tickets/[ticket_id]/analyze/route.ts): detect -> parseAndStoreMessage({reportId, ticketId}) -> groupReportIntoCampaign -> (classify is a separate manual route). -->
DB/style: postgresClient.query(sql, params); snake_case columns; console.error with context; no console.log debug left in.
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Create getCompanyAutomationGate reader + tests</name>
  <files>lib/services/phishing-automation-gate.ts, lib/services/phishing-automation-gate.test.ts</files>
  <read_first>
    - app/api/admin/phishing-automation/route.ts (Plan 03 — the COALESCE(..., false) read pattern to mirror)
    - lib/services/postgres-client.ts (postgresClient.query signature + how it's mocked in sibling *.test.ts files)
    - lib/services/campaign-classifier.test.ts (example of how postgresClient is mocked in this codebase's vitest tests)
    - .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md ("Opt-in/opt-out settings-table read" shared pattern)
  </read_first>
  <behavior>
    - getCompanyAutomationGate(companyId) with NO matching row resolves to { autoParse: false, autoClassify: false, autoReport: false }
    - getCompanyAutomationGate(companyId) with a row { auto_parse: true, auto_classify: false, auto_report: true } resolves to { autoParse: true, autoClassify: false, autoReport: true }
    - A null/invalid companyId resolves to all-false rather than throwing
  </behavior>
  <action>
    Create `lib/services/phishing-automation-gate.ts` exporting `getCompanyAutomationGate(companyId: number | null): Promise<{ autoParse: boolean; autoClassify: boolean; autoReport: boolean }>`. If companyId is null/NaN, return all-false without querying. Otherwise run `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` and map to the camelCase shape; when no row, return all-false. Export a small type for the return shape. Write `phishing-automation-gate.test.ts` mirroring the codebase's existing postgresClient-mock style (see campaign-classifier.test.ts), covering the three behaviors above. Follow RED (failing tests first) -> GREEN.
  </action>
  <verify>
    <automated>cd /opt/stacks/pulse && npx vitest run lib/services/phishing-automation-gate.test.ts && npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - `grep -q "export.*getCompanyAutomationGate" lib/services/phishing-automation-gate.ts`
    - vitest run passes with tests asserting absent-row -> all false, present-row -> mapped values, null companyId -> all false without query
    - `npx tsc --noEmit` exits 0
  </acceptance_criteria>
  <done>A tested, reusable getCompanyAutomationGate reader returns per-company gate flags defaulting to all-false; tests and type-check pass.</done>
</task>

<task type="auto">
  <name>Task 2: Wire gated parse->classify->acknowledge chain into triggerPhishingDetection</name>
  <files>lib/services/webhook-service.ts</files>
  <read_first>
    - lib/services/webhook-service.ts (triggerPhishingDetection lines 458-495, fire-and-forget call site lines 114-122, import block)
    - app/api/phishing/tickets/[ticket_id]/analyze/route.ts (the manual detect->parse->group chaining precedent)
    - lib/services/campaign-classifier.ts (classifyCampaign return shape, USER_AWARENESS literal — from Plan 01)
    - lib/services/triage-note-service.ts (generateAndPostAcknowledgment — from Plan 01)
    - .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md (webhook-service.ts section integration point + watch-out flags #2 and #3)
  </read_first>
  <action>
    In `triggerPhishingDetection`, capture the grouping result: `const grouped = await groupReportIntoCampaign(detection.reportId, { skipIfAlreadyGrouped: true });`. Detection + grouping stay unconditional (D-07) — do NOT gate them. After grouping, if `grouped?.campaignId` exists, add a new private method `runGatedPhishingStages({ campaignId, companyId, reportId, ticketId })` and call it. In that method: `const gate = await getCompanyAutomationGate(companyId)` (import from the Plan 01... Plan 03 gate module `@/lib/services/phishing-automation-gate`). Then, each in its OWN try/catch that logs `console.error('[WEBHOOK] <stage> stage error', ...)` and continues to the next stage (a failure must not abort later stages or block the webhook): (1) if `gate.autoParse`: `await parseAndStoreMessage({ reportId, ticketId: Number(ticketId) })`; (2) if `gate.autoClassify`: `const result = await classifyCampaign(campaignId)` and retain `result.verdict`; (3) if `gate.autoReport`: determine the current verdict — use the just-computed `result.verdict` if classify ran this pass, otherwise read the most-recent classifications row for the campaign (`SELECT verdict FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC LIMIT 1`) — and ONLY when that verdict === `'USER_AWARENESS'` call `await generateAndPostAcknowledgment(campaignId)`. This is the narrow D-04 carve-out (watch-out flags #2/#3): auto_report auto-posts EXCLUSIVELY the acknowledge_user thank-you note for USER_AWARENESS; every other verdict/action remains proposed-only and manual-approval-gated — do NOT auto-approve or auto-post block/purge/warn_user/etc. Add the needed imports (getCompanyAutomationGate, parseAndStoreMessage, classifyCampaign, generateAndPostAcknowledgment). Keep the whole thing invoked through the existing fire-and-forget `.catch()` at the call site — do not `await` it in `processWebhook`'s main flow. Use `Number(r.id)` (the ticket id) for ticketId and `r.company_id` for companyId.
  </action>
  <verify>
    <automated>cd /opt/stacks/pulse && npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - `grep -q "getCompanyAutomationGate" lib/services/webhook-service.ts` and it is called with the ticket's company_id
    - `grep -q "USER_AWARENESS" lib/services/webhook-service.ts` guarding the auto_report acknowledge call
    - The auto_report branch calls ONLY generateAndPostAcknowledgment (no auto-call to approve/remediate/block/purge/warn_user endpoints or services — grep shows no such call added)
    - detect + groupReportIntoCampaign remain unconditional (not wrapped in any gate check)
    - Each of the three gated stages is in its own try/catch that logs and continues
    - `npx tsc --noEmit` exits 0
  </acceptance_criteria>
  <done>Opted-in companies get automatic parse->classify->acknowledge on phishing webhooks; acknowledge auto-posts only for USER_AWARENESS; detection/grouping stay always-on; other actions stay manual; type-check passes.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Autotask webhook -> pipeline | external ticket event triggers automatic, possibly customer-visible, note posting |
| pipeline -> Autotask (customer portal) | auto-posted acknowledge_user note is client-visible |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-23-08 | Elevation of Privilege | auto_report carve-out | mitigate | auto_report auto-posts EXCLUSIVELY generateAndPostAcknowledgment and only when verdict === 'USER_AWARENESS'; a runtime verdict guard plus the absence of any auto-call to approve/remediate/block/purge keeps every destructive action on the manual-approval path (D-04) |
| T-23-09 | Denial of Service | stage chain blocking webhook | mitigate | Chain runs via the existing fire-and-forget `.catch()` call site; each stage has its own try/catch that logs and continues — no stage can block processWebhook's response or abort later stages |
| T-23-10 | Spoofing/Tampering | webhook authenticity | accept | Autotask webhook HMAC verification is unchanged (verified upstream in webhook-service before processWebhook); this plan adds no new inbound surface |
| T-23-11 | Information Disclosure | auto-posted note to wrong/absent company | mitigate | Gate is keyed on the ticket's own company_id; absent row = all-false so nothing auto-posts for un-opted-in companies (D-06) |
</threat_model>

<verification>
- `npx vitest run lib/services/phishing-automation-gate.test.ts` passes
- `npx tsc --noEmit --pretty` passes
- Gate reader defaults to all-false; webhook runs gated parse/classify/acknowledge; acknowledge only for USER_AWARENESS; detection/grouping unconditional
</verification>

<success_criteria>
- For an opted-in company, a phishing-flagged webhook auto-runs parse (if on) -> classify (if on) -> acknowledge_user note (if on AND USER_AWARENESS), without a human clicking anything
- No other verdict/action is ever auto-posted or auto-approved; detection + grouping remain unconditional; un-opted-in companies see no behavior change
</success_criteria>

<output>
Create `.planning/phases/23-classification-disposition-per-client-automation-gate/23-05-SUMMARY.md` when done
</output>
