diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-04-PLAN.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-04-PLAN.md
index 0845d1c..4a058ee 100644
--- a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-04-PLAN.md
+++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-04-PLAN.md
@@ -77,7 +77,7 @@ Audit event_type → label/icon/tint (UI-SPEC Timeline Spec):
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Classification Display Spec)
- Create `components/phishing/classification-card.tsx` (`'use client'`) exporting `ClassificationCard({ campaignId, classification, onReclassified })`. Wrap in `` with `` + leading lucide `Sparkles` (`h-4 w-4 mr-2 inline`) reading "Classification". Header row: verdict StatusBadge (per recipe above) + "{confidence}% confidence" inline + a right-aligned "Reclassify ticket" button (`variant="outline"` `size="sm"`) that is HIDDEN (not disabled) unless `hasPermission(role, 'phishing', 'analyze')` where role comes from `useSession()` (`(session?.user as {role?:string})?.role ?? 'user'`); on click it POSTs `/api/phishing/campaigns/${campaignId}/classify` then toasts and calls `onReclassified()`. Summary: `classification.summary` as `text-sm`. Reasons: `` one `- ` per reasons entry. Recommended actions: an informational chip row of humanized labels (block_sender→"Block sender", purge_message→"Purge message", warn_user→"Warn user", no_action→"No action", reset_password→"Reset password", isolate_endpoint→"Isolate endpoint", disable_forwarding_rule→"Disable forwarding rule") as slate/muted StatusBadges — NO checkboxes or editable params here (that surface lives only in ActionAreaCard). If `requiresApproval === true`, render an inline amber-tone ``: "This classification recommends a destructive action and requires explicit approval before remediation can proceed." This component renders nothing/omits itself when `classification` is null (the D-08 path handles that upstream).
+ Create `components/phishing/classification-card.tsx` (`'use client'`) exporting `ClassificationCard({ campaignId, classification, onReclassified })`. Wrap in `` with `` + leading lucide `Sparkles` (`h-4 w-4 mr-2 inline`) reading "Classification". Header row: verdict StatusBadge (per recipe above) + "{confidence}% confidence" inline + a right-aligned "Reclassify ticket" button (`variant="outline"` `size="sm"`) that is HIDDEN (not disabled) unless `hasPermission(role, 'phishing', 'analyze')` where role comes from `useSession()` (`(session?.user as {role?:string})?.role ?? 'user'`); on click it POSTs `/api/phishing/campaigns/${campaignId}/classify` then toasts and calls `onReclassified()`. Summary: `classification.summary` as `text-sm`. Reasons: `
` one `- ` per reasons entry. Recommended actions: an informational chip row of humanized labels (block_sender→"Block sender", purge_message→"Purge message", warn_user→"Warn user", no_action→"No action", reset_password→"Reset password", isolate_endpoint→"Isolate endpoint", disable_forwarding_rule→"Disable forwarding rule") as slate/muted StatusBadges — NO checkboxes or editable params here (that surface lives only in ActionAreaCard). If `requiresApproval === true`, render an inline amber-tone ``: "This classification recommends a destructive action and requires explicit approval before remediation can proceed." This component returns `null` (renders nothing) when `classification` is null — it does NOT attempt to render a verdict/reasons/actions for a missing classification. Two upstream paths pass a null classification, and plan 06 (not this card) renders the replacement UI: (a) a grouped-but-not-yet-classified campaign — the DEFAULT state, since classification is never auto-triggered by the detection/grouping pipeline — where plan 06 renders a "Classify this campaign" empty-state CTA; and (b) the D-08 ungrouped-report path, where plan 06 renders the D-08 Alert. This card must therefore guard on `classification == null` at the top and return null (never dereference `classification.verdict`/`.reasons`/etc.).
grep -q "hasPermission" components/phishing/classification-card.tsx && grep -q "font-bold" components/phishing/classification-card.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "classification-card" | grep -qx 0 && echo OK
@@ -86,6 +86,7 @@ Audit event_type → label/icon/tint (UI-SPEC Timeline Spec):
- `grep -q "requiresApproval" components/phishing/classification-card.tsx` succeeds and an Alert renders on that branch
- `grep -q "hasPermission(role, 'phishing', 'analyze')" components/phishing/classification-card.tsx` (or equivalent call) — Reclassify gated on analyze
- `grep -c "Checkbox" components/phishing/classification-card.tsx` returns 0 (no approve surface here)
+ - The component early-returns `null` when `classification == null` (no dereference of `classification.verdict`/`.reasons`) — verified by presence of a null/falsy guard before any `classification.` access
- `npx tsc --noEmit --pretty` reports no error in this file
ClassificationCard displays verdict/confidence/summary/reasons/recommended-action chips + approval warning; Reclassify gated on analyze permission.
diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-05-PLAN.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-05-PLAN.md
index 6a127ed..d8ec3f2 100644
--- a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-05-PLAN.md
+++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-05-PLAN.md
@@ -13,6 +13,7 @@ must_haves:
- "Operator selects recommended action(s) via checkboxes, edits each action's pre-filled params inline, and submits all checked actions in one POST /approve call"
- "Remediate and mark-false-positive call their existing Phase 20 routes and trigger a refetch on success (no optimistic mutation)"
- "Approve/remediate/mark-false-positive buttons are disabled with a tooltip when the operator lacks the exact permission the API enforces, or when the campaign is already resolved"
+ - "When classification is null (grouped-but-unclassified default state), ActionAreaCard renders an informational note and no action rows/buttons instead of dereferencing recommendedActions — it never crashes on the initial unclassified state"
artifacts:
- path: "components/phishing/action-area-card.tsx"
provides: "the only interactive remediation surface (REVIEW-05, D-03..06)"
@@ -79,13 +80,14 @@ Analog to copy: components/rmm/rmm-dispatch-dialog.tsx (useSession + disabled-bu
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Action Area Spec — checkbox list, 7-row param table, "Approve selected" button rules)
- Create `components/phishing/action-area-card.tsx` (`'use client'`) exporting `ActionAreaCard(props)`. Wrap in a shadcn `` with `` + leading lucide `ShieldAlert` (`h-4 w-4 mr-2 inline`). Render one row per string in `classification.recommendedActions`: a shadcn `` with a humanized label (block_sender→"Block sender", purge_message→"Purge message", warn_user→"Warn user", no_action→"No action", reset_password→"Reset password", isolate_endpoint→"Isolate endpoint", disable_forwarding_rule→"Disable forwarding rule"), plus an ALWAYS-VISIBLE params form beneath it (not gated on the checkbox) whose fields are pre-filled via `deriveDefaultParams(actionType, evidence)` and are editable per the UI-SPEC 7-row table: no_action renders "No parameters — informational verdict, no remediation needed." text instead of a form; warn_user→recipientEmail Input + message Textarea; block_sender→senderEmail + senderDomain Inputs; purge_message→messageId Input + mailboxes comma-separated Input (helper "Enter affected mailboxes, comma-separated"); reset_password→userPrincipalName Input; isolate_endpoint→deviceId Input (helper "No device identifier available from evidence — enter manually"); disable_forwarding_rule→userPrincipalName + ruleName Inputs. Hold per-row checked state and per-row edited params in `useState`. Add the "Approve selected" button (primary variant) that submits — enablement/tooltip gating comes in task 2, but wire the submit handler now: it POSTs `/api/phishing/campaigns/${campaignId}/approve` with body `{ actions: checkedRows.map(r => ({ actionType: r.actionType, params: r.currentParams })) }` (exactly ApproveActionInput[]; for purge_message parse the mailboxes comma-separated string into a string[] before submit), then on 2xx `toast.success` with the returned count and calls `props.onActionComplete()` (D-04 refetch — never mutate local state optimistically); on error `toast.error('Approve failed: {message}')`. Copy the fetch-POST-toast try/catch structure from rmm-dispatch-dialog.tsx.
+ Create `components/phishing/action-area-card.tsx` (`'use client'`) exporting `ActionAreaCard(props)`. Wrap in a shadcn `` with `` + leading lucide `ShieldAlert` (`h-4 w-4 mr-2 inline`). NULL-GUARD FIRST: the `classification` prop is `Classification | null`; the grouped-but-not-yet-classified campaign (the DEFAULT state, since classification is never auto-triggered) passes `null` here. Immediately after the card header, if `classification == null` (or `classification.recommendedActions` is empty), render an informational `text-sm text-muted-foreground` note ("No classification yet — classify this campaign to see recommended remediation actions.") and return early WITHOUT rendering the checkbox list or any action buttons. Never dereference `classification.recommendedActions` before this guard. Plan 06 additionally avoids rendering ActionAreaCard at all in the unclassified state, so this guard is defense-in-depth. When `classification != null`, render one row per string in `classification.recommendedActions`: a shadcn `` with a humanized label (block_sender→"Block sender", purge_message→"Purge message", warn_user→"Warn user", no_action→"No action", reset_password→"Reset password", isolate_endpoint→"Isolate endpoint", disable_forwarding_rule→"Disable forwarding rule"), plus an ALWAYS-VISIBLE params form beneath it (not gated on the checkbox) whose fields are pre-filled via `deriveDefaultParams(actionType, evidence)` and are editable per the UI-SPEC 7-row table: no_action renders "No parameters — informational verdict, no remediation needed." text instead of a form; warn_user→recipientEmail Input + message Textarea; block_sender→senderEmail + senderDomain Inputs; purge_message→messageId Input + mailboxes comma-separated Input (helper "Enter affected mailboxes, comma-separated"); reset_password→userPrincipalName Input; isolate_endpoint→deviceId Input (helper "No device identifier available from evidence — enter manually"); disable_forwarding_rule→userPrincipalName + ruleName Inputs. Hold per-row checked state and per-row edited params in `useState`. Add the "Approve selected" button (primary variant) that submits — enablement/tooltip gating comes in task 2, but wire the submit handler now: it POSTs `/api/phishing/campaigns/${campaignId}/approve` with body `{ actions: checkedRows.map(r => ({ actionType: r.actionType, params: r.currentParams })) }` (exactly ApproveActionInput[]; for purge_message parse the mailboxes comma-separated string into a string[] before submit), then on 2xx `toast.success` with the returned count and calls `props.onActionComplete()` (D-04 refetch — never mutate local state optimistically); on error `toast.error('Approve failed: {message}')`. Copy the fetch-POST-toast try/catch structure from rmm-dispatch-dialog.tsx.
grep -q "deriveDefaultParams" components/phishing/action-area-card.tsx && grep -q "onActionComplete" components/phishing/action-area-card.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "action-area-card" | grep -qx 0 && echo OK
- `grep -q "deriveDefaultParams" components/phishing/action-area-card.tsx` succeeds
+ - A null-guard on `classification` precedes any `classification.recommendedActions` access (component returns an informational note, not a `TypeError`, when classification is null) — `grep -Eq "classification == null|!classification" components/phishing/action-area-card.tsx`
- `grep -q "/approve" components/phishing/action-area-card.tsx` succeeds and the POST body uses `actions:` with `actionType`/`params`
- `grep -q "onActionComplete" components/phishing/action-area-card.tsx` succeeds (refetch, not optimistic)
- `npx tsc --noEmit --pretty` reports no error in this file
diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-06-PLAN.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-06-PLAN.md
index ee4df7f..7fe034c 100644
--- a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-06-PLAN.md
+++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-06-PLAN.md
@@ -8,15 +8,17 @@ files_modified:
- app/phishing/tickets/[ticketId]/page.tsx
- app/phishing/page.tsx
- components/navigation/app-navigation.tsx
-autonomous: true
+autonomous: false
requirements: [REVIEW-01, REVIEW-05, REVIEW-06]
must_haves:
truths:
- "Visiting /phishing/tickets/{ticketId} resolves the ticket to its campaign and renders the review page using the existing Better Auth session — no token/query-param auth"
- - "The review page renders classification, action area, evidence, and timeline for a grouped campaign; a 'Not yet triaged' empty state with an Analyze CTA for D-07; a standalone-report notice + evidence for D-08; and an error+Retry for load failures"
- - "After any approve/remediate/mark-false-positive succeeds, the page refetches and re-renders from fresh server state"
+ - "The review page renders classification, action area, evidence, and timeline for a CLASSIFIED grouped campaign; a 'Not yet classified' empty state with a 'Classify this campaign' CTA for a grouped-but-unclassified campaign (the default post-grouping state); a 'Not yet triaged' empty state with an Analyze CTA for D-07; a standalone-report notice + evidence for D-08; and an error+Retry for load failures"
+ - "A grouped campaign with zero classifications (the default state right after detection/grouping, since classification is never auto-triggered) never crashes — ClassificationCard and ActionAreaCard are not rendered, a Classify CTA (POST /classify) is offered instead, and evidence + timeline still display"
+ - "After any approve/remediate/mark-false-positive/classify/analyze succeeds, the page refetches and re-renders from fresh server state"
- "Visiting /phishing lists recent campaigns and clicking a row navigates to /phishing/tickets/{firstReportTicketId}"
- "A 'Phishing' nav entry links to /phishing"
+ - "Phase completion is gated on a manual confirmation that a real Autotask LiveLink click resolves against the numeric-ticket-id assumption (or the documented ticket_number fallback is applied)"
artifacts:
- path: "app/phishing/tickets/[ticketId]/page.tsx"
provides: "the ticket-scoped LiveLink review page (REVIEW-01..06)"
@@ -32,6 +34,10 @@ must_haves:
to: "/api/phishing/tickets/{ticketId}/campaign then /api/phishing/campaigns/{id}"
via: "two-step fetch in load()"
pattern: "tickets/.+/campaign"
+ - from: "app/phishing/tickets/[ticketId]/page.tsx"
+ to: "/api/phishing/campaigns/{id}/classify"
+ via: "Classify CTA in the grouped-but-unclassified branch"
+ pattern: "campaigns/.+/classify"
- from: "app/phishing/page.tsx"
to: "/phishing/tickets/{firstReportTicketId}"
via: "router.push on row click"
@@ -39,10 +45,10 @@ must_haves:
---
-Assemble the two pages and the nav entry. The ticket-scoped review page (`/phishing/tickets/{ticketId}`) is the LiveLink target: it resolves ticket→campaign, drives a five-state machine (loading / not-triaged / ungrouped / ready / error), composes the plan-03/04/05 cards, and refetches after every action (D-04). The minimal campaigns list page (`/phishing`, D-00) gives the nav somewhere to point and lets operators browse into the review page. A flat "Phishing" nav item (D-02) makes it discoverable.
+Assemble the two pages and the nav entry. The ticket-scoped review page (`/phishing/tickets/{ticketId}`) is the LiveLink target: it resolves ticket→campaign, drives a state machine (loading / not-triaged / ungrouped / ready / error) where the `ready` render additionally branches on whether the grouped campaign has been classified yet, composes the plan-03/04/05 cards, and refetches after every action (D-04). The minimal campaigns list page (`/phishing`, D-00) gives the nav somewhere to point and lets operators browse into the review page. A flat "Phishing" nav item (D-02) makes it discoverable. A final blocking manual-verification checkpoint confirms the LiveLink numeric-ticket-id assumption against a real Autotask tenant (REVIEW-01, per 22-RESEARCH resolved Open Question).
Purpose: This is the integration layer — every other plan's output is composed here into the operator-facing surfaces.
-Output: 2 pages + 1 nav edit.
+Output: 2 pages + 1 nav edit + 1 manual-verification gate.
@@ -55,11 +61,20 @@ Output: 2 pages + 1 nav edit.
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md
-Routes consumed (plan 02):
+Routes consumed (plan 02 + existing):
GET /api/phishing/tickets/{ticketId}/campaign -> { found:boolean; reportId?; campaignId?:string|null; ticketNumber? }
- GET /api/phishing/campaigns/{id} -> extended detail (campaign fields + reports + messages(headers/urls/attachments/bodyPreview) + indicators + classifications + remediationActions(+completedAt) + auditEvents + blastRadius + timeline)
+ GET /api/phishing/campaigns/{id} -> extended detail (campaign fields + reports(created_at ASC) + messages(headers/urls/attachments/bodyPreview) + indicators + classifications(created_at DESC) + remediationActions(+completedAt) + auditEvents + blastRadius + timeline)
GET /api/phishing/campaigns?limit&offset -> { items:[{ id, campaignKey, status, reportCount, firstSeenAt, lastSeenAt, firstReportTicketId }], total, limit, offset }
- POST /api/phishing/tickets/{ticketId}/analyze (D-07 CTA)
+ POST /api/phishing/tickets/{ticketId}/analyze (D-07 CTA — detect+parse+group; does NOT classify)
+ POST /api/phishing/campaigns/{id}/classify (grouped-but-unclassified CTA + ClassificationCard Reclassify — the ONLY trigger of classifyCampaign; detection/grouping never calls it)
+
+Latest-classification derivation: the detail route returns `classifications` (plural, created_at DESC). The single latest classification the cards consume is `campaignDetail.classifications[0] ?? null`. A newly-grouped campaign has `classifications: []` → `null`.
+
+Card signatures (plans 03/04/05):
+ ClassificationCard({ campaignId, classification, onReclassified })
+ ActionAreaCard({ campaignId, classification, remediationActions, campaignStatus, campaignUpdatedAt, evidence:{requesterEmail,senderEmail,senderDomain,messageId}, onActionComplete })
+ EvidenceCard({ messages, blastRadius })
+ TimelineCard({ timeline })
Components consumed:
components/phishing/{classification-card,action-area-card,evidence-card,timeline-card}.tsx (plans 03/04/05)
@@ -77,25 +92,45 @@ Nav analog: components/navigation/app-navigation.tsx navigationItems — the PAX
app/phishing/tickets/[ticketId]/page.tsx
- app/analyzer/reports/[id]/page.tsx (use(params) + fetch + loading/error early-return + Card-stack layout idiom)
- - components/phishing/classification-card.tsx, action-area-card.tsx, evidence-card.tsx, timeline-card.tsx (plans 03/04/05 — the cards composed here)
+ - components/phishing/classification-card.tsx, action-area-card.tsx, evidence-card.tsx, timeline-card.tsx (plans 03/04/05 — the cards composed here + their exact prop signatures)
+ - lib/auth-client.ts (useSession) + lib/permissions.ts (hasPermission — for gating the Classify CTA on phishing:analyze, mirroring the Reclassify button)
- components/ui/empty-state.tsx, components/ui/alert.tsx, components/ui/skeleton-helpers.tsx, components/navigation/page-header.tsx
- - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Ticket-scoped review page layout order, Empty & Error States, Copywriting Contract)
+ - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Ticket-scoped review page layout order, Empty & Error States — INCLUDING the "Grouped, not yet classified" row, Copywriting Contract — INCLUDING the "Not yet classified" copy)
- Create `app/phishing/tickets/[ticketId]/page.tsx` as a `'use client'` page: `export default function TicketReviewPage({ params }: { params: Promise<{ ticketId: string }> }) { const { ticketId } = use(params); ... }`. Use the full chrome shell (D-01): `` inside `` (AppNavigation is supplied by the layout). Implement a state machine `'loading'|'not-triaged'|'ungrouped'|'ready'|'error'` with an async `load()`: fetch `/api/phishing/tickets/${ticketId}/campaign`; if `!found` → 'not-triaged'; if `campaignId == null` → 'ungrouped' (fetch that report's evidence for the standalone EvidenceCard); else fetch `/api/phishing/campaigns/${campaignId}` and → 'ready' (throw on non-2xx → 'error'). `useEffect(() => { void load(); }, [ticketId])`. Render per state: loading → `SkeletonHeader` + 3× `SkeletonCard`; not-triaged (D-07) → full-width `EmptyState` (icon SearchX, title "Not yet triaged", body + CTA "Analyze this ticket" per Copywriting Contract that POSTs `/api/phishing/tickets/${ticketId}/analyze` then re-runs load()); ungrouped (D-08) → info-tone `` with the D-08 copy followed by `` alone (no classification/action/timeline cards); error → destructive `` with the error copy + "Retry" button re-running load(); ready → in order: `` (full width), `` (full width, pass campaignId/classification/remediationActions/campaignStatus/campaignUpdatedAt and the derived evidence object {requesterEmail,senderEmail,senderDomain,messageId}), then `
`. Pass `load` as `onActionComplete`/`onReclassified` so every action refetches (D-04, no optimistic mutation). Derive the ActionAreaCard `evidence` fields from the campaign detail's primary message headers + primary report requesterEmail. Authentication is the existing Better Auth session only (the route is not in middleware publicRoutes) — do NOT add any token/query-param auth (REVIEW-01).
+ Create `app/phishing/tickets/[ticketId]/page.tsx` as a `'use client'` page: `export default function TicketReviewPage({ params }: { params: Promise<{ ticketId: string }> }) { const { ticketId } = use(params); ... }`. Use the full chrome shell (D-01): `` inside `` (AppNavigation is supplied by the layout). Implement a state machine `'loading'|'not-triaged'|'ungrouped'|'ready'|'error'` with an async `load()`: fetch `/api/phishing/tickets/${ticketId}/campaign`; if `!found` → 'not-triaged'; if `campaignId == null` → 'ungrouped' (fetch that report's evidence for the standalone EvidenceCard); else fetch `/api/phishing/campaigns/${campaignId}` and → 'ready' (throw on non-2xx → 'error'). `useEffect(() => { void load(); }, [ticketId])`.
+
+ Render per state:
+ - loading → `SkeletonHeader` + 3× `SkeletonCard`.
+ - not-triaged (D-07) → full-width `EmptyState` (icon SearchX, title "Not yet triaged", body + CTA "Analyze this ticket" per Copywriting Contract that POSTs `/api/phishing/tickets/${ticketId}/analyze` then re-runs load()).
+ - ungrouped (D-08) → info-tone `` with the D-08 copy followed by `` alone (no classification/action/timeline cards).
+ - error → destructive `` with the error copy + "Retry" button re-running load().
+ - ready → FIRST derive the single latest classification: `const classification = campaignDetail.classifications[0] ?? null;` (the detail route returns `classifications` DESC-ordered; a freshly-grouped campaign returns `[]` → null). BRANCH on `classification`:
+ (a) `classification == null` (grouped-but-not-yet-classified — the DEFAULT state, because detection/grouping never auto-triggers classification): render a full-width `` (icon Sparkles, title "Not yet classified", body per Copywriting Contract, CTA "Classify this campaign" that POSTs `/api/phishing/campaigns/${campaignId}/classify` then re-runs load()). Gate the CTA button on `hasPermission(role, 'phishing', 'analyze')` (role from `useSession()`, `(session?.user as {role?:string})?.role ?? 'user'`) — HIDDEN when absent, mirroring ClassificationCard's Reclassify gate; when hidden, still show the empty-state text so a read-only operator understands the state. Do NOT render `` or `` in this branch (both require a non-null classification). Then render the read-only detail below: `
` (evidence + timeline exist even without a classification).
+ (b) `classification != null`: render in order, enumerating props explicitly for ALL four cards:
+ `` (full width),
+ `` (full width),
+ then `
`.
+
+ Derive the ActionAreaCard `evidence` object from the PRIMARY (earliest) report + its message — use `campaignDetail.reports[0]` (same `reports[0]`/earliest-report definition as plan 22-02's server-side `blastRadius` derivation, which orders reports `created_at ASC`, so the client evidence object and the server blast-radius agree on which report is "primary") and its message headers: `{ requesterEmail: reports[0].requesterEmail, senderEmail: primaryMessage.headers.from.email, senderDomain: , messageId: primaryMessage.messageId }`.
+
+ Pass `load` as `onActionComplete`/`onReclassified` so every action (approve/remediate/mark-false-positive/reclassify/classify) refetches (D-04, no optimistic mutation). Authentication is the existing Better Auth session only (the route is not in middleware publicRoutes) — do NOT add any token/query-param auth (REVIEW-01).
- grep -q "tickets/\${ticketId}/campaign" app/phishing/tickets/[ticketId]/page.tsx && grep -q "onActionComplete={load}" app/phishing/tickets/[ticketId]/page.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "tickets/\[ticketId\]/page" | grep -qx 0 && echo OK
+ grep -q "tickets/\${ticketId}/campaign" app/phishing/tickets/[ticketId]/page.tsx && grep -q "classifications\[0\]" app/phishing/tickets/[ticketId]/page.tsx && grep -q "campaigns/\${campaignId}/classify" app/phishing/tickets/[ticketId]/page.tsx && grep -q "onActionComplete={load}" app/phishing/tickets/[ticketId]/page.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "tickets/\[ticketId\]/page" | grep -qx 0 && echo OK
- `grep -q "not-triaged" app/phishing/tickets/[ticketId]/page.tsx` and `grep -q "ungrouped" ...` both succeed (D-07/D-08 states present)
+ - `grep -q "classifications\[0\]" app/phishing/tickets/[ticketId]/page.tsx` succeeds (single latest classification derived as `classifications[0] ?? null`, Warning 3)
+ - `grep -q "Not yet classified" app/phishing/tickets/[ticketId]/page.tsx` and `grep -q "campaigns/\${campaignId}/classify" ...` both succeed (grouped-but-unclassified branch + Classify CTA — Blocker 1)
- `grep -q "/analyze" app/phishing/tickets/[ticketId]/page.tsx` succeeds (D-07 CTA)
- `grep -q "onActionComplete" app/phishing/tickets/[ticketId]/page.tsx` succeeds and points at the refetch function (D-04)
- `grep -c "searchParams\|token=" app/phishing/tickets/[ticketId]/page.tsx` returns 0 (no separate auth scheme — REVIEW-01)
- - All four cards (ClassificationCard/ActionAreaCard/EvidenceCard/TimelineCard) are imported and rendered
+ - ActionAreaCard is rendered ONLY in the `classification != null` branch (never for a null classification) — verified by the branch guard; and its props include `classification={classification}` (non-null in that branch)
+ - All four cards (ClassificationCard/ActionAreaCard/EvidenceCard/TimelineCard) are imported; each is rendered with an explicit prop list per the card signatures above (Warning 3)
- `npx tsc --noEmit --pretty` reports no error in this file
- Review page resolves ticket→campaign, renders all four states + ready layout, refetches after every action, Better-Auth-session-only.
+ Review page resolves ticket→campaign; renders loading/not-triaged/ungrouped/error plus a `ready` render that branches into grouped-but-unclassified (Classify CTA + evidence/timeline, no crash) vs. classified (all four cards with explicit props); refetches after every action; Better-Auth-session-only.
@@ -124,6 +159,31 @@ Nav analog: components/navigation/app-navigation.tsx navigationItems — the PAX
Campaigns list page browses recent campaigns and navigates to the ticket-scoped review page; Phishing nav entry present for all roles.
+
+ Task 3: LiveLink numeric-ticket-id manual verification (REVIEW-01 / 22-RESEARCH resolved Open Question)
+ none (live verification only — no file changes unless the ticket_number fallback is needed, which edits lib/services/phishing-ticket-resolver.ts)
+ The ticket-scoped review page `/phishing/tickets/{ticketId}` (Task 1) assumes the URL segment is the numeric Autotask ticket id (`tickets.id` / `reports.ticket_id`), parsed via `Number(ticket_id)` in the plan-22-02 resolver route — consistent with the already-shipped Phase 18 `/api/phishing/tickets/{ticket_id}/analyze` route. 22-RESEARCH's Open Question on this was RESOLVED as a documented decision, gated on this manual confirmation (no live Autotask LiveLink admin access was available during research/planning).
+
+ This is a blocking human-verification checkpoint — no code changes unless the fallback is triggered. The executor pauses here; a human confirms a real Autotask LiveLink click resolves against the numeric-ticket-id assumption, per how-to-verify. Do not auto-approve: the numeric-vs-ticket_number question could not be confirmed against a live Autotask tenant during planning, so a real click is the only way to close it. If confirmation fails (the segment is the human-readable ticket number), apply the documented one-line `ticket_number` fallback in `lib/services/phishing-ticket-resolver.ts` and re-verify.
+
+
+ 1. In Autotask, configure (or inspect an existing) LiveLink button targeting `${BETTER_AUTH_URL}/phishing/tickets/` followed by the ticket-id dynamic-content field — reuse the SAME dynamic field the Phase 18 `/analyze` LiveLink (if one exists) already uses.
+ 2. From a real Autotask ticket that has a grouped phishing campaign, click the LiveLink button.
+ 3. Confirm the browser lands on `/phishing/tickets/{numeric id}` and the page RESOLVES (renders the campaign/classification/empty-state) — it must NOT return a 400 "Invalid ticket_id" from the resolver route.
+ 4. If instead the URL contains the human-readable ticket number (e.g. `T20260101.0001`) and the page 400s: apply the documented one-line fallback in `lib/services/phishing-ticket-resolver.ts` — look up via `reports.ticket_number` (already a stored column) when the segment is non-numeric — then re-run steps 2-3.
+
+
+ A real Autotask LiveLink click on a ticket with a grouped campaign lands on `/phishing/tickets/{numeric id}` and the page resolves (renders campaign/classification/empty-state), not a 400 "Invalid ticket_id"; if it 400s on a ticket_number segment, the documented resolver fallback is applied and the click then resolves.
+
+
+ - A real LiveLink click resolves to a rendered review page (not a 400) — OR the ticket_number fallback in phishing-ticket-resolver.ts was applied and the click then resolves.
+ - The dynamic-content field used in the LiveLink button is recorded (matches or diverges from Phase 18 /analyze's field).
+ - If deferred, the owner responsible for confirming before operator exposure is recorded.
+
+ A real Autotask LiveLink click resolves to the review page under the numeric-ticket-id assumption (or the documented ticket_number fallback was applied and now resolves); human typed "verified" / "verified — fallback applied" / "deferred: {owner}".
+ Type "verified" once a real LiveLink click resolves correctly (or "verified — fallback applied" if the ticket_number fallback was needed and now works). If no Autotask tenant/LiveLink button is available yet, type "deferred: {owner}" to record who will confirm before the button is exposed to operators — the numeric-id assumption ships as-is per the RESOLVED research decision until then.
+
+
@@ -139,19 +199,21 @@ Nav analog: components/navigation/app-navigation.tsx navigationItems — the PAX
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-22-16 | Spoofing / Elevation (unauthenticated LiveLink access) | both pages | mitigate | REVIEW-01 — existing Better Auth session only; /phishing absent from middleware publicRoutes so an unauth request redirects to sign-in; no token/query-param auth added |
-| T-22-17 | Elevation of Privilege (action buttons for unauthorized role) | review page | mitigate | Action gating is delegated to ActionAreaCard's `hasPermission()` (plan 05); the page adds no relaxed check and the server routes enforce independently |
+| T-22-17 | Elevation of Privilege (action buttons for unauthorized role) | review page | mitigate | Action gating is delegated to ActionAreaCard's `hasPermission()` (plan 05); the Classify CTA is likewise gated on `phishing:analyze`; the page adds no relaxed check and the server routes enforce independently |
| T-22-18 | Information Disclosure (rendering unsanitized evidence) | review page | mitigate | Evidence rendering is delegated to EvidenceCard (plan 03 — inert URLs, plain-text body); the page passes data through, never via dangerouslySetInnerHTML |
+| T-22-19 | Denial of Service / broken-link (LiveLink id semantics mismatch) | resolver + review page | mitigate | Numeric-id assumption documented (RESOLVED research decision) and gated by the blocking manual-verification checkpoint; a one-line `ticket_number` fallback is pre-specified if confirmation fails |
| T-22-SC | Tampering | npm/pip/cargo installs | accept | No package-manager installs in this plan (22-RESEARCH — zero new packages) |
- `npx tsc --noEmit --pretty` clean
- `npm test` full suite green
-- Manual click-through (per 22-VALIDATION Manual-Only): review page in all four states (full-campaign, D-07 not-yet-triaged, D-08 ungrouped, load-error); approve→remediate→resolved flow; user-role sees disabled/hidden actions; blast-radius unavailable state with Mimecast unset; list page row-click navigates correctly
+- Manual click-through (per 22-VALIDATION Manual-Only): review page in all states (classified full-campaign, grouped-but-not-yet-classified with Classify CTA, D-07 not-yet-triaged, D-08 ungrouped, load-error); approve→remediate→resolved flow; user-role sees disabled/hidden actions; blast-radius unavailable state with Mimecast unset; list page row-click navigates correctly
+- Blocking manual-verification checkpoint (Task 3): a real Autotask LiveLink click resolves against the numeric-ticket-id assumption (or the ticket_number fallback is applied) — REVIEW-01 / 22-RESEARCH resolved Open Question
-Both operator surfaces work end-to-end: the LiveLink review page (all states, actions with refetch, session-only auth) and the discoverable campaigns list page with a working nav entry.
+Both operator surfaces work end-to-end: the LiveLink review page (all states including the default grouped-but-unclassified Classify CTA, actions with refetch, session-only auth) and the discoverable campaigns list page with a working nav entry; the LiveLink ticket-id addressing is confirmed (or its documented fallback applied) via the blocking checkpoint.