---
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
  - lib/services/triage-note-format.ts
  - lib/services/remediation-service.ts
  - lib/services/remediation-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"
    - "When an approved acknowledge_user action is remediated (the manual path), a real customer-visible note is posted via generateAndPostAcknowledgment; the 7 existing action types remain simulated status-only transitions"
  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"
    - path: "lib/services/remediation-service.ts"
      provides: "acknowledge_user completion triggers a real note post; other action types unchanged"
      contains: "generateAndPostAcknowledgment"
  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"
    - from: "remediateApprovedActions acknowledge_user completion"
      to: "generateAndPostAcknowledgment(campaignId)"
      via: "post-commit call scoped to acknowledge_user only"
      pattern: "generateAndPostAcknowledgment"
---

<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, adds a triage-note-service writer that posts a customer-visible thank-you note, and wires that writer into the manual approve->remediate path so the note actually posts (the D-04 real-effect carve-out, scoped to `acknowledge_user` only).

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, `generateAndPostAcknowledgment()`, and the manual-path wiring in remediation-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>
<!-- 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/triage-note-format.ts — `TriageNoteEvidence.verdict` (line 25) is currently `'SPAM' | 'UNWANTED' | 'THREAT' | null`; its only caller (triage-note-service.ts) force-casts the DB value via `as TriageNoteEvidence['verdict']`. verdict is only interpolated into note text here (no Record/switch lookup).

From lib/services/remediation-default-params.ts — `deriveDefaultParams(actionType, evidence)` exhaustive switch with `no_action -> {}` precedent.

From lib/services/remediation-service.ts — `remediateApprovedActions(campaignId, actor)` (lines 173-207) transitions status='approved' rows to 'completed' inside a `postgresClient.transaction`, returning `RemediateResult { campaignId, actions: RemediateResultAction[] }` where each action carries `{ id, actionType, status:'completed', alreadyCompleted }`. Per the top-of-file D-01 doc comment (lines 1-19), this file makes NO real provider call for any of the 7 existing action types — the "effect" is the simulated approved->completed transition. This plan adds ONE narrowly-scoped real effect for the new `acknowledge_user` action only.
</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, lib/services/triage-note-format.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)
    - lib/services/triage-note-format.ts (TriageNoteEvidence.verdict union line 25 — the narrow type to widen)
    - .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. ALSO widen `TriageNoteEvidence.verdict` in `lib/services/triage-note-format.ts` (currently `'SPAM' | 'UNWANTED' | 'THREAT' | null`) to include `'USER_AWARENESS'` (WARNING 2 — the caller force-casts the DB verdict via `as TriageNoteEvidence['verdict']`, so once USER_AWARENESS exists at runtime the type must admit it). This is a pure type widen — verdict is only interpolated into note text in that formatter, never used in a Record/switch lookup, so no formatting/logic change is needed. 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)
    - `grep -q "USER_AWARENESS" lib/services/triage-note-format.ts` succeeds in the TriageNoteEvidence.verdict union
    - 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; the triage-note evidence type admits the new verdict; 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>

