# Phase 20: Remediation, Approval & Audit Safety - Pattern Map

**Mapped:** 2026-07-16
**Files analyzed:** 5 (3 new routes, 1 permissions edit, 1 new service — plus test files)
**Analogs found:** 5 / 5

## File Classification

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `app/api/phishing/campaigns/[id]/approve/route.ts` | route/controller | request-response (CRUD write, multi-row insert) | `app/api/phishing/campaigns/[id]/classify/route.ts` | exact (named precedent in CONTEXT.md) |
| `app/api/phishing/campaigns/[id]/remediate/route.ts` | route/controller | request-response (state transition, idempotent) | `app/api/phishing/campaigns/[id]/classify/route.ts` | exact (same shape) + `campaign-grouping-service.ts` transaction pattern for the state-transition body |
| `app/api/phishing/campaigns/[id]/mark-false-positive/route.ts` | route/controller | request-response (guarded state write) | `app/api/phishing/campaigns/[id]/classify/route.ts` | exact (same shape) + `app/api/admin/integrations/route.ts` PATCH for the "guard before write" + actor-capture pattern |
| `lib/permissions.ts` (edit: add `"approve"`, `"remediate"` to `superAdminRole` and `adminRole`) | config | CRUD (static role literal) | `lib/permissions.ts` itself — edit target, not a new file | exact (in-place edit, no analog needed) |
| `lib/services/remediation-service.ts` (new — service layer for approve/remediate/mark-false-positive + audit writes) | service | CRUD + event-driven (audit trail) | `lib/services/campaign-classifier.ts` (`classifyCampaign` orchestrator + append-only insert) and `lib/services/campaign-grouping-service.ts` (`postgresClient.transaction()` multi-statement write) | exact (classifier) / role-match (grouping service, for transaction shape) |
| `lib/services/remediation-service.test.ts` (new) | test | — | `lib/services/campaign-classifier.test.ts` | exact (mocking discipline: `vi.mock('./postgres-client', ...)` before import) |

## Pattern Assignments

### `app/api/phishing/campaigns/[id]/approve/route.ts` (route, request-response)

**Analog:** `app/api/phishing/campaigns/[id]/classify/route.ts` (full file, 51 lines — read in one pass)

**Imports pattern** (lines 12-15 of analog):
```typescript
import { NextRequest, NextResponse } from 'next/server';
import { requirePermission } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
import { classifyCampaign } from '@/lib/services/campaign-classifier';
```
For `approve/route.ts`, swap the last import for the new service, e.g.
`import { approveRemediationActions } from '@/lib/services/remediation-service';`

**UUID guard pattern** (lines 17, 26-31):
```typescript
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
...
const { id } = await params;
// V5: validate UUID shape before querying — a malformed id would otherwise
// surface as an unhandled Postgres error -> uncaught 500.
if (!UUID_RE.test(id)) {
  return NextResponse.json({ error: 'Invalid campaign id' }, { status: 400 });
}
```

**Auth/permission pattern** (lines 23-24) — use `'approve'` action per D-02:
```typescript
const { error } = await requirePermission('phishing', 'analyze');
if (error) return error;
```
becomes, for this route:
```typescript
const { session, error } = await requirePermission('phishing', 'approve');
if (error) return error;
```
Note: `classify/route.ts` discards `session` (only needs `error`); `approve`/`mark-false-positive` need `session` too, to capture the actor for `approved_by`/`audit_events.actor` (see Shared Patterns > Actor Capture below) — destructure both.

**Campaign-exists check** (lines 34-40, adapt to your service call instead of inline query if the service itself validates existence):
```typescript
const campaignRes = await postgresClient.query<{ id: string }>(
  `SELECT id FROM campaigns WHERE id = $1`,
  [id]
);
if (!campaignRes.rows[0]) {
  return NextResponse.json({ error: 'Campaign not found' }, { status: 404 });
}
```

**Request body parsing** — no existing phishing route parses a JSON body; copy the shape from `app/api/admin/integrations/route.ts` PATCH (lines 62-67):
```typescript
let body: { key?: unknown; disabled?: unknown; reason?: unknown };
try {
  body = (await request.json()) as typeof body;
} catch {
  return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
```
For `approve/route.ts`, the body shape is `{ actions: Array<{ actionType: string; params?: Record<string, unknown> }> }` per D-03 (operator specifies which recommended actions to approve, with optional param overrides).

