---
phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve
verified: 2026-07-17T06:40:00Z
status: human_needed
score: 6/6 must-haves verified (code-level); 5 items require human click-through before final close
overrides_applied: 0
human_verification:
  - test: "Click through /phishing/tickets/{ticketId} for a fully-classified campaign, a D-07 not-yet-triaged ticket, a D-08 ungrouped report, and an invalid/missing ticket ID"
    expected: "Correct state renders for each (ready/not-triaged/ungrouped/error) with no crash"
    why_human: "No app/**/components/** test infra (vitest.config.ts scopes to lib/** only); this is a rendered-page/visual check"
  - test: "Approve one action, confirm the page refetches and re-renders updated state; repeat for remediate and mark-false-positive; confirm buttons disable post-resolution (D-05) with a tooltip"
    expected: "Each action calls its API, toasts success, refetches campaign detail, and buttons become disabled with an explanatory tooltip once resolved"
    why_human: "Requires live DB state transitions and visual tooltip/DOM inspection across a multi-step flow"
  - test: "Sign in as a user-role account (no phishing:approve/remediate) and confirm approve/remediate/mark-false-positive buttons are disabled with tooltip, and a direct API call still 403s"
    expected: "Buttons disabled (not hidden per D-05), tooltip explains why, and the underlying API independently enforces the same permission"
    why_human: "Requires a second test-role session; client-side gating logic and server-side gating were confirmed identical by code inspection (hasPermission vs requirePermission), but the end-to-end browser behavior itself needs a human pass"
  - test: "With MIMECAST_* env unset/disabled, load a campaign's review page and confirm an explicit unavailable state renders (not blank/error)"
    expected: "Evidence card's Blast Radius tab shows the unavailable copy (\"Blast radius unavailable — Mimecast isn't configured for this environment.\")"
    why_human: "Requires toggling integration config in a running environment; code path confirmed to exist and be wired (getBlastRadius returns status:'unavailable', reason:'not_configured' when isMimecastConfigured() is false and no tenant client was injected), but live-environment behavior needs confirmation"
  - test: "Inspect rendered evidence section in a live browser — confirm no <a href> wraps any indicator URL, and the copy-to-clipboard affordance actually works"
    expected: "URLs render as inert monospace text with a working copy button, never a clickable link"
    why_human: "Source-level grep confirms no anchor tags or dangerouslySetInnerHTML exist in url-list.tsx/evidence-card.tsx, but actual clipboard behavior in a real browser is a runtime check"
---

# Phase 22: Approval UI (LiveLink) Verification Report

**Phase Goal:** A ticket-ID-addressable Pulse page (Autotask LiveLink target) showing campaign
timeline, evidence, and classification, with approve/remediate/mark-false-positive wired to the
Phase 20 approval APIs.

**Verified:** 2026-07-17
**Status:** human_needed
**Re-verification:** No — this is the first-ever verification pass for this phase (no prior
VERIFICATION.md existed; the phase reached 6/6 plans "complete" without ever going through
`/gsd:verify-work`).

## Goal Achievement

### Observable Truths

