From b50c1f5e1a58c64c1b37550df34d4110094dad1b Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 19:26:00 -0400 Subject: [PATCH] fix(23): resolve checker blockers (timeline crash, acknowledge_user manual post) + doc/type-safety warnings --- .../23-01-PLAN.md | 68 ++++++++++++++++--- .../23-02-PLAN.md | 49 +++++++++++-- .../23-03-PLAN.md | 34 +++++++++- 3 files changed, 135 insertions(+), 16 deletions(-) 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 diff --git a/.planning/phases/23-classification-disposition-per-client-automation-gate/23-02-PLAN.md b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-02-PLAN.md index 8096b94..6c31004 100644 --- a/.planning/phases/23-classification-disposition-per-client-automation-gate/23-02-PLAN.md +++ b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-02-PLAN.md @@ -7,6 +7,7 @@ depends_on: [] files_modified: - components/phishing/classification-card.tsx - components/phishing/action-area-card.tsx + - components/phishing/timeline-card.tsx autonomous: true requirements: [CLASSDISP-03] must_haves: @@ -14,6 +15,7 @@ must_haves: - "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" + - "A USER_AWARENESS classification entry or campaign_classified audit entry renders in TimelineCard with a distinct (emerald) tint and does NOT crash the review page" artifacts: - path: "components/phishing/classification-card.tsx" provides: "USER_AWARENESS badge variant + acknowledge_user label" @@ -21,6 +23,9 @@ must_haves: - path: "components/phishing/action-area-card.tsx" provides: "acknowledge_user ActionParamsForm case + label" contains: "acknowledge_user" + - path: "components/phishing/timeline-card.tsx" + provides: "USER_AWARENESS VERDICT_TINT entry + defensive .split fallback" + contains: "USER_AWARENESS" key_links: - from: "ClassificationCardData['verdict'] union" to: "VERDICT_VARIANT_CLASS record" @@ -30,13 +35,17 @@ must_haves: to: "acknowledge_user case" via: "new switch arm mirroring no_action" pattern: "case 'acknowledge_user'" + - from: "TimelineCard VERDICT_TINT lookup" + to: "USER_AWARENESS tint entry" + via: "4th record key + fallback on the .split sites" + pattern: "USER_AWARENESS" --- -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. +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; and `TimelineCard` gets a 4th verdict tint plus a defensive fallback so a USER_AWARENESS verdict no longer crashes the review page's timeline. -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. +Purpose: Operators must be able to see the User Awareness disposition distinctly and manually approve the acknowledgment where automation is off — and the review page must not crash for the exact verdict this phase creates. +Output: Extended verdict/label maps + a defensive tint fallback across three Phase 22 components. @@ -66,6 +75,13 @@ 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) + +From components/phishing/timeline-card.tsx: +- `TimelineEntry` classification-entry `verdict` union (line 10): `'SPAM' | 'UNWANTED' | 'THREAT'` — add USER_AWARENESS +- `VERDICT_TINT` record (lines 17-21): SPAM `'bg-slate-500 text-slate-600'`, UNWANTED `'bg-amber-500 text-amber-600'`, THREAT `'bg-destructive text-destructive'` (dot class + text class, space-separated) +- Classification branch (lines 52-53): `VERDICT_TINT[entry.verdict].split(' ')[0]` / `[1]` — crashes if lookup is undefined +- `campaign_classified` audit case (lines 86-95): casts `payload.verdict as 'SPAM'|'UNWANTED'|'THREAT'|undefined` then `tint.split(' ')` +- NOTE: the timeline SERVICE `lib/services/phishing-timeline.ts` emits `verdict` as plain runtime `string` (line 15, unvalidated) — so a `'USER_AWARENESS'` value reaches this component regardless of the component's own narrowed type; the fallback below is the runtime safety net. @@ -116,6 +132,29 @@ From components/phishing/action-area-card.tsx: 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. + + Task 3: Add USER_AWARENESS support to TimelineCard (prevents review-page crash) + components/phishing/timeline-card.tsx + + - components/phishing/timeline-card.tsx (TimelineEntry classification verdict line 10, VERDICT_TINT record lines 17-21, classification .split sites lines 52-53, campaign_classified audit case lines 86-95) + - lib/services/phishing-timeline.ts (TimelineEntry.verdict is plain `string` at runtime line 15 — unvalidated, so a USER_AWARENESS value flows straight into this component) + - .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md (classification-card.tsx section — mirror the emerald choice) + + + TimelineCard independently declares `verdict: 'SPAM' | 'UNWANTED' | 'THREAT'` and `VERDICT_TINT: Record<'SPAM'|'UNWANTED'|'THREAT', string>`, then does `VERDICT_TINT[entry.verdict].split(' ')[0]`. Because the timeline service (`phishing-timeline.ts`) emits `verdict` as plain runtime `string`, a `'USER_AWARENESS'` value hits `VERDICT_TINT['USER_AWARENESS']` = undefined and `undefined.split(' ')` throws — crashing the review page for exactly this phase's verdict. Fix: (1) add `'USER_AWARENESS'` to the classification-entry `verdict` union (line 10). (2) Add a 4th `VERDICT_TINT` entry keyed `USER_AWARENESS` and widen the Record key type to include it — use emerald to match Task 1's ClassificationCard choice, in this file's dot+text format: `USER_AWARENESS: 'bg-emerald-500 text-emerald-600'`. (3) Widen the `campaign_classified` audit-case cast (line 87) to include `'USER_AWARENESS'`. (4) Defensively guard BOTH `.split(' ')` sites — the classification branch (lines 52-53) and the audit branch (the `tint` var, lines 88-93) — with a fallback so ANY unexpected runtime verdict string can never crash: e.g. `const tint = VERDICT_TINT[entry.verdict] ?? 'bg-muted-foreground text-muted-foreground';` then `.split(' ')`, and in the audit case `const tint = (verdict && VERDICT_TINT[verdict]) || 'bg-muted-foreground text-muted-foreground';`. Do not change layout or any other behavior. + + + cd /opt/stacks/pulse && npx tsc --noEmit --pretty + + + - `grep -q "USER_AWARENESS" components/phishing/timeline-card.tsx` succeeds in both the verdict union and VERDICT_TINT + - `grep -c "?? 'bg-muted-foreground text-muted-foreground'\||| 'bg-muted-foreground text-muted-foreground'" components/phishing/timeline-card.tsx` >= 1 (defensive fallback covering the .split sites) + - The USER_AWARENESS tint differs from UNWANTED (amber) and THREAT (destructive) + - `npx tsc --noEmit` exits 0 + + TimelineCard renders a USER_AWARENESS classification/audit entry with a distinct emerald tint and no longer crashes on an unrecognized verdict; type-check passes. + + @@ -130,16 +169,18 @@ From components/phishing/action-area-card.tsx: | 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 | +| T-23-12 | Denial of Service | TimelineCard render on new verdict | mitigate | The 4th VERDICT_TINT entry plus a fallback on both `.split` sites means an unrecognized/new runtime verdict string can never throw and crash the review page | - `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 +- USER_AWARENESS badge distinct from UNWANTED; acknowledge_user labeled and manually approvable; no gate logic in action-area-card; TimelineCard renders USER_AWARENESS without crashing - 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 +- The review-page timeline renders a USER_AWARENESS classification/audit entry without crashing diff --git a/.planning/phases/23-classification-disposition-per-client-automation-gate/23-03-PLAN.md b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-03-PLAN.md index 92158af..4c37ed4 100644 --- a/.planning/phases/23-classification-disposition-per-client-automation-gate/23-03-PLAN.md +++ b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-03-PLAN.md @@ -8,6 +8,7 @@ files_modified: - migrations/100_phishing_automation_gate.sql - app/api/admin/phishing-automation/route.ts - app/api/admin/phishing-automation/[companyId]/route.ts + - .planning/REQUIREMENTS.md autonomous: true requirements: [AUTOGATE-01] must_haves: @@ -16,6 +17,7 @@ must_haves: - "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)" + - "REQUIREMENTS.md documents all six phase-23 requirement IDs (CLASSDISP-01/02/03, AUTOGATE-01/02/03) with Traceability rows" artifacts: - path: "migrations/100_phishing_automation_gate.sql" provides: "opt-in per-company automation gate table" @@ -26,6 +28,9 @@ must_haves: - path: "app/api/admin/phishing-automation/[companyId]/route.ts" provides: "admin-gated PATCH upsert + DELETE revert-to-default" exports: ["PATCH", "DELETE"] + - path: ".planning/REQUIREMENTS.md" + provides: "v3 CLASSDISP-*/AUTOGATE-* requirement entries + Traceability rows" + contains: "AUTOGATE-01" key_links: - from: "GET route" to: "phishing_automation_gate" @@ -38,10 +43,10 @@ must_haves: --- -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). +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). Also backfills the REQUIREMENTS.md entries for this phase's six requirement IDs (housekeeping — they currently exist only in ROADMAP.md). -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. +Purpose: The data + API layer the admin UI (Plan 04) and the webhook auto-pipeline (Plan 05) both depend on, plus requirement-doc consistency. +Output: migration 100 + two API routes + REQUIREMENTS.md v3 entries. @@ -124,6 +129,27 @@ DB conventions: snake_case columns, camelCase API responses, postgresClient.quer 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. + + Task 3: Backfill v3 CLASSDISP-*/AUTOGATE-* requirement entries + Traceability rows in REQUIREMENTS.md + .planning/REQUIREMENTS.md + + - .planning/REQUIREMENTS.md (v2 Requirements section lines 140-162, Traceability table lines 174-216 — mirror its row format and the Coverage footer) + - .planning/ROADMAP.md (Phase 23 block lines 529-541 — source the six requirement descriptions) + + + REQUIREMENTS.md has no entries for the six phase-23 requirement IDs — they exist only in ROADMAP.md (WARNING 1). Add a `## v3 Requirements` section (if one does not already exist; if it does, append) with entries for all six, sourced from the ROADMAP Phase 23 block: CLASSDISP-01 (dedicated USER_AWARENESS verdict for confirmed phishing-simulation-vendor reports, replacing the forced-UNWANTED disposition), CLASSDISP-02 (USER_AWARENESS maps to the new non-destructive acknowledge_user action that posts a customer-visible noteType-18 thank-you note), CLASSDISP-03 (review UI surfaces USER_AWARENESS + acknowledge_user distinctly), AUTOGATE-01 (per-company phishing_automation_gate table + admin GET/PATCH/DELETE API, opt-in defaults), AUTOGATE-02 (/admin/phishing-automation page with three per-company Switch toggles), AUTOGATE-03 (webhook auto-runs the gated parse->classify->acknowledge chain with the D-04 acknowledge_user auto-post carve-out). Then add six rows to the Traceability table mapping each ID to Phase 23 / Pending, and update the Coverage footer counts to include the six new requirements. Keep the existing entry + table format and wording style verbatim; do NOT remove or alter any existing v1/v2 entry or row. + + + cd /opt/stacks/pulse && grep -Eq "CLASSDISP-01|CLASSDISP-02|CLASSDISP-03" .planning/REQUIREMENTS.md && grep -Eq "AUTOGATE-01|AUTOGATE-02|AUTOGATE-03" .planning/REQUIREMENTS.md && [ "$(grep -c 'Phase 23' .planning/REQUIREMENTS.md)" -ge 6 ] && echo OK + + + - All six IDs (CLASSDISP-01/02/03, AUTOGATE-01/02/03) appear both as requirement entries and as Traceability rows mapped to Phase 23 + - The Coverage footer reflects the added requirements + - No existing v1/v2 entries or Traceability rows are removed or altered + + REQUIREMENTS.md documents the six phase-23 requirement IDs with descriptions and Traceability rows mapped to Phase 23; ROADMAP and REQUIREMENTS are consistent. + + @@ -145,11 +171,13 @@ DB conventions: snake_case columns, camelCase API responses, postgresClient.quer - Migration file present + applied; `npx tsc --noEmit --pretty` passes - GET defaults absent rows to all-false; PATCH upserts with actor stamp; both admin-gated +- REQUIREMENTS.md lists all six phase-23 IDs with Traceability rows - phishing_automation_gate table exists with opt-in (all-false) defaults - GET lists companies with three flags; PATCH upserts; DELETE reverts; all admin-gated +- REQUIREMENTS.md and ROADMAP.md agree on the phase-23 requirement IDs