**Delegation + error handling pattern** (lines 33-50, full try/catch):
```typescript
try {
  ...
  const result = await classifyCampaign(id);
  return NextResponse.json(result);
} catch (err) {
  console.error('[PHISHING-CLASSIFY] Failed to classify campaign', id, err);
  return NextResponse.json(
    { error: 'Failed to classify campaign', message: err instanceof Error ? err.message : 'Unknown error' },
    { status: 500 }
  );
}
```
Copy verbatim, renaming the log tag to `[PHISHING-APPROVE]` and delegating to the new service's `approveRemediationActions(campaignId, actions, actor)`.

---

### `app/api/phishing/campaigns/[id]/remediate/route.ts` (route, request-response, idempotent)

**Analog:** same as above (`classify/route.ts`) for the route shell; **`lib/services/campaign-grouping-service.ts`** (lines 175-220, `postgresClient.transaction()` usage) for the idempotent-transition body inside the service it delegates to.

**Idempotency-relevant excerpt** (`campaign-grouping-service.ts` lines 160-173, the "check-then-short-circuit" idiom to mirror for D-01's "calling remediate twice produces exactly one `completed` transition"):
```typescript
export async function groupReportIntoCampaign(
  reportId: string,
  opts?: { skipIfAlreadyGrouped?: boolean }
): Promise<GroupReportResult | null> {
  try {
    if (opts?.skipIfAlreadyGrouped) {
      const existing = await postgresClient.query<{ campaign_id: string | null }>(
        `SELECT campaign_id::text AS campaign_id FROM reports WHERE id = $1`,
        [reportId]
      );
      if (existing.rows[0]?.campaign_id) {
        return null; // already grouped, short-circuit (D-08)
      }
    }
    return await postgresClient.transaction(async (client) => {
      ...
    });
  } catch (error) { ... }
}
```
Apply the same "check current `status` column, short-circuit before the write" idiom in the remediate service function: read `remediation_actions.status` for the targeted row(s); if already `'completed'`, return the same success shape without re-inserting an `audit_events` row.

Route shell: identical UUID guard + `requirePermission('phishing', 'remediate')` + try/catch/500 as `approve/route.ts` above — delegate to `remediateApprovedActions(campaignId, actor)`.

---

### `app/api/phishing/campaigns/[id]/mark-false-positive/route.ts` (route, request-response, guarded state write)

**Analog:** `classify/route.ts` for the shell; `app/api/admin/integrations/route.ts` PATCH (lines 58-93) for the "guard condition before mutating, capture actor from session" pattern.

**Actor capture pattern** (line 80):
```typescript
const actor = (session?.user as { email?: string } | undefined)?.email ?? null;
```
This is the concrete precedent for D-02's "Claude's Discretion: actor value format — likely session email, matching the `disabled_by` convention" note in CONTEXT.md. Use this exact cast/fallback shape for `audit_events.actor` and `remediation_actions.approved_by`.

**Guard-before-write pattern** — no existing phishing route has a state guard; the shape to copy is "query current state, branch on a conflicting condition, return 409/400 before mutating" — synthesize from the general `try/catch` + explicit-status-check idiom used throughout (e.g. `campaigns/[id]/route.ts` lines 79-82, `if (!campaign) return NextResponse.json({ error: ... }, { status: 404 })`). For D-04's guard:
```typescript
const blockingRes = await postgresClient.query<{ id: string }>(
  `SELECT id FROM remediation_actions WHERE campaign_id = $1 AND status IN ('approved', 'completed') LIMIT 1`,
  [id]
);
if (blockingRes.rows[0]) {
  return NextResponse.json(
    { error: 'Cannot mark false positive: campaign already has approved or completed remediation' },
    { status: 409 }
  );
}
```
Route shell otherwise identical: UUID guard + `requirePermission('phishing', 'approve')` (D-04 says same elevated tier as approve — no separate `'mark-false-positive'` action was added to the statement) + try/catch/500.

---

### `lib/permissions.ts` (config edit — no new file)

**Edit target:** lines 51 and 65 (this file, in place — not a new file/analog pair).

Current state (lines 41-52, `superAdminRole`):
```typescript
export const superAdminRole = ac.newRole({
  ...
  phishing: ["read", "analyze"], // approve/remediate ungranted until Phase 20
});
```
Current state (lines 55-66, `adminRole`):
```typescript
export const adminRole = ac.newRole({
  ...
  phishing: ["read", "analyze"],
});
```
D-02 change: both become `phishing: ["read", "analyze", "approve", "remediate"]`. The `statement` object (line 34) already has the full vocabulary — no change needed there. `userRole` (line 79, `phishing: ["read"]`) is untouched.

---

### `lib/services/remediation-service.ts` (service, CRUD + event-driven audit trail)

**Analog:** `lib/services/campaign-classifier.ts` (full orchestrator shape — `classifyCampaign`, lines 449-524) for the "single exported orchestrator wrapping try/catch, delegating to helper functions, ending in an append-only INSERT + RETURNING" shape; `lib/services/campaign-grouping-service.ts` (lines 175-220) for the `postgresClient.transaction()` multi-statement write shape needed when approve/remediate write both a `remediation_actions` row AND an `audit_events` row atomically.

**Imports pattern** (campaign-classifier.ts lines 17-19):
```typescript
import type { AuthResults } from './eml-parser';
import { postgresClient } from './postgres-client';
import { getBlastRadius, type BlastRadiusResult } from './mimecast-blast-radius';
```
For `remediation-service.ts`:
```typescript
import { postgresClient } from './postgres-client';
```
(No external client dependency — D-01 confirms no real provider call this phase.)

**Orchestrator + append-only insert pattern** (campaign-classifier.ts lines 449-524, condensed):
```typescript
export async function classifyCampaign(campaignId: string): Promise<ClassifyResult> {
  try {
    const evidence = await gatherCampaignEvidence(campaignId);
    ...
    // D-02: append-only INSERT — no ON CONFLICT. Each classify call is a new
    // history row; "current" verdict is the most recent by created_at.
    const insertResult = await postgresClient.query<{ id: string; created_at: string }>(
      `INSERT INTO classifications (
         campaign_id, verdict, confidence, summary, reasons, recommended_actions, requires_approval
       ) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7)
       RETURNING id::text AS id, created_at::text AS created_at`,
      [campaignId, verdict, confidence, summary, JSON.stringify(reasons), JSON.stringify(recommendedActions), requiresApproval]
    );
    const row = insertResult.rows[0];
    return { id: row.id, campaignId, verdict, ... };
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    console.error('[CAMPAIGN-CLASSIFIER] Failed to classify campaign', campaignId, message);
    throw error;
  }
}
```
Apply the identical shape for `approveRemediationActions(campaignId, actions, actor)`: read the latest `classifications` row (mirrors `app/api/phishing/campaigns/[id]/route.ts` lines 120-124's `ORDER BY created_at DESC` query) to validate the requested action types are in `recommendedActions`, then INSERT one `remediation_actions` row per approved action (`status: 'approved'`, `approved_by: actor`, `approved_at: NOW()`, `params` from the request body) inside a transaction that also writes one `audit_events` row (`event_type: 'remediation_approved'`).

**Transaction pattern** (campaign-grouping-service.ts lines 175-176, 210-220 style — read-then-write inside one `client`):
```typescript
return await postgresClient.transaction(async (client) => {
  const ownReportRes = await client.query<OwnReportRow>(
    `SELECT ... FROM reports WHERE id = $1`,
    [reportId]
  );
  const ownReport = ownReportRes.rows[0];
  if (!ownReport) {
    throw new Error(`groupReportIntoCampaign: report ${reportId} not found`);
  }
  ... // further client.query() calls using the SAME `client`, not postgresClient
});
```
Use this exact "all queries go through the callback's `client` param, not the top-level `postgresClient`" discipline for the remediate/approve/mark-false-positive functions so the `remediation_actions` INSERT and the `audit_events` INSERT are atomic.

**Idempotency check (D-01/REMED-04)** — status-check short-circuit, mirroring `groupReportIntoCampaign`'s `opts?.skipIfAlreadyGrouped` early-return (lines 165-173):
```typescript
if (existing.rows[0]?.campaign_id) {
  return null; // already grouped, short-circuit (D-08)
}
```
For `remediateApprovedActions`: query the `remediation_actions` row's `status` first; if already `'completed'`, return the same result shape without a second `audit_events` INSERT or a second `status` transition.

**Error handling pattern** (campaign-classifier.ts lines 519-523):
```typescript
} catch (error) {
  const message = error instanceof Error ? error.message : String(error);
  console.error('[CAMPAIGN-CLASSIFIER] Failed to classify campaign', campaignId, message);
  throw error;
}
```
Rethrow after logging — the route layer's try/catch converts to the 500 JSON response (per `classify/route.ts`'s pattern), so the service should NOT itself return `NextResponse` — keep the layering the same as Phase 19.