| # | Truth (REVIEW-01..06) | Status | Evidence |
|---|------------------------|--------|----------|
| 1 | REVIEW-01: A stable, ticket-ID-addressable Pulse route resolves the ticket to its campaign and renders the review page, suitable as a LiveLink target | VERIFIED | `app/phishing/tickets/[ticketId]/page.tsx` reads `ticketId` from the URL, calls `GET /api/phishing/tickets/{ticket_id}/campaign` (`app/api/phishing/tickets/[ticket_id]/campaign/route.ts` → `lib/services/phishing-ticket-resolver.ts`), then `GET /api/phishing/campaigns/{id}`. Route is NOT in `middleware.ts`'s public list (`grep -n "phishing" middleware.ts` → no match), so it is gated by the existing Better Auth session only — no separate token/query-param scheme. `phishing-ticket-resolver.test.ts` (4/4 passing) covers all 3 resolution states. |
| 2 | REVIEW-02: The page displays the campaign's timeline — linked reports, classification history, and audit events — in chronological order | VERIFIED | `app/api/phishing/campaigns/[id]/route.ts` fetches `reports`, `classifications`, `audit_events` and calls `mergeTimeline()` (`lib/services/phishing-timeline.ts`); `phishing-timeline.test.ts` (4/4 passing) asserts ascending chronological order + all 3 source kinds. `components/phishing/timeline-card.tsx` renders every `TimelineEntry` variant (report/classification/audit) with relative+absolute timestamps. |
| 3 | REVIEW-03: The page displays gathered evidence — parsed EML headers/URLs/attachments, sanitized body preview, and Mimecast blast-radius data (incl. explicit `unavailable` state) — never a raw/unsanitized body or unredacted secrets | VERIFIED (code-level; see human items) | `components/phishing/evidence-card.tsx` renders Headers/URLs/Attachments/Body-preview/Blast-Radius tabs. Body preview is rendered as JSX text inside `<pre>` only (React auto-escapes; no `dangerouslySetInnerHTML` anywhere in `components/phishing/` or `app/phishing/` — confirmed via grep). The underlying `bodyPreview` value is built by `buildBodyPreview()` in `lib/services/eml-parser.ts`, which strips `<script>/<style>` blocks and all HTML tags to plain text before truncation — never raw HTML. URLs are delegated to `components/phishing/url-list.tsx`, which contains no `<a>`/anchor and no `dangerouslySetInnerHTML` — copy-to-clipboard only (D-09). Attachments render filename/content-type/size/hash metadata only, no content fetch. Blast radius: `lib/services/mimecast-blast-radius.ts`'s `getBlastRadius()` returns `{status:'unavailable', reason:'not_configured'}` when `isMimecastConfigured()` is false and no per-tenant client was injected, and `{status:'unavailable', reason:'lookup_failed', error}` on any thrown error — never propagates a raw exception. `evidence-card.tsx`'s Blast Radius tab explicitly branches on `blastRadius.status === 'unavailable'` and renders `unavailableCopy()` instead of the stats table. This is the requirement that was unchecked in REQUIREMENTS.md; code inspection confirms it IS implemented correctly. Flagged for human click-through only because there is no `components/**` test coverage (vitest.config.ts scopes to `lib/**`) and a live-browser DOM/clipboard check is prudent before permanently closing the requirement. |
| 4 | REVIEW-04: The page displays the current classification (SPAM/UNWANTED/THREAT/USER_AWARENESS), confidence, reasons, and recommended remediation action(s) | VERIFIED | `components/phishing/classification-card.tsx` renders `verdict` (badge), `confidence` (rounded %), `summary`, `reasons` (bulleted list), and `recommendedActions` (badges, humanized labels). Extended `GET /api/phishing/campaigns/[id]` route returns `reasons`/`recommended_actions`/`requires_approval` from the `classifications` table (previously omitted per 22-CONTEXT.md's Claude's Discretion note — now included). |
| 5 | REVIEW-05: An operator can approve, remediate, or mark a campaign false-positive directly from the page, calling the existing approve/remediate/mark-false-positive endpoints and reflecting resulting state | VERIFIED | `components/phishing/action-area-card.tsx`'s `handleApprove()` POSTs `{actions: ApproveActionInput[]}` to `/approve` (checkbox-selected actions only, matching D-03 exactly); `handleRemediateConfirm()`/`handleMarkFalsePositiveConfirm()` POST to `/remediate` and `/mark-false-positive` respectively, each behind an `AlertDialog` confirmation. All three call `onActionComplete()` → `load()` on success (full refetch, no optimistic mutation — D-04, matches CLAUDE.md's no-SWR/react-query convention). Once resolved (`false_positive` status or a `completed` remediation action), all three buttons stay rendered but disabled with a tooltip (D-05) via the `GatedButton` component — confirmed in source, not removed from DOM. |
| 6 | REVIEW-06: An operator without elevated permission sees approve/remediate disabled/hidden rather than a failed request; page enforces no separate/relaxed permission model | VERIFIED | Client: `action-area-card.tsx` calls `hasPermission(role, 'phishing', 'approve')` / `hasPermission(role, 'phishing', 'remediate')` from `lib/permissions.ts` — the identical function (not a bespoke role-string check). Server: `app/api/phishing/campaigns/[id]/approve/route.ts` → `requirePermission('phishing','approve')`; `.../remediate/route.ts` → `requirePermission('phishing','remediate')`; `.../mark-false-positive/route.ts` → `requirePermission('phishing','approve')`. Client and server gate on the exact same resource/action pairs — grep-confirmed 1:1 match, no drift. |

