---
phase: 20-remediation-approval-audit-safety
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - lib/services/phishing-audit.ts
  - lib/services/phishing-audit.test.ts
  - lib/services/remediation-service.ts
  - lib/services/remediation-service.test.ts
autonomous: true
requirements: [REMED-01, REMED-02, REMED-03, REMED-04, REMED-05, REMED-06]

must_haves:
  truths:
    - "Recommended actions exist only as proposals (in the classification's recommended_actions) until an operator explicitly approves them — no service function auto-creates an approved/completed remediation_actions row"
    - "approveRemediationActions only materializes action types that appear in the campaign's latest classification recommended_actions, storing the operator-supplied/overridden params per action (D-03); anything else is rejected"
    - "remediateApprovedActions applies D-01's simulated internal effect — no real external provider call for any action type — transitioning approved rows to completed, and is idempotent: a second call transitions nothing and writes no second audit row"
    - "remediateApprovedActions on a campaign with no approved actions raises an explicit failure, never a silent success"
    - "markCampaignFalsePositive is blocked when any approved/completed remediation exists; otherwise it sets campaigns.status='false_positive'"
    - "Every state-changing service call writes exactly one audit_events row per state change, atomically with that change"
  artifacts:
    - path: "lib/services/phishing-audit.ts"
      provides: "writeAuditEvent(input, client?) — single audit_events insert path"
      exports: ["writeAuditEvent"]
      min_lines: 20
    - path: "lib/services/remediation-service.ts"
      provides: "approveRemediationActions / remediateApprovedActions / markCampaignFalsePositive + typed error classes"
      exports: ["approveRemediationActions", "remediateApprovedActions", "markCampaignFalsePositive", "RemediationValidationError", "RemediationConflictError"]
      min_lines: 100
    - path: "lib/services/remediation-service.test.ts"
      provides: "idempotency (REMED-04), audit (REMED-06), D-04 guard, recommended-only approval proofs"
      min_lines: 80
  key_links:
    - from: "lib/services/remediation-service.ts"
      to: "audit_events"
      via: "writeAuditEvent(client) inside postgresClient.transaction"
      pattern: "writeAuditEvent"
    - from: "lib/services/remediation-service.ts"
      to: "classifications.recommended_actions"
      via: "latest-classification read validates approvable action types"
      pattern: "recommended_actions"
    - from: "lib/services/remediation-service.ts"
      to: "remediation_actions.status"
      via: "status='approved' FOR UPDATE filter drives idempotent completion"
      pattern: "FOR UPDATE"
---

<objective>
Build the Phase 20 service layer: a single audit-event writer and a remediation
service exposing approve / remediate / mark-false-positive orchestrators. All
state changes are proposed-only until approved (REMED-01), gated by the caller
(routes in Plan 02), idempotent on re-run (REMED-04), and every state change is
audited (REMED-06).

Purpose: This is the crux of the phase — the correctness properties
(idempotency, audit-atomicity, D-04 conflict guard, recommended-only approval)
live here and are proven by unit tests, independent of the HTTP layer.
Output: `lib/services/phishing-audit.ts`, `lib/services/remediation-service.ts`,
and their vitest suites.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/20-remediation-approval-audit-safety/20-CONTEXT.md
@.planning/phases/20-remediation-approval-audit-safety/20-PATTERNS.md

<interfaces>
<!-- Contracts the executor must build against. Do not re-derive from the codebase. -->

remediation_actions table (migration 097): columns
`id UUID`, `campaign_id UUID`, `action_type TEXT`, `status TEXT DEFAULT 'proposed'`,
`params JSONB`, `approved_by TEXT`, `approved_at TIMESTAMPTZ`, `created_at TIMESTAMPTZ`.

audit_events table (migration 097): columns
`id UUID`, `campaign_id UUID`, `actor TEXT`, `event_type TEXT NOT NULL`,
`payload JSONB`, `created_at TIMESTAMPTZ`.

campaigns table (migration 097): `status TEXT NOT NULL DEFAULT 'open'` — the
mark-false-positive target column.