---

### `lib/services/remediation-service.test.ts` (test)

**Analog:** `lib/services/campaign-classifier.test.ts` (lines 1-17, mock setup)

```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';

// Mock postgresClient BEFORE importing the module under test — mirrors
// campaign-grouping-service.test.ts's vi.mock() factory-mocking discipline.
const queryMock = vi.fn();
vi.mock('./postgres-client', () => ({
  postgresClient: {
    query: (...args: unknown[]) => queryMock(...args),
  },
}));
```
If `remediation-service.ts` uses `postgresClient.transaction()`, the mock factory must also export a `transaction` mock (check `campaign-grouping-service.test.ts` for the transaction-mock variant — not read in this pass, but the same file is the analog for that specific mock shape; grep `transaction` in that test file before writing this one).

## Shared Patterns

### Auth / Permission Gate
**Source:** `app/api/phishing/campaigns/[id]/classify/route.ts` lines 23-24; `lib/auth-utils.ts` lines 51-74
**Apply to:** All three new routes (`approve` → action `'approve'`, `remediate` → action `'remediate'`, `mark-false-positive` → action `'approve'` per D-04)
```typescript
const { session, error } = await requirePermission('phishing', 'approve');
if (error) return error;
```
`requirePermission` internally calls `requireAuth()` (401 if no session) then `hasPermission(userRole, resource, action)` (403 if role lacks it) — no route-level code needed beyond this one call + early return.