**Score:** 6/6 truths VERIFIED at the code level. Status is `human_needed` (not `passed`) because 5 behaviors — all listed in `22-VALIDATION.md`'s own "Manual-Only Verifications" table — require a live browser/session pass that this verifier cannot execute (no `app/**`/`components/**` automated test coverage in this codebase's vitest config). None of the 6 REVIEW requirements are FAILED; this is a completeness gate, not a defect finding.

### Required Artifacts

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `app/phishing/tickets/[ticketId]/page.tsx` | LiveLink target page, 5-state machine | VERIFIED | Exists, substantive (339 lines), wired to resolver + detail + analyze + classify endpoints |
| `app/phishing/page.tsx` | D-00 campaigns list page | VERIFIED | Exists, uses `DataTable`, reuses `GET /api/phishing/campaigns`, row-click navigates to ticket-scoped page |
| `components/phishing/evidence-card.tsx` | Tabbed evidence display (REVIEW-03) | VERIFIED | Headers/URLs/Attachments/Body/Blast-Radius tabs, all data paths sanitized/inert |
| `components/phishing/url-list.tsx` | Inert URL rendering (D-09) | VERIFIED | No anchor tags, copy-only |
| `components/phishing/classification-card.tsx` | Classification display + reclassify (REVIEW-04) | VERIFIED | Verdict/confidence/summary/reasons/actions rendered; reclassify gated on `analyze` permission |
| `components/phishing/action-area-card.tsx` | Approve/remediate/mark-false-positive UI (REVIEW-05/06) | VERIFIED | Checkbox-per-action approve flow, confirmation dialogs, D-04 refetch, D-05 disabled-not-hidden, D-06 identical permission check |
| `components/phishing/timeline-card.tsx` | Chronological timeline (REVIEW-02) | VERIFIED | Renders report/classification/audit entries with relative+absolute timestamps |
| `app/api/phishing/campaigns/[id]/route.ts` | Extended campaign detail (all evidence/timeline/classification fields) | VERIFIED | Bulk-fetches reports/messages/indicators/classifications/remediation/audit, fresh per-tenant blast-radius lookup, merged timeline |
| `app/api/phishing/tickets/[ticket_id]/campaign/route.ts` | Ticket→campaign resolver (REVIEW-01) | VERIFIED | Thin wrapper around `resolveTicketToCampaign()`, returns `{found}` at 200 (never 404) |
| `app/api/phishing/reports/[report_id]/route.ts` | D-08 standalone-report evidence endpoint | VERIFIED | Mirrors campaign-detail bulk-fetch idiom, scoped to a single report |
| `app/api/phishing/campaigns/route.ts` | List endpoint, extended with `first_report_ticket_id` for D-00 | VERIFIED | Confirmed subquery + camelCase transform present |
| `lib/services/mimecast-blast-radius.ts` | `unavailable` state (REVIEW-03) | VERIFIED (pre-existing from Phase 17, consumed correctly here) | `getBlastRadius()` returns `not_configured`/`lookup_failed` reasons, never throws |
| `lib/services/phishing-ticket-resolver.ts` + `.test.ts` | Pure resolution logic | VERIFIED | 4/4 tests passing |
| `lib/services/remediation-default-params.ts` + `.test.ts` | Default param derivation | VERIFIED | 9/9 tests passing (part of the 18 total) |
| `lib/services/phishing-timeline.ts` + `.test.ts` | Server-merge timeline | VERIFIED | 4/4 tests passing |
| `components/navigation/app-navigation.tsx` nav entry | D-02 discoverability | VERIFIED | "Phishing" entry present, links to `/phishing` |