<task type="auto" tdd="true">
  <name>Task 3: Wire the acknowledge_user manual-path real note post into remediateApprovedActions</name>
  <files>lib/services/remediation-service.ts, lib/services/remediation-service.test.ts</files>
  <read_first>
    - lib/services/remediation-service.ts (top-of-file D-01 doc comment lines 1-19; remediateApprovedActions lines 173-207 — the approved->completed transition loop + RemediateResult return shape)
    - lib/services/triage-note-service.ts (generateAndPostAcknowledgment from Task 2 — signature + per-ticket error isolation)
    - lib/services/remediation-service.test.ts (queryMock/transactionMock/writeAuditEventMock mock setup lines 1-86; makeClient + stage() helpers; existing remediate tests staging remediationRows via `SELECT id::text, action_type, status ... FOR UPDATE`)
    - .planning/phases/23-classification-disposition-per-client-automation-gate/23-CONTEXT.md (D-04 — the carve-out applies to acknowledge_user ONLY; every other action stays manual/simulated)
  </read_first>
  <behavior>
    - remediateApprovedActions on a campaign whose approved rows include an acknowledge_user action calls generateAndPostAcknowledgment(campaignId) exactly once, after the transition
    - remediateApprovedActions on a campaign whose approved rows are only existing action types (e.g. block_sender, warn_user) does NOT call generateAndPostAcknowledgment
    - an acknowledge_user row that is already 'completed' (idempotent re-run — no approved->completed transition this pass) does NOT re-post
    - a generateAndPostAcknowledgment rejection does not propagate out of remediateApprovedActions (the DB transition already committed) — it is caught and logged
  </behavior>
  <action>
    Special-case `acknowledge_user` in `remediateApprovedActions` — the narrow D-04 real-effect carve-out for the ONE new non-destructive action, leaving all 7 existing action types (quarantine, block_sender, purge_message, warn_user, reset_password, isolate_endpoint, disable_forwarding_rule) as simulated status-only transitions (do NOT add a provider call for any of them). Do NOT call the Autotask writer inside the `postgresClient.transaction` callback (it does network I/O — a held transaction and a post-then-rollback are both hazards). Instead, capture the transaction's `RemediateResult` in a const, and AFTER the transaction returns (post-commit), check `result.actions.some(a => a.actionType === 'acknowledge_user' && a.alreadyCompleted === false)` — this is true only when an approved acknowledge_user row was transitioned this pass, and false for the already-completed idempotent case. When true, `await generateAndPostAcknowledgment(campaignId)` inside its OWN `try/catch` that does `console.error('[REMEDIATE] acknowledge_user note post failed', campaignId, err)` and swallows the error (the remediation already committed; generateAndPostAcknowledgment already isolates per-ticket failures internally). Import `generateAndPostAcknowledgment` from `'./triage-note-service'`. Update the top-of-file D-01 doc comment to record the carve-out: as of Phase 23 the ONLY real provider call in this file is the post-commit `acknowledge_user` customer-note post (a non-destructive thank-you — D-04); the original 7 action types remain simulated status-only transitions. In remediation-service.test.ts add `vi.mock('./triage-note-service', () => ({ generateAndPostAcknowledgment: (...args) => generateAndPostAcknowledgmentMock(...args) }))` with a top-level `const generateAndPostAcknowledgmentMock = vi.fn()` (reset in beforeEach), then add tests asserting: it IS called once with the campaignId when an approved acknowledge_user row is remediated; it is NOT called for a block_sender/warn_user-only remediation; and it is NOT called when the acknowledge_user row staged is already 'completed'. Follow RED (failing tests first) -> GREEN.
  </action>
  <verify>
    <automated>cd /opt/stacks/pulse && npx vitest run lib/services/remediation-service.test.ts && npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - `grep -q "generateAndPostAcknowledgment" lib/services/remediation-service.ts` and it is imported from './triage-note-service'
    - The post call is guarded by `actionType === 'acknowledge_user'` AND `alreadyCompleted === false` and runs only after the transaction commits (outside the transaction callback)
    - No provider/Autotask call is added for any other action type (the acknowledge post is the only external call introduced in this file)
    - Tests assert generateAndPostAcknowledgment IS called for a remediated acknowledge_user action and is NOT called for a block_sender/warn_user-only remediation, and NOT called for an already-completed acknowledge_user row
    - `npx vitest run lib/services/remediation-service.test.ts` passes (existing REMED tests still green)
    - `npx tsc --noEmit` exits 0
  </acceptance_criteria>
  <done>Approving then remediating an acknowledge_user action posts a real customer-visible note (the manual path is now functional) while every other action type keeps its simulated status-only behavior; 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), on both the manual remediate path and the automatic webhook path (Plan 05) |

## 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; the real-effect carve-out in remediateApprovedActions is guarded by `actionType === 'acknowledge_user'` so no destructive action inherits a real provider call; the auto-post-without-approval carve-out is separately scoped to the webhook path only (Plan 05) |
</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 lib/services/remediation-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; remediating acknowledge_user posts a real note while other action types stay simulated
</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
- The manual approve->remediate path posts the acknowledge_user note for real; the 7 existing action types remain simulated status-only transitions
</success_criteria>

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