}>;
+}
+// Response (200): ApprovedRemediationAction[]
+interface ApprovedRemediationAction {
+ id: string;
+ campaignId: string;
+ actionType: string;
+ status: 'approved';
+ approvedBy: string | null;
+}
+// Errors: 400 (bad UUID / empty actions array / action not in recommended_actions),
+// 404 (campaign not found), 409 (RemediationConflictError — not used by
+// approve currently, but the route catches it defensively)
+
+// POST /api/phishing/campaigns/{id}/remediate
+// Body: none
+// Response (200):
+interface RemediateResult {
+ campaignId: string;
+ actions: Array<{
+ id: string;
+ actionType: string;
+ status: 'completed';
+ alreadyCompleted: boolean; // true when idempotent re-run found it already completed
+ }>;
+}
+// Errors: 400 (bad UUID / zero remediation_actions rows exist at all), 404 (campaign not found)
+
+// POST /api/phishing/campaigns/{id}/mark-false-positive
+// Body (optional): { reason?: string }
+// Response (200):
+interface MarkFalsePositiveResult {
+ campaignId: string;
+ status: 'false_positive';
+ auditEventId: string;
+}
+// Errors: 400 (bad UUID), 404 (campaign not found),
+// 409 (approved/completed remediation already exists — D-04 guard)
+```
+
+### Confirmed `messages` row shape (exact persisted JSONB structure)
+```typescript
+// Source: lib/services/phishing-eml-service.ts (read directly, the actual INSERT)
+// messages.headers JSONB:
+interface MessageHeaders {
+ from: { displayName: string | null; email: string | null; domain: string | null };
+ replyTo: string | null;
+ returnPath: string | null;
+ to: string[];
+ cc: string[];
+ subject: string | null;
+ date: string | null;
+ messageId: string | null;
+ receivedChain: string[];
+ authResults: { spf?: string; dkim?: string; dmarc?: string };
+ authResultsOriginal: { spf?: string; dkim?: string; dmarc?: string } | null;
+}
+// messages.urls JSONB: string[]
+// messages.attachments JSONB: Array<{ filename: string | null; contentType: string | null; size: number; checksum: string | null; related: boolean }>
+// messages.body_preview: TEXT (plain text, max 500 chars, EVID-04 truncation already applied)
+```
+
+### Confirmed recommended-action vocabulary (all 7 types, exhaustive)
+```typescript
+// Source: lib/services/campaign-classifier.ts mapVerdictToActions() (read directly)
+// SPAM -> ['no_action']
+// UNWANTED -> ['warn_user']
+// THREAT (base) -> ['block_sender', 'purge_message']
+// THREAT (evidence.clicked > 0) -> adds ['reset_password', 'isolate_endpoint', 'disable_forwarding_rule']
+// DESTRUCTIVE_ACTIONS (always requires_approval:true): block_sender, purge_message, reset_password, isolate_endpoint
+// NOT in DESTRUCTIVE_ACTIONS: no_action, warn_user, disable_forwarding_rule
+```
+This confirms the UI-SPEC's 7-row default-params table (Action Area Spec) is exhaustive and matches the real vocabulary exactly — no 8th action type exists to plan for.
+
+## State of the Art
+
+Not applicable in the traditional sense (no external library version churn to track) — the "state of the art" here is Phase 20/21's own precedent, which this phase should match:
+
+| Old Approach (would be wrong for this phase) | Current Approach (this phase should follow) | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| Optimistic local state mutation after approve/remediate | Refetch campaign detail from server (D-04) | Locked by CONTEXT.md this session | Guarantees the page always reflects server truth, no client/server drift possible |
+| A second campaign-ID-scoped page for list-row navigation | Single ticket-scoped review page, list row navigates via `firstReportTicketId` | Locked by CONTEXT.md/UI-SPEC this session | Exactly one review-page implementation to maintain |
+
+**Deprecated/outdated:** N/A — no prior version of this UI exists to deprecate.
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | Autotask LiveLink supplies the numeric AT ticket ID (matching `tickets.id`/`reports.ticket_id`) as the dynamic URL segment, not the human-readable `ticket_number` | Architecture Patterns, Pattern 1 | If LiveLink actually supplies `ticket_number` (e.g. "T20260101.0001"), the resolver route's `Number(ticket_id)` parse would fail on every request. This is inferred by consistency with the existing `/api/phishing/tickets/{ticket_id}/analyze` route (Phase 18, already shipped and presumably already validated against real Autotask LiveLink behavior in production) rather than independently verified against live Autotask LiveLink configuration docs in this session — no Autotask LiveLink admin-console access was available to confirm directly. |
+| A2 | Server-merging the timeline (reports + classifications + audit_events into one sorted array) inside the extended API route is preferable to a client-side merge | Architecture Patterns, Alternatives Considered | Low risk — CONTEXT.md explicitly leaves this to planner's discretion either way; recommendation is a preference, not a hard requirement. If planner chooses client-side merge instead, no correctness issue, just a different code-location tradeoff. |
+
+**If this table is empty:** N/A — see above, two low-to-moderate-risk assumptions logged.
+
+## Open Questions
+
+1. **Does Autotask LiveLink actually pass the numeric ticket ID, or the ticket number?**
+ - What we know: The existing (already-shipped, Phase 18) `/api/phishing/tickets/{ticket_id}/analyze` route treats its `ticket_id` path param as the numeric `tickets.id`/AT entity ID, via `Number(ticket_id)`. ROADMAP.md and CONTEXT.md both describe LiveLink as supplying "the ticket ID" without specifying numeric-ID vs. ticket-number.
+ - What's unclear: No direct access to the Autotask LiveLink admin configuration UI was available this session to confirm which field a configured LiveLink button would interpolate into a target URL.
+ - Recommendation: Follow the existing `analyze` route's precedent (numeric AT ID) for consistency — if it turns out wrong in a later integration/manual test, the fix is a one-line change (parse `ticket_number` string and look up via `reports.ticket_number` instead of `reports.ticket_id`, since `reports.ticket_number` is already a stored column). Flag this as a manual verification item for whoever configures the actual LiveLink button in Autotask (likely outside this phase's automated test surface entirely, since it requires a real Autotask tenant).
+
+## Environment Availability
+
+Skip — this phase has no new external dependencies. All existing integrations (Postgres, Mimecast client, Autotask client) are already configured and used by prior phases; this phase adds no new env vars, no new services, no new CLI tools.
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | vitest 4.1.5 |
+| Config file | `/opt/stacks/pulse/vitest.config.ts` |
+| Quick run command | `npm test -- lib/services/campaign-classifier.test.ts` (or the specific new test file) |
+| Full suite command | `npm test` |
+
+**Confirmed constraint:** `vitest.config.ts`'s `test.include` is `['lib/**/*.test.ts']` only — it does NOT include `app/**` or `components/**`. This means React page/component code and Next.js API route handlers in this phase have **no automated test coverage under the current config** — consistent with CLAUDE.md's "Other parts of the codebase have no tests... type-check is the only safety net." Any genuinely new *pure logic* this phase introduces (see below) should be extracted into a `lib/services/*.ts` file specifically so it CAN be unit-tested; UI composition and route wiring fall back to `npx tsc --noEmit --pretty` as the safety net, matching existing project convention.
+
+### Phase Requirements → Test Map
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| REVIEW-01 | Ticket→campaign resolver returns correct `{found, reportId, campaignId}` shape for: no report, ungrouped report, grouped campaign | unit (if resolver query logic is extracted to a testable `lib/services/*.ts` helper) | `npx vitest run lib/services/ticket-campaign-resolver.test.ts` | ❌ Wave 0 — recommend extracting the SQL lookup into a small `lib/services/phishing-ticket-resolver.ts` function specifically so this is unit-testable against a test DB/mock, rather than leaving the logic inline in the route handler (which vitest.config.ts's include pattern cannot reach) |
+| REVIEW-04 | Default-param derivation table (7 action types -> client-side default params) | unit | `npx vitest run lib/services/remediation-default-params.test.ts` (if extracted) | ❌ Wave 0 — recommend extracting UI-SPEC's Action Area default-param derivation logic (Input/Textarea prefill values) into a pure, testable function rather than inline component logic, since this is the one genuinely new piece of business logic in this phase |
+| REVIEW-02 | Timeline merge/sort (reports + classifications + audit_events, chronological) | unit (if server-merged) | `npx vitest run lib/services/phishing-timeline.test.ts` (if extracted) | ❌ Wave 0 — only applicable if planner chooses the server-merge approach (Architecture Alternatives Considered); if client-merged instead, this becomes untested UI logic same as the rest of the page |
+| REVIEW-03, REVIEW-05, REVIEW-06 | Page rendering, action button gating/disabling, evidence display | manual-only | — (justification: no component/page test infra exists in this codebase; `vitest.config.ts` does not include `app/**`/`components/**`) | — |
+
+### Sampling Rate
+- **Per task commit:** `npx tsc --noEmit --pretty` (type-check, matches CLAUDE.md's stated safety net for untested code) + `npm test -- ` if one exists for that task
+- **Per wave merge:** `npm test` (full suite) + `npx tsc --noEmit --pretty`
+- **Phase gate:** Full suite green + type-check green before `/gsd:verify-work`; manual click-through of both new pages (list page + ticket-scoped review page in all four states: full-campaign, D-07 not-yet-triaged, D-08 ungrouped, load-error) since REVIEW-02/03/05/06 have no automated coverage
+
+### Wave 0 Gaps
+- [ ] Decide whether to extract ticket→campaign resolution SQL into a testable `lib/services/phishing-ticket-resolver.ts` (recommended) vs. leaving it inline in the route handler (untestable under current vitest config)
+- [ ] Decide whether to extract the 7-action-type default-param-derivation table (UI-SPEC's Action Area Spec) into a pure `lib/services/remediation-default-params.ts` function (recommended, since this is the phase's one piece of genuinely new business logic worth unit-testing) vs. inline component logic
+- [ ] If server-merging the timeline: extract that merge/sort into a testable pure function rather than inline in the route handler
+- [ ] No framework install needed — vitest already configured and passing for the rest of the codebase
+
+## Security Domain
+
+### Applicable ASVS Categories
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | yes (indirect) | Existing Better Auth session cookie, enforced by `middleware.ts` — no new auth surface introduced by this phase (REVIEW-01 explicitly forbids a separate token/query-param scheme) |
+| V3 Session Management | yes (indirect) | Unchanged — reuses existing Better Auth session; no new session state introduced |
+| V4 Access Control | yes | `requirePermission('phishing', 'read'|'analyze'|'approve'|'remediate')` server-side (existing, unchanged) + client-side `hasPermission()` mirror for UX only (REVIEW-06) — server remains sole enforcement point, confirmed no route in this phase's plan bypasses `requirePermission` |
+| V5 Input Validation | yes | UUID-shape regex validation (`UUID_RE`) already the established idiom for every existing `[id]` route — the new ticket→campaign resolver route must validate `ticket_id` as `Number.isFinite()` matching the existing `analyze/route.ts` idiom exactly |
+| V6 Cryptography | no | Not applicable — no new crypto/hashing/secret handling in this phase |
+
+### Known Threat Patterns for this stack
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| Reflected/stored XSS via rendered email content (attacker-controlled `.eml` body/headers) | Tampering / Information Disclosure | Never `dangerouslySetInnerHTML` on `body_preview` (always plain `` text); never render extracted URLs as clickable `` (D-09 — inert copy-only text, this phase's explicit stricter posture); React's default JSX text-escaping already protects header field rendering as long as no field is passed through `dangerouslySetInnerHTML` anywhere |
+| IDOR — an operator without `phishing:approve`/`remediate` calling the write routes directly (bypassing the UI) | Elevation of Privilege | Already fully mitigated server-side by the existing `requirePermission('phishing', 'approve'|'remediate')` gates in all three write routes (confirmed by direct code read) — this phase's client-side gating (REVIEW-06) is a UX improvement only, not a new security boundary; the security boundary already exists and is unchanged |
+| Campaign-UUID enumeration via the extended detail route | Information Disclosure | Already mitigated — `requirePermission('phishing', 'read')` gates the entire route; a UUID guess without `phishing:read` permission still 401/403s before any query runs |
+| CSRF on the three write routes (approve/remediate/mark-false-positive) | Tampering | Out of scope for this phase — same-origin `fetch()` calls from a Better-Auth-session-cookie'd page, matching every other write route in this codebase's existing (unaudited-by-this-phase) CSRF posture; no change introduced or required here |
+
+## Sources
+
+### Primary (HIGH confidence — direct code reads this session)
+- `/opt/stacks/pulse/app/api/phishing/campaigns/[id]/route.ts` — current GET shape, bulk-fetch pattern, no-other-consumer confirmation basis
+- `/opt/stacks/pulse/app/api/phishing/campaigns/route.ts` — list endpoint, limit/offset/total shape
+- `/opt/stacks/pulse/app/api/phishing/campaigns/[id]/approve/route.ts`, `remediate/route.ts`, `mark-false-positive/route.ts`, `classify/route.ts`, `triage-note/route.ts` — full request/response/error shapes
+- `/opt/stacks/pulse/app/api/phishing/tickets/[ticket_id]/analyze/route.ts` — confirms `ticket_id` param = `tickets.id` numeric AT ID
+- `/opt/stacks/pulse/lib/services/remediation-service.ts` — full approve/remediate/mark-false-positive orchestration logic, confirms no `completed_at` column and the `remediation_completed` audit payload shape
+- `/opt/stacks/pulse/lib/services/mimecast-blast-radius.ts` — `getBlastRadius()` full signature, confirms `BlastRadiusResult` is never persisted by this module (D-03 "ephemeral" doc-comment)
+- `/opt/stacks/pulse/lib/services/triage-note-service.ts` — Phase 21 precedent, confirms the empty-string sender/recipient bug (Pitfall 3)
+- `/opt/stacks/pulse/lib/services/campaign-classifier.ts` — confirms `reasons`/`recommended_actions` never carry structured blast-radius data, confirms exhaustive 7-action vocabulary, confirms correct sender/recipient derivation pattern to copy instead
+- `/opt/stacks/pulse/lib/services/phishing-audit.ts` — confirms exhaustive `event_type` vocabulary (4 values) and `writeAuditEvent` shape
+- `/opt/stacks/pulse/lib/services/phishing-eml-service.ts` — confirms exact `messages.headers`/`urls`/`attachments`/`body_preview` persisted shapes
+- `/opt/stacks/pulse/migrations/097_phishing_triage_schema.sql`, `098_phishing_sweep_schedule.sql`, `099_indicators_metadata.sql` — full schema, confirms no `completed_at` column on `remediation_actions`, confirms no FK from `classifications`/`remediation_actions`/`audit_events` to `campaigns`
+- `/opt/stacks/pulse/migrations/001_initial_schema.sql` — confirms `tickets.id BIGINT PRIMARY KEY` + `ticket_number VARCHAR` distinction, confirms `contacts.email_address` column
+- `/opt/stacks/pulse/lib/permissions.ts` — confirms `hasPermission()` signature, isomorphic import safety, and the full role/permission matrix (`phishing: ["read","analyze","approve","remediate"]` for admin/super-admin, `["read"]` only for `user`)
+- `/opt/stacks/pulse/lib/auth-client.ts` — confirms `useSession()` export shape
+- `/opt/stacks/pulse/lib/auth-utils.ts` — confirms `requireAuth()`/`requirePermission()` server-side shapes
+- `/opt/stacks/pulse/components/rmm/rmm-dispatch-dialog.tsx`, `components/navigation/app-navigation.tsx` — confirms the actual (non-`hasPermission`) client-side role-check precedent in this codebase today
+- `/opt/stacks/pulse/components/admin/DataTable.tsx` — confirms `Column`/`DataTableProps` shape for the campaigns list page
+- `/opt/stacks/pulse/components/ui/status-badge.tsx`, `empty-state.tsx`, `skeleton-helpers.tsx` — confirms exact prop shapes for reused primitives
+- `/opt/stacks/pulse/middleware.ts` — confirms `/phishing` is absent from `publicRoutes`, so default auth-redirect behavior applies
+- `/opt/stacks/pulse/vitest.config.ts`, `package.json` — confirms test include pattern (`lib/**/*.test.ts` only) and `npm test` script
+- `/opt/stacks/pulse/.planning/config.json` — confirms `nyquist_validation: true`, `ui_phase: true`, `research: false` (orchestrator-level; this file itself is the research output regardless)
+- Repo-wide `grep -rl "hasPermission"` and `grep -rl "useSession"` across `app/` and `components/` — confirms zero existing client-side `hasPermission()` call sites (Pitfall 4)
+
+### Secondary (MEDIUM confidence)
+- None — every claim in this research was verified against the actual repo code, not external documentation or web search, since this phase involves zero new external libraries/services.
+
+### Tertiary (LOW confidence)
+- Assumption A1 (Autotask LiveLink's dynamic-ID semantics) — see Assumptions Log and Open Questions; based on internal consistency with an already-shipped route, not independently confirmed against Autotask's LiveLink configuration UI.
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — zero new dependencies, one new shadcn primitive from the already-configured official registry
+- Architecture: HIGH — every route/table/service this phase touches was read in full this session; the three CONTEXT.md-deferred decisions all have evidence-backed answers, not guesses
+- Pitfalls: HIGH — all five pitfalls are drawn from direct code reads (missing column, empty-string bug in a real precedent file, absent client-side pattern), not speculation
+
+**Research date:** 2026-07-16
+**Valid until:** 30 days (stable — no external library churn risk; codebase-internal findings remain valid until Phase 20/21 services are modified, which is out of this phase's scope)