diff --git a/.planning/phases/23-classification-disposition-per-client-automation-gate/23-02-SUMMARY.md b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-02-SUMMARY.md new file mode 100644 index 0000000..136cabc --- /dev/null +++ b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-02-SUMMARY.md @@ -0,0 +1,104 @@ +--- +phase: 23-classification-disposition-per-client-automation-gate +plan: 02 +subsystem: ui +tags: [react, typescript, phishing, tailwind, classification] + +# Dependency graph +requires: + - phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve + provides: ClassificationCard, ActionAreaCard, TimelineCard components and the campaign review page they render on +provides: + - USER_AWARENESS verdict rendered with a distinct emerald badge in ClassificationCard + - acknowledge_user action label + no-params ActionParamsForm case in ClassificationCard and ActionAreaCard + - USER_AWARENESS verdict tint (emerald) in TimelineCard plus a defensive fallback on both `.split(' ')` tint sites +affects: [23-classification-disposition-per-client-automation-gate, review-ui] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "New verdict/action literal added to a component: extend the union type, the Record lookup table, and the ACTION_LABEL map together — TypeScript's exhaustive-Record-key check on Record is what catches a forgotten entry" + +key-files: + created: [] + modified: + - components/phishing/classification-card.tsx + - components/phishing/action-area-card.tsx + - components/phishing/timeline-card.tsx + +key-decisions: + - "Used emerald (bg-emerald-500/15 text-emerald-600 in ClassificationCard; bg-emerald-500 text-emerald-600 dot+text format in TimelineCard) for USER_AWARENESS — distinct from UNWANTED's amber and THREAT's destructive red, signalling a positive/benign disposition (D-01 discretion, plan's own suggestion)" + - "acknowledge_user renders as a normal manual checkbox+Approve action in ActionAreaCard with zero automation-gate/per-company logic — the auto-approval carve-out lives only in the webhook path (Plan 05), confirmed via grep for automation_gate/auto_report/auto_parse/auto_classify returning no matches" + - "TimelineCard's VERDICT_TINT lookup was made defensive with a fallback (?? / || 'bg-muted-foreground text-muted-foreground') on both .split(' ') call sites, because the underlying phishing-timeline.ts service emits verdict as an unvalidated runtime string — any future/unrecognized verdict can never crash the review page again (T-23-12 mitigation)" + +patterns-established: + - "Verdict-keyed Record lookups (VERDICT_VARIANT_CLASS, VERDICT_TINT) must add a defensive fallback wherever the source data crossing the type boundary is a validated-at-the-type-level-only runtime string" + +requirements-completed: [CLASSDISP-03] + +# Metrics +duration: 8min +completed: 2026-07-16 +--- + +# Phase 23 Plan 02: Review UI USER_AWARENESS + acknowledge_user Support Summary + +**Extended ClassificationCard, ActionAreaCard, and TimelineCard with a distinct emerald USER_AWARENESS verdict, an acknowledge_user manual action, and a defensive VERDICT_TINT fallback that prevents the review page from crashing on the new verdict.** + +## Performance + +- **Duration:** 8 min +- **Started:** 2026-07-16T23:28:00Z +- **Completed:** 2026-07-16T23:36:22Z +- **Tasks:** 3 completed +- **Files modified:** 3 + +## Accomplishments +- ClassificationCard now renders `USER_AWARENESS` with a distinct emerald badge (`bg-emerald-500/15 text-emerald-600`) and labels `acknowledge_user` as "Acknowledge user" +- ActionAreaCard renders `acknowledge_user` as a normal recommended-action checkbox + Approve flow (no-params form), with zero gate-check/automation-gate logic added to the manual review UI +- TimelineCard renders a `USER_AWARENESS` classification or `campaign_classified` audit entry with a matching emerald tint, and both `.split(' ')` tint-derivation sites now fall back to a neutral muted-foreground tint instead of throwing on any unrecognized runtime verdict string + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Add USER_AWARENESS badge variant + acknowledge_user label to ClassificationCard** - `af8a5bd` (feat) +2. **Task 2: Add acknowledge_user manual action case to ActionAreaCard** - `547372e` (feat) +3. **Task 3: Add USER_AWARENESS support to TimelineCard (prevents review-page crash)** - `9ae834f` (feat) + +**Plan metadata:** committed with this SUMMARY.md (worktree mode — orchestrator merges and records final metadata commit) + +## Files Created/Modified +- `components/phishing/classification-card.tsx` - Added USER_AWARENESS to verdict union + VERDICT_VARIANT_CLASS (emerald); added acknowledge_user to ACTION_LABEL +- `components/phishing/action-area-card.tsx` - Added acknowledge_user to ACTION_LABEL; added `case 'acknowledge_user'` to ActionParamsForm (no-params, mirrors no_action) +- `components/phishing/timeline-card.tsx` - Added USER_AWARENESS to classification-entry verdict union + VERDICT_TINT (emerald); widened campaign_classified audit-case cast; added defensive `?? '...' ` / `|| '...'` fallback on both `.split(' ')` tint sites + +## Decisions Made +- Emerald was chosen for USER_AWARENESS in both cards to keep a consistent visual language for the new disposition across the review page (matches the plan's own suggested color). +- No architectural changes were needed — all three tasks were additive Record-entry/union-literal extensions plus one defensive-programming fix (TimelineCard fallback), all within Rule 1/Rule 2 deviation bounds implied by the plan itself (the plan explicitly calls out the crash risk and fallback as part of the task, not a deviation). + +## Deviations from Plan + +None - plan executed exactly as written. The TimelineCard defensive fallback was explicitly specified in the plan's task 3 action text, not an executor-discovered deviation. + +## Issues Encountered + +None. `npx tsc --noEmit --pretty` passed with zero errors after each task, and all specified `grep` acceptance criteria (USER_AWARENESS presence, acknowledge_user label, absence of gate-related terms in action-area-card.tsx, presence of the fallback pattern) were verified directly. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness +- The review UI can now render every verdict and action introduced by Plan 01's classification/action schema without risk of a runtime crash. +- Plan 05 (webhook auto-approval carve-out) can proceed independently — this plan intentionally added no automation-gate logic to the manual UI, per D-04. +- No blockers for downstream plans in this phase. + +--- +*Phase: 23-classification-disposition-per-client-automation-gate* +*Completed: 2026-07-16* + +## Self-Check: PASSED + +All created/modified files verified present on disk; all task commits (`af8a5bd`, `547372e`, `9ae834f`) and the SUMMARY commit (`44bbe90`) verified present in git log. diff --git a/components/phishing/action-area-card.tsx b/components/phishing/action-area-card.tsx index 40fe6b5..99f38bc 100644 --- a/components/phishing/action-area-card.tsx +++ b/components/phishing/action-area-card.tsx @@ -72,6 +72,7 @@ const ACTION_LABEL: Record = { reset_password: 'Reset password', isolate_endpoint: 'Isolate endpoint', disable_forwarding_rule: 'Disable forwarding rule', + acknowledge_user: 'Acknowledge user', }; function humanizeAction(actionType: string): string { @@ -130,6 +131,12 @@ function ActionParamsForm({ No parameters — informational verdict, no remediation needed.

); + case 'acknowledge_user': + return ( +

+ No parameters — posts a customer-visible thank-you note to the reporting employee. +

+ ); case 'warn_user': return (
diff --git a/components/phishing/classification-card.tsx b/components/phishing/classification-card.tsx index a6fc5a5..34b3730 100644 --- a/components/phishing/classification-card.tsx +++ b/components/phishing/classification-card.tsx @@ -12,7 +12,7 @@ import { hasPermission } from '@/lib/permissions'; export interface ClassificationCardData { id: string; - verdict: 'SPAM' | 'UNWANTED' | 'THREAT'; + verdict: 'SPAM' | 'UNWANTED' | 'THREAT' | 'USER_AWARENESS'; confidence: string | null; summary: string | null; reasons: string[]; @@ -31,6 +31,7 @@ const VERDICT_VARIANT_CLASS: Record = SPAM: 'bg-slate-500/15 text-slate-600', UNWANTED: 'bg-amber-500/15 text-amber-600', THREAT: 'bg-destructive/15 text-destructive', + USER_AWARENESS: 'bg-emerald-500/15 text-emerald-600', }; const ACTION_LABEL: Record = { @@ -41,6 +42,7 @@ const ACTION_LABEL: Record = { reset_password: 'Reset password', isolate_endpoint: 'Isolate endpoint', disable_forwarding_rule: 'Disable forwarding rule', + acknowledge_user: 'Acknowledge user', }; function humanizeAction(actionType: string): string { diff --git a/components/phishing/timeline-card.tsx b/components/phishing/timeline-card.tsx index f1bcf81..3c23897 100644 --- a/components/phishing/timeline-card.tsx +++ b/components/phishing/timeline-card.tsx @@ -7,17 +7,23 @@ import { cn } from '@/lib/utils'; export type TimelineEntry = | { kind: 'report'; at: string; reportId: string; ticketNumber: string | null; companyName: string | null } - | { kind: 'classification'; at: string; verdict: 'SPAM' | 'UNWANTED' | 'THREAT'; confidence: string | null } + | { + kind: 'classification'; + at: string; + verdict: 'SPAM' | 'UNWANTED' | 'THREAT' | 'USER_AWARENESS'; + confidence: string | null; + } | { kind: 'audit'; at: string; eventType: string; actor: string | null; payload: unknown }; interface TimelineCardProps { timeline: TimelineEntry[]; } -const VERDICT_TINT: Record<'SPAM' | 'UNWANTED' | 'THREAT', string> = { +const VERDICT_TINT: Record<'SPAM' | 'UNWANTED' | 'THREAT' | 'USER_AWARENESS', string> = { SPAM: 'bg-slate-500 text-slate-600', UNWANTED: 'bg-amber-500 text-amber-600', THREAT: 'bg-destructive text-destructive', + USER_AWARENESS: 'bg-emerald-500 text-emerald-600', }; function humanizeEventType(eventType: string): string { @@ -46,11 +52,15 @@ function renderEntry(entry: TimelineEntry): { if (entry.kind === 'classification') { const confidence = entry.confidence != null ? ` (${entry.confidence}%)` : ''; + // Defensive fallback: phishing-timeline.ts emits verdict as a plain + // unvalidated runtime `string`, so a value outside this component's + // narrowed union must never crash the render (T-23-12). + const tint = VERDICT_TINT[entry.verdict] ?? 'bg-muted-foreground text-muted-foreground'; return { label: `Classified as ${entry.verdict}${confidence}`, icon: Sparkles, - dotClass: VERDICT_TINT[entry.verdict].split(' ')[0], - textClass: VERDICT_TINT[entry.verdict].split(' ')[1], + dotClass: tint.split(' ')[0], + textClass: tint.split(' ')[1], }; } @@ -84,8 +94,8 @@ function renderEntry(entry: TimelineEntry): { textClass: 'text-slate-600', }; case 'campaign_classified': { - const verdict = payload.verdict as 'SPAM' | 'UNWANTED' | 'THREAT' | undefined; - const tint = verdict ? VERDICT_TINT[verdict] : 'bg-muted-foreground text-muted-foreground'; + const verdict = payload.verdict as 'SPAM' | 'UNWANTED' | 'THREAT' | 'USER_AWARENESS' | undefined; + const tint = (verdict && VERDICT_TINT[verdict]) || 'bg-muted-foreground text-muted-foreground'; return { label: verdict ? `Classified as ${verdict}` : 'Campaign classified', icon: Sparkles,