### Key Link Verification

| From | To | Via | Status | Details |
|------|----|----|--------|---------|
| `[ticketId]/page.tsx` | `/api/phishing/tickets/{id}/campaign` | `fetch()` in `load()` | WIRED | Response drives `not-triaged`/`ungrouped`/`ready` branching |
| `[ticketId]/page.tsx` | `/api/phishing/campaigns/{id}` | `fetch()` in `load()` | WIRED | Populates `campaignDetail`, feeds all 4 cards |
| `action-area-card.tsx` | `/api/phishing/campaigns/{id}/approve` | `fetch POST` in `handleApprove()` | WIRED | Body matches `ApproveActionInput[]` exactly; response handled, refetch triggered |
| `action-area-card.tsx` | `/api/phishing/campaigns/{id}/remediate` | `fetch POST` in `handleRemediateConfirm()` | WIRED | Refetch triggered on success |
| `action-area-card.tsx` | `/api/phishing/campaigns/{id}/mark-false-positive` | `fetch POST` in `handleMarkFalsePositiveConfirm()` | WIRED | Refetch triggered on success |
| `action-area-card.tsx` / `classification-card.tsx` (client) | `lib/auth-utils.ts` `requirePermission()` (server, in each action route) | `hasPermission(role,'phishing',action)` mirrors `requirePermission('phishing',action)` | WIRED, no drift | Grep-confirmed identical resource/action pairs on both sides |
| `evidence-card.tsx` | `lib/services/mimecast-blast-radius.ts` (via campaign-detail route) | `blastRadius` prop, `status==='unavailable'` branch | WIRED | Explicit unavailable copy rendered, no silent blank/crash |
| `phishing/page.tsx` (list) | `[ticketId]/page.tsx` | `router.push` on row click, using `firstReportTicketId` | WIRED | Confirmed field present in `GET /api/phishing/campaigns` response |

### Data-Flow Trace (Level 4)

| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|---------------------|--------|
| `evidence-card.tsx` | `messages` / `blastRadius` props | `campaignDetail.messages` / `campaignDetail.blastRadius` from `GET /api/phishing/campaigns/[id]` | DB query (`messages`, `indicators` tables) + live `getBlastRadius()` call (fan-out to Mimecast client or `not_configured`) | FLOWING |
| `timeline-card.tsx` | `timeline` prop | `mergeTimeline(reports, classifications, auditEvents)` — all 3 from real DB queries | Yes | FLOWING |
| `classification-card.tsx` | `classification` prop | `campaignDetail.classifications[0]` — real DB row, `null` when none exists yet (not faked/hardcoded) | Yes | FLOWING |
| `action-area-card.tsx` | `remediationActions` prop | `campaignDetail.remediationActions` — real DB rows with derived `completedAt` from `audit_events` join | Yes | FLOWING |

### Behavioral Spot-Checks

| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| TypeScript compiles cleanly across the whole repo (incl. all phase-22 files) | `npx tsc --noEmit --pretty` | No output, exit 0 | PASS |
| Phase-22 pure-logic unit tests pass | `npx vitest run lib/services/phishing-ticket-resolver.test.ts lib/services/remediation-default-params.test.ts lib/services/phishing-timeline.test.ts` | 3 files, 18/18 tests passed | PASS |
| Full test suite has no phase-22-caused regressions | `npx vitest run` | 439/441 passed; 2 failures in `lib/services/analyzer/itglue-search.test.ts`, pre-existing and unrelated to any phase-22 file (documented in `deferred-items.md`, confirmed present before phase-22 changes, itglue-search.ts untouched by any phase-22 commit) | PASS (no regression) |
| `/phishing` route requires auth (not a public LiveLink bypass) | `grep -n "phishing" middleware.ts` | No match — route not in `publicRoutes` list | PASS |
| No unsafe HTML injection anywhere in phase-22 UI | `grep -rn "dangerouslySetInnerHTML\|<a \|<a>" components/phishing/ app/phishing/` | No matches | PASS |
| Client/server permission checks identical (no relaxed model) | `grep -n "requirePermission" app/api/phishing/campaigns/[id]/{approve,remediate,mark-false-positive}/route.ts` | `approve`→`phishing/approve`, `remediate`→`phishing/remediate`, `mark-false-positive`→`phishing/approve` — matches client `hasPermission()` calls exactly | PASS |

### Probe Execution

No `scripts/*/tests/probe-*.sh` files exist for this phase and none are declared in any PLAN/SUMMARY. Step 7c: SKIPPED (no probes declared or discovered).

### Requirements Coverage

| Requirement | Source Plan | Description | Status | Evidence |
|--------------|-------------|-------------|--------|----------|
| REVIEW-01 | 22-01, 22-02, 22-06 | Ticket-ID-addressable route resolving to campaign, LiveLink-suitable | SATISFIED | `[ticketId]/page.tsx` + resolver route + `phishing-ticket-resolver.test.ts` |
| REVIEW-02 | 22-01, 22-02, 22-04 | Chronological timeline of reports/classifications/audit events | SATISFIED | `mergeTimeline()` + `TimelineCard` |
| REVIEW-03 | 22-02, 22-03 | Evidence display: headers/URLs/attachments/sanitized body/blast-radius incl. `unavailable` | SATISFIED (code-level) — was incorrectly left "Pending" in REQUIREMENTS.md traceability table | `EvidenceCard` + `UrlList` + `mimecast-blast-radius.ts`; see human_verification for live-browser confirmation |
| REVIEW-04 | 22-01, 22-02, 22-04 | Classification display: verdict/confidence/reasons/recommended actions | SATISFIED | `ClassificationCard` + extended detail route |
| REVIEW-05 | 22-05, 22-06 | Approve/remediate/mark-false-positive wired to Phase 20 APIs, reflects state | SATISFIED | `ActionAreaCard` handlers + refetch (D-04) |
| REVIEW-06 | 22-01, 22-05 | No relaxed/separate permission model | SATISFIED | Identical `hasPermission`/`requirePermission` resource-action pairs |

No orphaned requirements found for Phase 22 in `.planning/REQUIREMENTS.md`'s traceability table.

**Correction to REQUIREMENTS.md:** REVIEW-03 is checked `[ ]` / listed "Pending" in the traceability table, but code-level inspection finds it fully implemented and consistent with the other 5 (which are marked "Complete"). This appears to be a bookkeeping oversight from the phase never having gone through a formal `/gsd:verify-work` pass, rather than a real implementation gap. Recommend updating REQUIREMENTS.md to `[x]` / "Complete" once the human-verification items below are confirmed.

### Anti-Patterns Found

None. Scanned all phase-22 touched files (`app/phishing/**`, `components/phishing/**`, `app/api/phishing/**`, and the 3 new `lib/services/*.ts` files) for `TBD|FIXME|XXX|TODO|HACK|PLACEHOLDER|placeholder|coming soon|not yet implemented`. Only match was a legitimate shadcn `<SelectValue placeholder="Select a report" />` prop in `evidence-card.tsx` — a UI placeholder-text prop, not a stub marker.

### Human Verification Required