### UUID Path-Param Guard
**Source:** `app/api/phishing/campaigns/[id]/classify/route.ts` line 17
**Apply to:** All three new routes (same `[id]` campaign param)
```typescript
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_RE.test(id)) {
  return NextResponse.json({ error: 'Invalid campaign id' }, { status: 400 });
}
```

### Actor Capture (for `approved_by` / `audit_events.actor`)
**Source:** `app/api/admin/integrations/route.ts` line 80
**Apply to:** `remediation-service.ts` functions and/or the route layer before delegating
```typescript
const actor = (session?.user as { email?: string } | undefined)?.email ?? null;
```

### Error Handling / Response Shape
**Source:** `app/api/phishing/campaigns/[id]/classify/route.ts` lines 33, 44-49
**Apply to:** All three new routes
```typescript
try {
  ...
} catch (err) {
  console.error('[PHISHING-<ACTION>] Failed to <verb> campaign', id, err);
  return NextResponse.json(
    { error: 'Failed to <verb> campaign', message: err instanceof Error ? err.message : 'Unknown error' },
    { status: 500 }
  );
}
```

### Transactional Multi-Row Write
**Source:** `lib/services/campaign-grouping-service.ts` lines 175 onward
**Apply to:** `remediation-service.ts` — every function that writes both a `remediation_actions` row and an `audit_events` row must do so inside one `postgresClient.transaction(async (client) => { ... })`, using `client.query()` for every statement in that block (never the top-level `postgresClient.query()` inside the callback).

### Append-Only History Insert (no `ON CONFLICT`)
**Source:** `lib/services/campaign-classifier.ts` lines 489-505 (`classifications` INSERT); confirmed as the established shape for `remediation_actions`/`audit_events` per CONTEXT.md `<code_context>` ("Established Patterns" section: "Append-only history tables with `campaign_id` FK + `created_at`... `remediation_actions` and `audit_events` should follow the same insert-only shape, no upsert.")

## No Analog Found

None — all 5 files/edits have a strong analog. The only genuinely new pattern is the D-04 "guard-before-write" conflict check on `mark-false-positive`, for which no existing phishing route has a direct precedent; it is synthesized from the general "query state, branch, 4xx before mutating" idiom used for 404s elsewhere (`app/api/phishing/campaigns/[id]/route.ts` line 81) plus the actor-capture precedent from `app/api/admin/integrations/route.ts`.

## Metadata

**Analog search scope:** `app/api/phishing/**`, `app/api/admin/integrations/route.ts`, `lib/services/campaign-classifier.ts`, `lib/services/campaign-grouping-service.ts`, `lib/services/campaign-classifier.test.ts`, `lib/permissions.ts`, `lib/auth-utils.ts`, `migrations/097_phishing_triage_schema.sql`
**Files scanned:** 9
**Pattern extraction date:** 2026-07-16
