docs(23): create phase plan (5 plans, 2 waves)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
This commit is contained in:
lorentz 2026-07-16 19:08:45 -04:00
parent fd92263416
commit 683e647984
6 changed files with 807 additions and 3 deletions

View file

@ -529,12 +529,16 @@ Phases execute in numeric order. v1.0 (Phases 1-9.1) shipped 2026-07-10. v2.0 (P
### Phase 23: Classification Disposition + Per-Client Automation Gate
**Goal:** Add a dedicated "User Awareness" verdict for confirmed phishing-simulation-vendor (KnowBe4/Breach Secure Now) reports — today forced into the generic UNWANTED bucket despite the classifier already detecting the simulation vendor and explicitly skipping the THREAT tier — and add an admin UI gate page letting an admin choose, per Autotask company, whether the phishing pipeline's parse/classify/report-to-ticket stages run automatically (now that the previously-dead Autotask webhook is fixed) or require the existing manual Analyze/Classify/triage-note triggers.
**Requirements**: TBD
**Requirements**: CLASSDISP-01, CLASSDISP-02, CLASSDISP-03, AUTOGATE-01, AUTOGATE-02, AUTOGATE-03
**Depends on:** Phase 17, Phase 18, Phase 19, Phase 20, Phase 21, Phase 22
**Plans:** 0 plans
**Plans:** 5 plans (2 waves)
Plans:
- [ ] TBD (run /gsd-plan-phase 23 to break down)
- [ ] 23-01-PLAN.md — USER_AWARENESS verdict + acknowledge_user action + customer-visible note writer (noteType 18) (CLASSDISP-01, CLASSDISP-02)
- [ ] 23-02-PLAN.md — Review UI: USER_AWARENESS badge + acknowledge_user manual action (CLASSDISP-03)
- [ ] 23-03-PLAN.md — Migration 100 phishing_automation_gate + admin GET/PATCH/DELETE API (AUTOGATE-01)
- [ ] 23-04-PLAN.md — /admin/phishing-automation page (3-toggle company table) + admin index tile (AUTOGATE-02)
- [ ] 23-05-PLAN.md — Gate reader + gated parse->classify->acknowledge webhook chain (D-04 carve-out) (AUTOGATE-03)
---
*Roadmap created: 2026-05-03*

View file

@ -0,0 +1,179 @@
---
phase: 23-classification-disposition-per-client-automation-gate
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- lib/services/campaign-classifier.ts
- lib/services/campaign-classifier.test.ts
- lib/services/remediation-default-params.ts
- lib/services/remediation-default-params.test.ts
- lib/services/triage-note-service.ts
- lib/services/triage-note-service.test.ts
autonomous: true
requirements: [CLASSDISP-01, CLASSDISP-02]
must_haves:
truths:
- "A campaign whose classifier reasoning matches the phishing-simulation-vendor allowlist is classified USER_AWARENESS, not UNWANTED"
- "The USER_AWARENESS verdict recommends exactly the acknowledge_user action"
- "acknowledge_user is NOT treated as destructive (requires_approval stays false for it)"
- "acknowledge_user posts a customer-visible Autotask ticket note (noteType 18) with an appreciative thank-you body"
artifacts:
- path: "lib/services/campaign-classifier.ts"
provides: "USER_AWARENESS verdict in Verdict union + acknowledge_user action mapping + simulation-branch assignment"
contains: "USER_AWARENESS"
- path: "lib/services/remediation-default-params.ts"
provides: "acknowledge_user empty-params case"
contains: "acknowledge_user"
- path: "lib/services/triage-note-service.ts"
provides: "generateAndPostAcknowledgment writer using noteType 18"
contains: "noteType: 18"
key_links:
- from: "classifyCampaign isSimulation branch"
to: "USER_AWARENESS verdict"
via: "direct assignment (replaces evaluateSpamVsUnwanted call)"
pattern: "verdict = 'USER_AWARENESS'"
- from: "mapVerdictToActions('USER_AWARENESS')"
to: "['acknowledge_user']"
via: "new switch arm"
pattern: "acknowledge_user"
---
<objective>
Add the dedicated "User Awareness" classification disposition and its `acknowledge_user` delivery action. Today the classifier already detects phishing-simulation-vendor senders (KnowBe4 / Breach Secure Now) and skips the THREAT tier, but the result falls into the generic `UNWANTED` bucket with a `warn_user` action. This plan introduces a distinct 4th verdict `USER_AWARENESS`, maps it to a new non-destructive `acknowledge_user` action, and adds a triage-note-service writer that posts a customer-visible thank-you note.
Purpose: Correctly distinguish "an employee did the right thing by reporting a training simulation" from "unwanted marketing spam", and reward it with a customer-facing acknowledgment instead of a warning.
Output: Extended `Verdict` union + action mapping, `acknowledge_user` default-params case, and `generateAndPostAcknowledgment()`.
</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>
<!-- Extracted from lib/services/campaign-classifier.ts — use directly, no exploration needed. -->
Current (line 150): `export type Verdict = 'SPAM' | 'UNWANTED' | 'THREAT';`
Current action mapping (lines 164-186):
- SPAM -> ['no_action']
- UNWANTED -> ['warn_user']
- THREAT -> ['block_sender', 'purge_message'] (+ reset_password/isolate_endpoint/disable_forwarding_rule when evidence.clicked > 0)
Destructive set (line 153) — DO NOT add acknowledge_user here:
`DESTRUCTIVE_ACTIONS = { block_sender, purge_message, reset_password, isolate_endpoint }`
`classifyCampaign` simulation branch (lines 457-464) currently reads:
`if (isSimulation) { verdict = evaluateSpamVsUnwanted(evidence); reasons.push('Sender domain matches a known phishing-simulation vendor allowlist ...'); }`
`ClassifyResult` (line 431) exposes `.verdict: Verdict`.
The `classifications.verdict` DB column is plain `TEXT` (migration 097 line 117) — no enum/CHECK, so no migration needed for the new value.
From lib/services/triage-note-service.ts — existing TicketNotes write (lines 176-183):
`client.createEntity('TicketNotes', { ticketID, title, description: noteText, noteType: 1, publish: 1 })`
`generateAndPostTriageNote(campaignId: string): Promise<TriageNoteResult>` — per-ticket try/catch loop over reports, returns `{ noteText, tickets: TriageNotePostResult[] }`.
From lib/services/remediation-default-params.ts — `deriveDefaultParams(actionType, evidence)` exhaustive switch with `no_action -> {}` precedent.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add USER_AWARENESS verdict + acknowledge_user action mapping</name>
<files>lib/services/campaign-classifier.ts, lib/services/campaign-classifier.test.ts, lib/services/remediation-default-params.ts, lib/services/remediation-default-params.test.ts</files>
<read_first>
- lib/services/campaign-classifier.ts (Verdict union line 150, DESTRUCTIVE_ACTIONS line 153, mapVerdictToActions lines 164-186, computeRequiresApproval line 189, classifyCampaign simulation branch lines 449-490)
- lib/services/campaign-classifier.test.ts (existing test structure + how verdict/action assertions are written)
- lib/services/remediation-default-params.ts (switch lines 23-42)
- lib/services/remediation-default-params.test.ts (existing case assertions)
- .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md (campaign-classifier + remediation-default-params sections, watch-out flag #4)
</read_first>
<behavior>
- mapVerdictToActions('USER_AWARENESS', ...) returns exactly ['acknowledge_user']
- computeRequiresApproval(['acknowledge_user']) returns false (acknowledge_user is NOT in DESTRUCTIVE_ACTIONS)
- classifyCampaign, given a simulation-sender campaign (existing KnowBe4/BSN fixture), produces verdict 'USER_AWARENESS' (previously UNWANTED/SPAM)
- deriveDefaultParams('acknowledge_user', evidence) returns {}
</behavior>
<action>
Add the literal `'USER_AWARENESS'` to the `Verdict` union (line 150) so it becomes `'SPAM' | 'UNWANTED' | 'THREAT' | 'USER_AWARENESS'`. The verdict string is LOCKED to `USER_AWARENESS` (planner decision, per D-01 which delegated exact string to discretion) — Plans 02 and 05 reference this exact literal, do not rename it. In `mapVerdictToActions`, add a `case 'USER_AWARENESS': return ['acknowledge_user'];` arm. Do NOT add `acknowledge_user` to `DESTRUCTIVE_ACTIONS` (watch-out flag #4) — its auto-approval carve-out (D-04) is enforced in the webhook path, not via this invariant, so `requires_approval` must compute false for it. In `classifyCampaign`'s `if (isSimulation)` branch, replace `verdict = evaluateSpamVsUnwanted(evidence);` with `verdict = 'USER_AWARENESS';` (per D-01/D-02 this is the exact code path the classifier already isolates for the allowlist match — no new branching). Keep the existing `reasons.push('Sender domain matches a known phishing-simulation vendor allowlist ...')`. In `remediation-default-params.ts`, add `case 'acknowledge_user': return {};` alongside the `no_action` precedent. Write/extend tests: add classifier assertions that a simulation fixture yields verdict USER_AWARENESS and actions ['acknowledge_user'] with requires_approval false; add a remediation-default-params assertion for the acknowledge_user empty-params case. Follow RED (add failing tests first) -> GREEN.
</action>
<verify>
<automated>cd /opt/stacks/pulse && npx vitest run lib/services/campaign-classifier.test.ts lib/services/remediation-default-params.test.ts && npx tsc --noEmit --pretty</automated>
</verify>
<acceptance_criteria>
- `grep -c "USER_AWARENESS" lib/services/campaign-classifier.ts` >= 2 (union + branch assignment + action case)
- `grep -q "case 'acknowledge_user'" lib/services/remediation-default-params.ts` succeeds
- `grep -v '^#' lib/services/campaign-classifier.ts | grep -c "acknowledge_user" ` >= 1 and acknowledge_user is absent from the DESTRUCTIVE_ACTIONS set block (lines 153-158)
- vitest run for both test files passes; a test asserts verdict === 'USER_AWARENESS' and recommendedActions === ['acknowledge_user'] and requiresApproval === false for a simulation fixture
- `npx tsc --noEmit` exits 0
</acceptance_criteria>
<done>USER_AWARENESS is a first-class verdict producing the non-destructive acknowledge_user action; simulation campaigns classify as USER_AWARENESS; type-check and tests pass.</done>
</task>
<task type="auto">
<name>Task 2: Add generateAndPostAcknowledgment customer-visible note writer</name>
<files>lib/services/triage-note-service.ts, lib/services/triage-note-service.test.ts</files>
<read_first>
- lib/services/triage-note-service.ts (generateAndPostTriageNote lines 85-195, the createEntity('TicketNotes', ...) call lines 176-183, per-ticket try/catch loop lines 172-192, TriageNoteResult / TriageNotePostResult return shapes)
- lib/services/triage-note-service.test.ts (how the Autotask client is mocked, how post results are asserted)
- .planning/phases/23-classification-disposition-per-client-automation-gate/23-CONTEXT.md (D-02 tone, D-03 noteType 18 correction)
- .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md (triage-note-service section + watch-out flag #1)
</read_first>
<action>
Add a sibling exported async function `generateAndPostAcknowledgment(campaignId: string)` that mirrors `generateAndPostTriageNote`'s signature and `{ noteText, tickets: TriageNotePostResult[] }` return shape and its per-ticket `try/catch`-in-loop error isolation (one entry per linked report, `posted: true/false`, on failure record `error`). The note body is a short, genuinely appreciative thank-you (D-02) — e.g. thanking the employee for reporting the suspicious message and reinforcing that their vigilance helps keep the company secure. It is NOT the evidence-dump `formatTriageNote()` template; do not include any evidence, URLs, secrets, or classification internals in the body. The `createEntity('TicketNotes', ...)` call MUST use `noteType: 18` (Client Portal Note — the field that actually controls customer visibility, verified live against this tenant's TicketNotes/entityInformation/fields on 2026-07-16) and `publish: 1` UNCHANGED (publish is an internal-staff tier field, orthogonal to client visibility — do NOT flip it; see watch-out flag #1). Use `title: 'Thank You — Suspicious Email Reported'` (or similar customer-appropriate title). Gather the campaign's linked report ticket IDs the same way `generateAndPostTriageNote` does. Add tests mirroring triage-note-service.test.ts: assert the createEntity payload carries noteType 18 and publish 1, that per-ticket failures are isolated (one failing ticket does not abort the others), and that the returned tickets array reflects per-ticket posted status.
</action>
<verify>
<automated>cd /opt/stacks/pulse && npx vitest run lib/services/triage-note-service.test.ts && npx tsc --noEmit --pretty</automated>
</verify>
<acceptance_criteria>
- `grep -q "generateAndPostAcknowledgment" lib/services/triage-note-service.ts` succeeds and the symbol is exported
- `grep -n "noteType: 18" lib/services/triage-note-service.ts` returns a line inside generateAndPostAcknowledgment
- No `publish: 0` or `publish:` value other than 1 introduced (grep confirms publish still 1)
- A test asserts the acknowledgment createEntity call uses noteType 18 and publish 1
- A test asserts per-ticket error isolation (loop continues after one ticket throws)
- `npx tsc --noEmit` exits 0
</acceptance_criteria>
<done>generateAndPostAcknowledgment posts a customer-visible (noteType 18) appreciative note per linked ticket with the same error-isolation semantics as the existing triage-note writer; tests and type-check pass.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Pulse -> Autotask API | acknowledge_user note text crosses to a customer-visible surface (client portal) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-23-01 | Information Disclosure | generateAndPostAcknowledgment note body | mitigate | Note body is a fixed appreciative template with zero evidence/URL/secret interpolation — nothing from the parsed email or classification reasons is placed in a customer-visible note |
| T-23-02 | Tampering | acknowledge_user approval invariant | mitigate | acknowledge_user is deliberately kept OUT of DESTRUCTIVE_ACTIONS and its auto-post carve-out is scoped to the webhook path only (Plan 05); no destructive action inherits the exemption |
</threat_model>
<verification>
- `npx vitest run lib/services/campaign-classifier.test.ts lib/services/remediation-default-params.test.ts lib/services/triage-note-service.test.ts` passes
- `npx tsc --noEmit --pretty` passes
- USER_AWARENESS present in Verdict union and simulation branch; acknowledge_user maps and is non-destructive; note writer uses noteType 18
</verification>
<success_criteria>
- A simulation-vendor campaign classifies as USER_AWARENESS with the single acknowledge_user action and requires_approval false
- generateAndPostAcknowledgment posts a customer-visible thank-you note (noteType 18, publish 1) per linked ticket with per-ticket error isolation
</success_criteria>
<output>
Create `.planning/phases/23-classification-disposition-per-client-automation-gate/23-01-SUMMARY.md` when done
</output>

View file

@ -0,0 +1,147 @@
---
phase: 23-classification-disposition-per-client-automation-gate
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- components/phishing/classification-card.tsx
- components/phishing/action-area-card.tsx
autonomous: true
requirements: [CLASSDISP-03]
must_haves:
truths:
- "A campaign classified USER_AWARENESS renders in ClassificationCard with a distinct badge color (not UNWANTED's amber)"
- "The acknowledge_user action renders with a human-readable label ('Acknowledge user') wherever action labels appear"
- "In the manual review UI, acknowledge_user appears as a normal checkbox + Approve action for every company, with no gate-check logic"
artifacts:
- path: "components/phishing/classification-card.tsx"
provides: "USER_AWARENESS badge variant + acknowledge_user label"
contains: "USER_AWARENESS"
- path: "components/phishing/action-area-card.tsx"
provides: "acknowledge_user ActionParamsForm case + label"
contains: "acknowledge_user"
key_links:
- from: "ClassificationCardData['verdict'] union"
to: "VERDICT_VARIANT_CLASS record"
via: "new keyed entry"
pattern: "USER_AWARENESS.*bg-"
- from: "action-area-card ActionParamsForm switch"
to: "acknowledge_user case"
via: "new switch arm mirroring no_action"
pattern: "case 'acknowledge_user'"
---
<objective>
Surface the new `USER_AWARENESS` verdict and `acknowledge_user` action in the Phase 22 review UI. `ClassificationCard` gets a distinct badge color for the new verdict; `ActionAreaCard` renders `acknowledge_user` as a normal recommended-action checkbox (like every other action) so an operator can approve it manually on the review page for companies that do not have report-to-ticket automation enabled.
Purpose: Operators must be able to see the User Awareness disposition distinctly and manually approve the acknowledgment where automation is off.
Output: Extended verdict/label maps in two Phase 22 components.
</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>
<!-- Locked identifiers this UI must reference (defined in Plan 01) -->
Verdict literal: `USER_AWARENESS`
Action id: `acknowledge_user` -> display label: `Acknowledge user`
From components/phishing/classification-card.tsx:
- `ClassificationCardData['verdict']` type (line 15): `'SPAM' | 'UNWANTED' | 'THREAT'` — add USER_AWARENESS
- `VERDICT_VARIANT_CLASS` record (lines 30-38): SPAM slate, UNWANTED amber, THREAT destructive
- `ACTION_LABEL` record (lines 40-44): block_sender/purge_message/warn_user/no_action/reset_password/isolate_endpoint/disable_forwarding_rule
- StatusBadge consumes a free-form Tailwind class string (variantClass) — a new record key is sufficient, no StatusBadge change
From components/phishing/action-area-card.tsx:
- `ACTION_LABEL` record (lines 67-75)
- `ActionParamsForm` switch (lines 111-246); `no_action` case (lines 126-132) is the template for a no-params action
- Manual approval flow handleApprove -> POST /api/phishing/campaigns/[id]/approve (lines 375-399)
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add USER_AWARENESS badge variant + acknowledge_user label to ClassificationCard</name>
<files>components/phishing/classification-card.tsx</files>
<read_first>
- components/phishing/classification-card.tsx (ClassificationCardData['verdict'] type line 15, VERDICT_VARIANT_CLASS lines 30-38, ACTION_LABEL lines 40-44, StatusBadge usage line 115)
- components/ui/status-badge.tsx (CustomVariantProps — confirm variantClass is a free-form class string)
- .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md (classification-card.tsx section)
</read_first>
<action>
Add `'USER_AWARENESS'` to the `ClassificationCardData['verdict']` union (line 15). Add a `VERDICT_VARIANT_CLASS` entry keyed `USER_AWARENESS` using a distinct, clearly-not-UNWANTED color — use emerald: `'bg-emerald-500/15 text-emerald-600'` (D-01 discretion; distinct from UNWANTED's amber and THREAT's destructive red, signalling a positive/benign disposition). Add `acknowledge_user: 'Acknowledge user'` to the `ACTION_LABEL` record. No StatusBadge component change is required — variantClass is a free-form Tailwind string.
</action>
<verify>
<automated>cd /opt/stacks/pulse && npx tsc --noEmit --pretty</automated>
</verify>
<acceptance_criteria>
- `grep -q "USER_AWARENESS" components/phishing/classification-card.tsx` succeeds in both the verdict union and VERDICT_VARIANT_CLASS
- `grep -q "acknowledge_user: 'Acknowledge user'" components/phishing/classification-card.tsx` succeeds
- The USER_AWARENESS variant class differs from the UNWANTED (amber) and THREAT (destructive) entries
- `npx tsc --noEmit` exits 0 (the Record<verdict,...> is exhaustive so a missing key would fail the build)
</acceptance_criteria>
<done>ClassificationCard renders USER_AWARENESS with a distinct emerald badge and labels acknowledge_user; type-check passes.</done>
</task>
<task type="auto">
<name>Task 2: Add acknowledge_user manual action case to ActionAreaCard</name>
<files>components/phishing/action-area-card.tsx</files>
<read_first>
- components/phishing/action-area-card.tsx (ACTION_LABEL lines 67-75, ActionParamsForm switch lines 111-246, no_action case lines 126-132, handleApprove flow lines 375-399)
- .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md (action-area-card.tsx section + watch-out flag #2)
</read_first>
<action>
Add `acknowledge_user: 'Acknowledge user'` to this file's `ACTION_LABEL` record. Add a `case 'acknowledge_user':` arm to `ActionParamsForm`'s switch, mirroring the `no_action` no-params case — render a short muted paragraph explaining "No parameters — posts a customer-visible thank-you note to the reporting employee." acknowledge_user must render exactly like every other recommended action here (checkbox + Approve button via the existing handleApprove -> POST approve flow). Do NOT add any automation-gate / per-company gate-check logic to this component (watch-out flag #2 / D-04): the auto-approval carve-out lives only in the webhook path (Plan 05); the manual review UI always shows acknowledge_user as a normal approvable action for every company regardless of gate state.
</action>
<verify>
<automated>cd /opt/stacks/pulse && npx tsc --noEmit --pretty</automated>
</verify>
<acceptance_criteria>
- `grep -q "case 'acknowledge_user'" components/phishing/action-area-card.tsx` succeeds
- `grep -q "acknowledge_user: 'Acknowledge user'" components/phishing/action-area-card.tsx` succeeds
- No new reference to phishing_automation_gate, auto_report, or any gate table/flag appears in this component (grep -Ei "automation_gate|auto_report|auto_parse|auto_classify" returns nothing)
- `npx tsc --noEmit` exits 0
</acceptance_criteria>
<done>acknowledge_user renders as a normal manual checkbox+Approve action with a no-params form; no gate logic added to the manual UI; type-check passes.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| operator browser -> review UI | operator triggers approve of acknowledge_user; server-side permission gate (Phase 20/22) unchanged |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-23-03 | Elevation of Privilege | action-area-card acknowledge_user manual approve | accept | This plan adds no new permission path — acknowledge_user routes through the existing handleApprove -> POST approve flow whose server-side requirePermission gate (Phase 20) is unchanged; UI-only display change |
</threat_model>
<verification>
- `npx tsc --noEmit --pretty` passes (exhaustive Record keys enforce completeness)
- USER_AWARENESS badge distinct from UNWANTED; acknowledge_user labeled and manually approvable; no gate logic in action-area-card
</verification>
<success_criteria>
- USER_AWARENESS renders with a distinct badge color in the review page
- acknowledge_user shows as a normal manual checkbox+Approve action for every company with no gate-check logic in the component
</success_criteria>
<output>
Create `.planning/phases/23-classification-disposition-per-client-automation-gate/23-02-SUMMARY.md` when done
</output>

View file

@ -0,0 +1,157 @@
---
phase: 23-classification-disposition-per-client-automation-gate
plan: 03
type: execute
wave: 1
depends_on: []
files_modified:
- migrations/100_phishing_automation_gate.sql
- app/api/admin/phishing-automation/route.ts
- app/api/admin/phishing-automation/[companyId]/route.ts
autonomous: true
requirements: [AUTOGATE-01]
must_haves:
truths:
- "A phishing_automation_gate table exists keyed by company_id with auto_parse/auto_classify/auto_report booleans defaulting to false"
- "GET /api/admin/phishing-automation returns every company with its three gate flags, defaulting absent rows to all-false"
- "PATCH /api/admin/phishing-automation/{companyId} upserts the three flags with actor+timestamp stamping, admin-gated"
- "A company with no row reads as all three stages OFF (opt-in)"
artifacts:
- path: "migrations/100_phishing_automation_gate.sql"
provides: "opt-in per-company automation gate table"
contains: "phishing_automation_gate"
- path: "app/api/admin/phishing-automation/route.ts"
provides: "admin-gated GET list with COALESCE(..., false) defaults"
exports: ["GET"]
- path: "app/api/admin/phishing-automation/[companyId]/route.ts"
provides: "admin-gated PATCH upsert + DELETE revert-to-default"
exports: ["PATCH", "DELETE"]
key_links:
- from: "GET route"
to: "phishing_automation_gate"
via: "LEFT JOIN + COALESCE(..., false)"
pattern: "COALESCE\\(pag\\.auto_"
- from: "PATCH route"
to: "phishing_automation_gate"
via: "INSERT ... ON CONFLICT DO UPDATE"
pattern: "ON CONFLICT \\(company_id\\)"
---
<objective>
Create the per-company automation-gate persistence and admin API. A new `phishing_automation_gate` table (migration 100) stores three independent opt-in booleans per company (`auto_parse`, `auto_classify`, `auto_report`), defaulting to all-OFF. Two admin-gated routes mirror the existing `/api/admin/company-scope` pattern: a GET listing every company with its flags, and a `[companyId]` PATCH that upserts flags (plus a DELETE that reverts a company to the all-OFF default).
Purpose: The data + API layer the admin UI (Plan 04) and the webhook auto-pipeline (Plan 05) both depend on.
Output: migration 100 + two API routes.
</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>
<!-- Analogs to mirror near line-for-line. -->
migrations/082_company_scope.sql — opt-out table shape (company_id PK -> companies(id) ON DELETE CASCADE, boolean NOT NULL DEFAULT, updated_by TEXT, updated_at TIMESTAMPTZ). This phase flips defaults to false (opt-IN) and uses THREE booleans.
Latest existing migration is 099_indicators_metadata.sql -> use 100.
app/api/admin/company-scope/route.ts (75 lines):
- requireAdmin() guard (lines 33-35), search/type param parsing + conditions[] (lines 37-52)
- SELECT c.id::text, c.company_name, c.company_type, COALESCE(cs.in_scope, true) FROM companies c LEFT JOIN company_scope cs ... (lines 54-62)
- .map() camelCase transform + NextResponse.json (lines 64-75)
app/api/admin/company-scope/[companyId]/route.ts (59 lines):
- PATCH: requireAdmin, parseInt companyId, body validation, INSERT ... ON CONFLICT (company_id) DO UPDATE with updated_by = session email (lines 14-43)
- DELETE: revert to default (lines 45-58)
DB conventions: snake_case columns, camelCase API responses, postgresClient.query(sql, params) from lib/services/postgres-client.ts.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create migration 100 phishing_automation_gate + apply to dev DB</name>
<files>migrations/100_phishing_automation_gate.sql</files>
<read_first>
- migrations/082_company_scope.sql (full — the schema-shape analog to adapt)
- .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md (migration section)
- CLAUDE.md ("Migrations" + the migration-apply caveat: Postgres init only runs migrations on first volume boot; existing DB must be applied manually)
</read_first>
<action>
Create `migrations/100_phishing_automation_gate.sql` defining `CREATE TABLE IF NOT EXISTS phishing_automation_gate` with columns: `company_id BIGINT PRIMARY KEY REFERENCES companies(id) ON DELETE CASCADE`, `auto_parse BOOLEAN NOT NULL DEFAULT false`, `auto_classify BOOLEAN NOT NULL DEFAULT false`, `auto_report BOOLEAN NOT NULL DEFAULT false`, `updated_by TEXT`, `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`. All three booleans default false (D-06 opt-in — opposite polarity from company_scope). Add a `COMMENT ON TABLE` explaining: opt-in per-company phishing pipeline automation gate; absent row = all three stages manual-only; auto_report auto-posts ONLY acknowledge_user for USER_AWARENESS verdicts (D-04), not arbitrary actions. Use `IF NOT EXISTS` throughout. After writing, apply it to the running dev Postgres (this is an existing long-lived volume, so the file will not auto-run) — apply via the project's migration-apply path (check scripts/apply-migrations behavior first) or `docker exec pulse-postgres psql` per the CLAUDE.md caveat, then confirm the table exists.
</action>
<verify>
<automated>cd /opt/stacks/pulse && grep -q "CREATE TABLE IF NOT EXISTS phishing_automation_gate" migrations/100_phishing_automation_gate.sql && grep -c "NOT NULL DEFAULT false" migrations/100_phishing_automation_gate.sql | grep -qx 3 && echo OK</automated>
</verify>
<acceptance_criteria>
- File migrations/100_phishing_automation_gate.sql exists with `phishing_automation_gate` table, PK company_id referencing companies(id) ON DELETE CASCADE
- Exactly three `BOOLEAN NOT NULL DEFAULT false` columns: auto_parse, auto_classify, auto_report
- updated_by TEXT and updated_at TIMESTAMPTZ columns present
- Table applied to dev DB: `docker exec pulse-postgres psql -U <user> -d <db> -c "\d phishing_automation_gate"` shows the table (or the project's chosen apply-verification command confirms existence)
</acceptance_criteria>
<done>phishing_automation_gate table exists in the schema file and is applied to the dev database with opt-in (all-false) defaults.</done>
</task>
<task type="auto">
<name>Task 2: Admin GET list route + [companyId] PATCH/DELETE route</name>
<files>app/api/admin/phishing-automation/route.ts, app/api/admin/phishing-automation/[companyId]/route.ts</files>
<read_first>
- app/api/admin/company-scope/route.ts (full — GET analog)
- app/api/admin/company-scope/[companyId]/route.ts (full — PATCH/DELETE analog)
- lib/auth-utils.ts (requireAdmin signature/return shape)
- .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md (both route sections + the "send all three values each PATCH" recommendation)
</read_first>
<action>
Create `app/api/admin/phishing-automation/route.ts` exporting `GET`, mirroring company-scope/route.ts: `requireAdmin()` guard, same `search`/`type` query param + `conditions[]` building, then `SELECT c.id::text, c.company_name, c.company_type, COALESCE(pag.auto_parse, false) AS auto_parse, COALESCE(pag.auto_classify, false) AS auto_classify, COALESCE(pag.auto_report, false) AS auto_report FROM companies c LEFT JOIN phishing_automation_gate pag ON pag.company_id = c.id WHERE ... ORDER BY c.company_name`. Map rows to camelCase `{ id, companyName, companyType, companyTypeLabel, autoParse, autoClassify, autoReport }` and return via NextResponse.json (match company-scope's response envelope). Create `app/api/admin/phishing-automation/[companyId]/route.ts` exporting `PATCH` and `DELETE`, mirroring company-scope/[companyId]/route.ts. PATCH: `requireAdmin()`, parseInt+NaN-guard companyId, validate the body carries the three booleans `autoParse`/`autoClassify`/`autoReport` (client always sends all three current values — the admin page knows them from local state, avoiding partial-update SQL), then `INSERT INTO phishing_automation_gate (company_id, auto_parse, auto_classify, auto_report, updated_by, updated_at) VALUES ($1,$2,$3,$4,$5,NOW()) ON CONFLICT (company_id) DO UPDATE SET auto_parse = EXCLUDED.auto_parse, auto_classify = EXCLUDED.auto_classify, auto_report = EXCLUDED.auto_report, updated_by = EXCLUDED.updated_by, updated_at = NOW()` with `updated_by` = `(session?.user as any)?.email ?? null`. DELETE: `requireAdmin()`, delete the company's row (reverts to all-OFF default), return `{ ok: true }`.
</action>
<verify>
<automated>cd /opt/stacks/pulse && npx tsc --noEmit --pretty</automated>
</verify>
<acceptance_criteria>
- GET route: `grep -c "COALESCE(pag.auto_" app/api/admin/phishing-automation/route.ts` >= 3 and `grep -q "requireAdmin" app/api/admin/phishing-automation/route.ts`
- PATCH route: `grep -q "ON CONFLICT (company_id)" app/api/admin/phishing-automation/[companyId]/route.ts` and updates all three auto_* columns
- Both routes call `requireAdmin()` and return its error when present
- DELETE handler present in the [companyId] route
- `npx tsc --noEmit` exits 0
</acceptance_criteria>
<done>GET returns all companies with COALESCE(false) gate defaults; PATCH upserts all three flags with actor stamping; DELETE reverts to default; both admin-gated; type-check passes.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| operator browser -> admin API | authenticated admin toggles per-company automation flags |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-23-04 | Elevation of Privilege | /api/admin/phishing-automation GET+PATCH+DELETE | mitigate | Every handler calls requireAdmin() and returns its error before any DB access — no non-admin can read or change gate state |
| T-23-05 | Tampering | PATCH body (arbitrary company id / non-boolean flags) | mitigate | parseInt+NaN-guard on companyId; body validated to carry three booleans; parameterized query prevents injection; FK ON DELETE CASCADE keeps rows consistent with companies |
| T-23-06 | Repudiation | who changed a company's gate | mitigate | updated_by (session email) + updated_at stamped on every upsert |
</threat_model>
<verification>
- Migration file present + applied; `npx tsc --noEmit --pretty` passes
- GET defaults absent rows to all-false; PATCH upserts with actor stamp; both admin-gated
</verification>
<success_criteria>
- phishing_automation_gate table exists with opt-in (all-false) defaults
- GET lists companies with three flags; PATCH upserts; DELETE reverts; all admin-gated
</success_criteria>
<output>
Create `.planning/phases/23-classification-disposition-per-client-automation-gate/23-03-SUMMARY.md` when done
</output>

View file

@ -0,0 +1,153 @@
---
phase: 23-classification-disposition-per-client-automation-gate
plan: 04
type: execute
wave: 2
depends_on: ["23-03"]
files_modified:
- app/admin/phishing-automation/page.tsx
- app/admin/page.tsx
autonomous: false
requirements: [AUTOGATE-02]
must_haves:
truths:
- "An admin can open /admin/phishing-automation and see a searchable/filterable company table"
- "Each company row exposes three independent Switch toggles: auto-parse, auto-classify, auto-report"
- "Toggling a switch PATCHes /api/admin/phishing-automation/{companyId} and reflects the new state"
- "The page is reachable from the /admin index card grid"
artifacts:
- path: "app/admin/phishing-automation/page.tsx"
provides: "admin gate page (table + search/filter + 3 Switches per row)"
min_lines: 120
- path: "app/admin/page.tsx"
provides: "tile linking to /admin/phishing-automation"
contains: "phishing-automation"
key_links:
- from: "page toggle()"
to: "/api/admin/phishing-automation/{companyId}"
via: "fetch PATCH"
pattern: "fetch\\(`/api/admin/phishing-automation/"
- from: "page load"
to: "/api/admin/phishing-automation"
via: "fetch GET in useEffect"
pattern: "fetch\\('/api/admin/phishing-automation'"
---
<objective>
Build the `/admin/phishing-automation` page: a searchable/filterable company table with three per-row `Switch` toggles (auto-parse, auto-classify, auto-report), structurally mirroring the existing `/admin/client-scope` page. Add a tile to the `/admin` index card grid so the page is discoverable.
Purpose: The human control surface for the per-company automation gate (D-08).
Output: New admin page + admin index tile.
</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>
<!-- GET /api/admin/phishing-automation returns (Plan 03): -->
{ companies: Array<{ id: string; companyName: string; companyType: number | null; companyTypeLabel: string | null; autoParse: boolean; autoClassify: boolean; autoReport: boolean }> }
<!-- PATCH /api/admin/phishing-automation/{companyId} body: { autoParse, autoClassify, autoReport } (send all three current values) -->
Analog: app/admin/client-scope/page.tsx (243 lines, near line-for-line template):
- imports block lines 1-28 (shadcn Card/Table/Switch/Input/Select, PageHeader, lucide icons, sonner toast)
- Company interface + state lines 30-49
- load() useEffect lines 51-76
- toggle(company, next) lines 78-100
- search Input + type Select chrome lines 102-178
- <Table>/<TableRow> with trailing single <Switch> cell lines 200-236
/admin index tile pattern: app/admin/page.tsx tile objects (lines 238-249) — { title, href, icon, description } inside a section's tiles[] array.
</interfaces>
</context>
<tasks>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 1: Build /admin/phishing-automation page (3-toggle company table)</name>
<files>app/admin/phishing-automation/page.tsx</files>
<read_first>
- app/admin/client-scope/page.tsx (full file — the near-verbatim template)
- components/ui/switch.tsx (Switch props)
- .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md (page.tsx section — Company interface with 3 booleans, per-stage toggle(company, stage, next), three <Switch> cells)
</read_first>
<action>
Create `app/admin/phishing-automation/page.tsx` as a `'use client'` page cloning client-scope/page.tsx's structure. Extend the `Company` interface with `autoParse: boolean; autoClassify: boolean; autoReport: boolean` (drop the single `inScope`). Repoint the load fetch to `/api/admin/phishing-automation`. Replace the single `toggle(company, next)` with a per-stage `toggle(company: Company, stage: 'autoParse' | 'autoClassify' | 'autoReport', next: boolean)` that PATCHes `/api/admin/phishing-automation/${company.id}` with a body containing ALL three current flag values (the two unchanged from local state plus the toggled one), updates local state on success, and uses the same sonner error/toast handling as client-scope. In the table, add THREE header cells (Auto-parse, Auto-classify, Auto-report) and render three `<Switch>` cells per row, each wired to `toggle(company, '<stage>', next)` with an `aria-label` like `Auto-parse for ${company.companyName}`. Reuse the search Input + company-type Select filter chrome verbatim. Use a `PageHeader` titled "Phishing Automation" with a description explaining the opt-in gate and that auto-report only auto-posts the acknowledge_user thank-you note for User Awareness verdicts. Add a short helper caption near the toggles clarifying stage dependencies (report meaningfully requires classify; classify requires parse) — informational only, do not enforce in UI.
</action>
<verify>
<automated>cd /opt/stacks/pulse && npx tsc --noEmit --pretty</automated>
<what-built>The /admin/phishing-automation page with a company table and three per-row Switch toggles wired to the Plan 03 API.</what-built>
<how-to-verify>
1. Run `npm run dev` and sign in as an admin.
2. Visit http://localhost:3100/admin/phishing-automation — confirm the company table loads with three toggle columns (Auto-parse, Auto-classify, Auto-report), all OFF by default.
3. Toggle Auto-parse for one company; confirm a success toast and that the switch stays on after a page reload (persisted via PATCH).
4. Use the search box and company-type filter; confirm the list filters.
</how-to-verify>
<resume-signal>Type "approved" or describe issues</resume-signal>
</verify>
<acceptance_criteria>
- `grep -q "fetch('/api/admin/phishing-automation'" app/admin/phishing-automation/page.tsx` (GET load)
- `grep -q "fetch(\`/api/admin/phishing-automation/" app/admin/phishing-automation/page.tsx` (PATCH toggle)
- Three distinct `<Switch` elements per row bound to autoParse/autoClassify/autoReport
- `npx tsc --noEmit` exits 0
- Human verification confirms toggles persist across reload
</acceptance_criteria>
<done>Admins can view and toggle per-company auto-parse/classify/report on /admin/phishing-automation; toggles persist; type-check passes and human verification approved.</done>
</task>
<task type="auto">
<name>Task 2: Add admin index tile for Phishing Automation</name>
<files>app/admin/page.tsx</files>
<read_first>
- app/admin/page.tsx (tile object shape lines 238-249, the "Tools & Data" section and its icon imports; the Client Scope tile at lines 241-246 is the direct pattern)
</read_first>
<action>
Add a new tile object to the same section that contains the "Client Scope" tile (Tools & Data) in `app/admin/page.tsx`: `{ title: 'Phishing Automation', href: '/admin/phishing-automation', icon: <a security/bot lucide icon already imported, e.g. ShieldCheck or Bot — import it if not present>, description: 'Per-company opt-in gate for automatic phishing parse / classify / acknowledge-note stages' }`. Ensure the chosen icon is imported at the top of the file if not already.
</action>
<verify>
<automated>cd /opt/stacks/pulse && grep -q "/admin/phishing-automation" app/admin/page.tsx && npx tsc --noEmit --pretty</automated>
</verify>
<acceptance_criteria>
- `grep -q "'/admin/phishing-automation'" app/admin/page.tsx` succeeds
- The tile has a title, href, icon (imported), and description
- `npx tsc --noEmit` exits 0
</acceptance_criteria>
<done>The /admin index card grid links to /admin/phishing-automation; type-check passes.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| operator browser -> admin page/API | admin views + toggles gate flags; page-level admin gate + API-level requireAdmin |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-23-07 | Elevation of Privilege | /admin/phishing-automation page | mitigate | Server-side authority is the Plan 03 requireAdmin() gate on every API call; the page performs no privileged action except through those admin-gated endpoints (middleware also blocks unauth'd /admin/* page loads) |
</threat_model>
<verification>
- `npx tsc --noEmit --pretty` passes
- Page loads, three toggles per row persist via PATCH, reachable from /admin index (human-verified)
</verification>
<success_criteria>
- An admin can open /admin/phishing-automation from the /admin index and toggle each of the three per-company stages independently, with changes persisting
</success_criteria>
<output>
Create `.planning/phases/23-classification-disposition-per-client-automation-gate/23-04-SUMMARY.md` when done
</output>

View file

@ -0,0 +1,164 @@
---
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>