The following are carried forward from `22-VALIDATION.md`'s own "Manual-Only Verifications" table (this codebase has no `app/**`/`components/**` test infrastructure — `vitest.config.ts`'s `test.include` is `lib/**/*.test.ts` only per CLAUDE.md's stated safety net). Code-level inspection strongly supports all 5 passing, but a live browser/session pass has never been recorded for this phase.

### 1. Full state-machine click-through

**Test:** Visit `/phishing/tickets/{ticketId}` for a fully-classified campaign, a D-07 not-yet-triaged ticket, a D-08 ungrouped report, and an invalid/missing ticket ID.
**Expected:** Correct state renders for each (ready / not-triaged / ungrouped / error) with no crash.
**Why human:** No component-level test coverage; this is a rendered-page visual/flow check.

### 2. Approve/remediate/mark-false-positive end-to-end

**Test:** Approve one action, confirm the page refetches and shows updated state; repeat for remediate and mark-false-positive; confirm buttons disable post-resolution with a tooltip.
**Expected:** Each action calls its API, toasts success, refetches, and buttons become disabled-with-tooltip once resolved (D-05).
**Why human:** Requires live DB state transitions across a multi-step flow; source code confirms the wiring but not the rendered/runtime result.

### 3. Non-privileged role gating

**Test:** Sign in as a `user`-role account (no `phishing:approve`/`remediate`) and confirm the three action buttons are disabled with a tooltip, and a direct API call still 403s.
**Expected:** Buttons disabled (not hidden), tooltip explains why; underlying API independently enforces the same permission.
**Why human:** Requires a second test-role session. Code inspection already confirms `hasPermission()` (client) and `requirePermission()` (server) check identical resource/action pairs.

### 4. Mimecast `unavailable` state in a real environment

**Test:** With `MIMECAST_*` env unset/disabled, load a campaign's review page.
**Expected:** Blast Radius tab shows the explicit unavailable copy, not a blank panel or error.
**Why human:** Requires toggling integration config in a running environment; the code path is confirmed to exist (`getBlastRadius()` returns `status:'unavailable'` cleanly).

### 5. URL inertness + clipboard behavior

**Test:** Inspect the rendered evidence section in a live browser; confirm no extracted URL is a clickable link, and the copy button actually copies.
**Expected:** URLs render as inert monospace text with a working copy-to-clipboard affordance.
**Why human:** Source-level grep confirms no `<a>`/`dangerouslySetInnerHTML` exists, but actual clipboard API behavior is a runtime check.

### Gaps Summary

No code-level gaps found. All 6 REVIEW-01..06 requirements have real, wired, non-stub implementations verified directly against source (not SUMMARY.md claims): the extended `GET /api/phishing/campaigns/[id]` route genuinely joins reports/messages/indicators/classifications/remediation_actions/audit_events and performs a fresh per-tenant Mimecast lookup; the review page's 5-state machine (loading/not-triaged/ungrouped/ready/error) is implemented and each card composes real data; approve/remediate/mark-false-positive call the exact Phase 20 endpoints with matching payload shapes and refetch afterward; permission gating is provably identical client/server; URL/body/attachment evidence rendering follows the stricter-than-sanitization D-09 posture with no anchor tags or raw-HTML injection anywhere in the touched files.

The only reason this report does not resolve to a clean `passed` is procedural: this phase was never run through `/gsd:verify-work` before, so the 5 manual-only verification items documented in the phase's own `22-VALIDATION.md` (written by the planner/executor, not by this verifier) have never actually been executed by a human against a running instance. These are process gaps (missing human sign-off), not code gaps — nothing here suggests any of them will fail. REQUIREMENTS.md's REVIEW-03 "Pending" checkbox appears to be a stale artifact of the same missing-verification-pass issue rather than a genuine implementation gap; recommend correcting it once the human-verification pass above is completed.

---

*Verified: 2026-07-17T06:40:00Z*
*Verifier: Claude (gsd-verifier)*