classifications table (migration 097): `recommended_actions JSONB`,
`requires_approval BOOLEAN`, `created_at` — the current verdict is the most
recent row `ORDER BY created_at DESC`.

Action vocabulary (Phase 19 D-08, lib/services/campaign-classifier.ts):
```typescript
// The 7 action types; DESTRUCTIVE_ACTIONS force requires_approval:true.
export const DESTRUCTIVE_ACTIONS = new Set([
  'block_sender', 'purge_message', 'reset_password', 'isolate_endpoint',
]);
// mapVerdictToActions returns e.g. ['no_action'] | ['warn_user'] |
// ['block_sender','purge_message', ...]
export interface ClassifyResult {
  recommendedActions: string[];   // <- what approve validates against
  requiresApproval: boolean;
}
```

postgresClient transaction contract (lib/services/postgres-client.ts):
```typescript
// import { postgresClient } from './postgres-client';
transaction<T>(callback: (client: PoolClient) => Promise<T>): Promise<T>;
// Inside the callback, EVERY query must use `client.query(...)`, never the
// top-level postgresClient.query — so the remediation_actions write and the
// audit_events write commit/rollback atomically.
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Audit-event writer (phishing-audit.ts)</name>
  <files>lib/services/phishing-audit.ts, lib/services/phishing-audit.test.ts</files>
  <read_first>
    - lib/services/campaign-classifier.ts (append-only INSERT + RETURNING id::text pattern, lines 489-505; `import { postgresClient } from './postgres-client'`)
    - lib/services/campaign-grouping-service.test.ts (vi.mock('./postgres-client') factory-mock discipline, lines 1-13)
    - migrations/097_phishing_triage_schema.sql (audit_events columns, lines 150-157)
  </read_first>
  <action>
    Create `lib/services/phishing-audit.ts` exporting `writeAuditEvent`.
    Import `{ postgresClient }` from `'./postgres-client'`. Export
    `interface AuditEventInput { campaignId: string; actor: string | null;
    eventType: string; payload: Record<string, unknown>; }` and a minimal
    `interface AuditQueryClient { query: (text: string, params?: unknown[]) =>
    Promise<{ rows: Array<{ id: string }> }> }`.
    Signature: `writeAuditEvent(input: AuditEventInput, client?: AuditQueryClient):
    Promise<string>` — when `client` is passed (a transaction client) use it,
    otherwise fall back to `postgresClient`. Body performs a single parameterized
    INSERT into `audit_events (campaign_id, actor, event_type, payload)` with
    `VALUES ($1, $2, $3, $4::jsonb)` binding `[input.campaignId, input.actor,
    input.eventType, JSON.stringify(input.payload)]`, `RETURNING id::text AS id`,
    and returns `rows[0].id`. No ON CONFLICT (append-only). Document the four
    canonical event_type strings this phase emits in a header comment:
    `'campaign_classified'`, `'remediation_approved'`, `'remediation_completed'`,
    `'campaign_marked_false_positive'`.
    Create `phishing-audit.test.ts`: `vi.mock('./postgres-client', ...)` before
    import (mirror campaign-grouping-service.test.ts). Assert (a) calling
    writeAuditEvent with no client issues one parameterized INSERT into
    audit_events binding actor/event_type and a JSON-stringified payload and
    returns the RETURNING id; (b) passing an explicit client object routes the
    query through that client, not postgresClient.
  </action>
  <verify>
    <automated>npx vitest run lib/services/phishing-audit.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - `npx vitest run lib/services/phishing-audit.test.ts` passes.
    - `grep -n "INSERT INTO audit_events" lib/services/phishing-audit.ts` matches a parameterized statement using `$1`..`$4` (no string interpolation of values).
    - `writeAuditEvent` returns the inserted row id (string), sourced from `RETURNING id::text`.
    - The optional `client` param, when supplied, is the object `.query` is called on.
  </acceptance_criteria>
  <done>writeAuditEvent exists, is parameterized, supports an injected transaction client, and its test suite passes.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: approve + remediate orchestrators (idempotent, audited)</name>
  <files>lib/services/remediation-service.ts, lib/services/remediation-service.test.ts</files>
  <read_first>
    - lib/services/phishing-audit.ts (writeAuditEvent — created in Task 1)
    - lib/services/campaign-grouping-service.ts (postgresClient.transaction usage + status short-circuit idiom, lines 160-220)
    - lib/services/campaign-grouping-service.test.ts (transactionMock that invokes the callback with a fake client whose query is recorded, lines 55-140)
    - lib/services/campaign-classifier.ts (ClassifyResult.recommendedActions, DESTRUCTIVE_ACTIONS, append-only INSERT shape)
    - app/api/phishing/campaigns/[id]/route.ts (latest-classification read: `FROM classifications WHERE campaign_id=$1 ORDER BY created_at DESC`, lines 120-124)
  </read_first>
  <behavior>
    - approveRemediationActions rejects an action type that is NOT in the latest classification's recommended_actions → throws RemediationValidationError.
    - approveRemediationActions with no classification row for the campaign → throws RemediationValidationError.
    - approveRemediationActions inserts one remediation_actions row per requested action with status='approved', approved_by=actor, approved_at=NOW(), params=operator-supplied params, AND one audit_events row (event_type 'remediation_approved') — both inside one transaction.
    - remediateApprovedActions transitions every status='approved' row for the campaign to 'completed' and writes one 'remediation_completed' audit row per transitioned action.
    - remediateApprovedActions called twice: first call transitions N approved rows and writes N audit rows; second call transitions 0 and writes 0 audit rows (idempotent — REMED-04).
    - remediateApprovedActions on a campaign with zero remediation_actions rows → throws RemediationValidationError (explicit failure, never silent success — REMED-03).
  </behavior>
  <action>
    Create `lib/services/remediation-service.ts`. Import `{ postgresClient }` from
    `'./postgres-client'` and `{ writeAuditEvent }` from `'./phishing-audit'`.
    Export two typed error classes `RemediationValidationError` and
    `RemediationConflictError` (both `extends Error`, set `this.name`).

    Export `interface ApproveActionInput { actionType: string; params?:
    Record<string, unknown> }` and `approveRemediationActions(campaignId: string,
    actions: ApproveActionInput[], actor: string | null)`:
    inside `postgresClient.transaction(async (client) => { ... })`, first read the
    latest classification via `client.query` with
    `SELECT recommended_actions FROM classifications WHERE campaign_id=$1 ORDER BY
    created_at DESC LIMIT 1`. If no row → throw RemediationValidationError
    ('Campaign has no classification to approve against'). Parse
    recommended_actions (JSONB → string[]). For each input action, if
    `actionType` is not in that set → throw RemediationValidationError
    (`Action <type> is not a recommended action for this campaign`). Then INSERT
    one row per action into `remediation_actions (campaign_id, action_type,
    status, params, approved_by, approved_at)` VALUES `($1,$2,'approved',
    $3::jsonb, $4, NOW())` RETURNING `id::text`, collecting the ids. After the
    inserts, call `writeAuditEvent({ campaignId, actor, eventType:
    'remediation_approved', payload: { actions, actionIds } }, client)`. Return
    the approved rows as camelCase objects.

    Export `remediateApprovedActions(campaignId: string, actor: string | null)`:
    inside a transaction, `SELECT id::text, action_type, status FROM
    remediation_actions WHERE campaign_id=$1 FOR UPDATE`. If zero rows → throw
    RemediationValidationError ('No remediation actions to remediate — nothing
    approved') (REMED-03 explicit failure). For each row with status='approved':
    `UPDATE remediation_actions SET status='completed' WHERE id=$1` (the D-01
    simulated internal effect — no external provider call for any of the 7 action
    types), and `writeAuditEvent({ campaignId, actor, eventType:
    'remediation_completed', payload: { actionId, actionType } }, client)` — one
    audit row per transitioned action. Rows already 'completed' are left
    untouched and generate NO audit row (idempotency — REMED-04). Return
    `{ campaignId, actions: [{ id, actionType, status, alreadyCompleted }] }`.
    The `status='approved'` filter + FOR UPDATE is the idempotency mechanism
    (planner's-discretion choice per CONTEXT.md): a re-run finds no approved rows
    and transitions/audits nothing. Add a header comment noting D-01: this phase
    NEVER returns `not_implemented` because the simulated effect always takes and
    logs an action; the only explicit-failure branch is the zero-approved case.

    Create `remediation-service.test.ts`: `vi.mock('./postgres-client')` exporting
    both `query` and `transaction` mocks, and `vi.mock('./phishing-audit')`
    exporting a `writeAuditEvent` spy — both BEFORE importing the module. The
    transaction mock invokes its callback with a fake client whose `query`
    resolves scripted rows and records calls (copy the fake-client recorder from
    campaign-grouping-service.test.ts). Implement the six behavior assertions
    above; the REMED-04 idempotency test must call remediateApprovedActions twice
    and assert the writeAuditEvent spy was called exactly once per originally-
    approved action across both calls (zero additional on the second call).
  </action>
  <verify>
    <automated>npx vitest run lib/services/remediation-service.test.ts && npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - `npx vitest run lib/services/remediation-service.test.ts` passes all six behavior cases.
    - The idempotency test proves the writeAuditEvent spy fires exactly once per action across two remediate calls, and only 'approved' rows transition to 'completed'.
    - `grep -n "FOR UPDATE" lib/services/remediation-service.ts` matches inside remediateApprovedActions.
    - `grep -n "RemediationValidationError" lib/services/remediation-service.ts` shows it thrown for both the non-recommended-action and zero-approved-actions cases.
    - `grep -vn "^\s*//" lib/services/remediation-service.ts | grep -c "not_implemented"` returns 0 (no not_implemented code path, per D-01).
    - `npx tsc --noEmit` reports no errors.
  </acceptance_criteria>
  <done>approve and remediate orchestrators exist with recommended-only validation, idempotent completion via status filter, atomic audit writes, and a passing test suite proving REMED-01/02/03/04/06 at the unit level.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 3: mark-false-positive orchestrator (D-04 guard, audited)</name>
  <files>lib/services/remediation-service.ts, lib/services/remediation-service.test.ts</files>
  <read_first>
    - lib/services/remediation-service.ts (the file extended in Task 2 — reuse its transaction + error-class conventions)
    - lib/services/phishing-audit.ts (writeAuditEvent)
    - migrations/097_phishing_triage_schema.sql (campaigns.status DEFAULT 'open', remediation_actions.status)
    - app/api/admin/integrations/route.ts (guard-before-write + actor capture precedent, PATCH lines 58-93)
  </read_first>
  <behavior>
    - markCampaignFalsePositive throws RemediationConflictError when any remediation_actions row for the campaign has status IN ('approved','completed') (D-04 — prevents a "remediated AND false-positive" contradiction).
    - markCampaignFalsePositive with no approved/completed rows sets campaigns.status='false_positive' and writes one 'campaign_marked_false_positive' audit row, atomically.
    - The audit payload records the previous campaign status and the optional reason.
  </behavior>
  <action>
    Extend `remediation-service.ts` with
    `markCampaignFalsePositive(campaignId: string, actor: string | null, reason?:
    string)`. Inside `postgresClient.transaction`: first the D-04 guard —
    `SELECT id FROM remediation_actions WHERE campaign_id=$1 AND status IN
    ('approved','completed') FOR UPDATE LIMIT 1`; if a row is found → throw
    RemediationConflictError ('Cannot mark false positive: campaign already has
    approved or completed remediation'). Otherwise read the current campaign
    status (`SELECT status FROM campaigns WHERE id=$1`; if no row → throw
    RemediationValidationError 'Campaign not found'), `UPDATE campaigns SET
    status='false_positive', updated_at=NOW() WHERE id=$1`, then
    `writeAuditEvent({ campaignId, actor, eventType:
    'campaign_marked_false_positive', payload: { previousStatus, reason: reason ??
    null } }, client)`. Return `{ campaignId, status: 'false_positive',
    auditEventId }`. False-positive reversibility is out of scope (CONTEXT.md
    Deferred Ideas) — do not add an un-mark path.
    Extend `remediation-service.test.ts` with the three behavior assertions:
    guard throws RemediationConflictError when an approved/completed row exists;
    happy path updates campaigns.status to 'false_positive' and calls
    writeAuditEvent once with event_type 'campaign_marked_false_positive';
    audit payload carries previousStatus + reason.
  </action>
  <verify>
    <automated>npx vitest run lib/services/remediation-service.test.ts && npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - `npx vitest run lib/services/remediation-service.test.ts` passes including the three new mark-false-positive cases.
    - `grep -n "status IN ('approved', 'completed')\|status IN ('approved','completed')" lib/services/remediation-service.ts` matches the D-04 guard SELECT.
    - `grep -n "false_positive" lib/services/remediation-service.ts` shows the campaigns.status UPDATE.
    - RemediationConflictError is thrown (not a silent success) when the guard trips.
    - `npx tsc --noEmit` reports no errors.
  </acceptance_criteria>
  <done>markCampaignFalsePositive exists with the D-04 conflict guard, sets campaigns.status='false_positive', writes an atomic audit row, and its tests pass (REMED-05/06).</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| route → remediation-service | Route-supplied campaignId, action list, params, and actor cross into privileged state-change logic |
| service → Postgres | Every state change (approve/remediate/mark-fp) plus its audit write cross into the durable store; must be atomic |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-20-01 | Elevation of Privilege | approveRemediationActions | mitigate | Validate each requested `actionType` against the latest `classifications.recommended_actions`; reject non-recommended (and no-classification) with `RemediationValidationError` → route 400. Approve never invents an action the classifier did not recommend. |
| T-20-02 | Tampering | remediateApprovedActions idempotency | mitigate | Transition only `status='approved'` rows, selected `FOR UPDATE` inside the transaction; already-`completed` rows are not re-selected → a re-run transitions nothing and writes no second audit row. Proven by the double-call test (REMED-04). |
| T-20-03 | Repudiation | audit omission | mitigate | `writeAuditEvent(..., client)` runs inside the SAME `postgresClient.transaction` as every state write; a rollback discards the state change and its audit together — no state change can commit without its audit row (REMED-06). |
| T-20-04 | Tampering | contradictory audit trail | mitigate | D-04 guard in `markCampaignFalsePositive` rejects with `RemediationConflictError` when any approved/completed remediation exists, so a campaign can never be both remediated and false-positive. |
| T-20-05 | Information Disclosure | over-broad remediation | accept | D-01 simulated internal effect makes no external provider call for any of the 7 action types this phase; no real destructive effect can leak. Revisited when REMEDEXEC-01..05 (v2) wires a real provider. |
| T-20-SC | Tampering | npm/pip/cargo installs | n/a | This plan installs no packages — the service depends only on the existing `postgres-client` singleton. No install task; supply-chain checkpoint not required. |
</threat_model>

<verification>
- `npx vitest run lib/services/phishing-audit.test.ts lib/services/remediation-service.test.ts` — all pass.
- `npx tsc --noEmit --pretty` — clean.
- Idempotency, audit-atomicity, recommended-only approval, and the D-04 guard are each asserted by a named test case.
</verification>

<success_criteria>
- writeAuditEvent is the single, parameterized, append-only audit_events insert path, usable inside or outside a transaction.
- approveRemediationActions materializes only classifier-recommended actions as status='approved' rows with approver + timestamp + params, plus one audit row.
- remediateApprovedActions is idempotent (status filter + FOR UPDATE), applies the D-01 simulated completion, audits each transition, and fails explicitly when nothing is approved.
- markCampaignFalsePositive enforces the D-04 guard, sets campaigns.status='false_positive', and audits.
- No not_implemented code path and no external-provider call exists in this phase (D-01).
</success_criteria>

<output>
Create `.planning/phases/20-remediation-approval-audit-safety/20-01-SUMMARY.md` when done.
</output>
