diff --git a/.planning/phases/23-classification-disposition-per-client-automation-gate/23-01-PLAN.md b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-01-PLAN.md
index 805b7b4..22e0a3e 100644
--- a/.planning/phases/23-classification-disposition-per-client-automation-gate/23-01-PLAN.md
+++ b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-01-PLAN.md
@@ -11,6 +11,9 @@ files_modified:
- 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:
@@ -19,6 +22,7 @@ must_haves:
- "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"
@@ -29,6 +33,9 @@ must_haves:
- 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"
@@ -38,13 +45,17 @@ must_haves:
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"
---
-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.
+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, and `generateAndPostAcknowledgment()`.
+Output: Extended `Verdict` union + action mapping, `acknowledge_user` default-params case, `generateAndPostAcknowledgment()`, and the manual-path wiring in remediation-service.
@@ -82,7 +93,11 @@ From lib/services/triage-note-service.ts — existing TicketNotes write (lines 1
`client.createEntity('TicketNotes', { ticketID, title, description: noteText, noteType: 1, publish: 1 })`
`generateAndPostTriageNote(campaignId: string): Promise` — 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.
@@ -90,12 +105,13 @@ From lib/services/remediation-default-params.ts — `deriveDefaultParams(actionT
Task 1: Add USER_AWARENESS verdict + acknowledge_user action mapping
- 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/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
- 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)
@@ -105,7 +121,7 @@ From lib/services/remediation-default-params.ts — `deriveDefaultParams(actionT
- deriveDefaultParams('acknowledge_user', evidence) returns {}
- 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.
+ 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.
cd /opt/stacks/pulse && npx vitest run lib/services/campaign-classifier.test.ts lib/services/remediation-default-params.test.ts && npx tsc --noEmit --pretty
@@ -114,10 +130,11 @@ From lib/services/remediation-default-params.ts — `deriveDefaultParams(actionT
- `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
- 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.
+ 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.
@@ -146,6 +163,38 @@ From lib/services/remediation-default-params.ts — `deriveDefaultParams(actionT
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.
+
+ Task 3: Wire the acknowledge_user manual-path real note post into remediateApprovedActions
+ lib/services/remediation-service.ts, lib/services/remediation-service.test.ts
+
+ - 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)
+
+
+ - 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
+
+
+ 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.
+
+
+ cd /opt/stacks/pulse && npx vitest run lib/services/remediation-service.test.ts && npx tsc --noEmit --pretty
+
+
+ - `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
+
+ 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.
+
+
@@ -153,25 +202,26 @@ From lib/services/remediation-default-params.ts — `deriveDefaultParams(actionT
| Boundary | Description |
|----------|-------------|
-| Pulse -> Autotask API | acknowledge_user note text crosses to a customer-visible surface (client portal) |
+| 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 and its auto-post carve-out is scoped to the webhook path only (Plan 05); no destructive action inherits the exemption |
+| 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) |
-- `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 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
+- 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
- 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