# Phase 23: Classification Disposition + Per-Client Automation Gate - Pattern Map

**Mapped:** 2026-07-16
**Files analyzed:** 9
**Analogs found:** 9 / 9

## File Classification

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `lib/services/campaign-classifier.ts` (modify: `Verdict` union, `mapVerdictToActions`) | service (pure rule engine) | transform | itself (existing D-06/D-08 sections) | exact |
| `migrations/100_phishing_automation_gate.sql` (new) | migration | CRUD (settings table) | `migrations/082_company_scope.sql` (+ `081_integration_settings.sql` for audit cols) | exact |
| `app/admin/phishing-automation/page.tsx` (new) | component (admin page) | request-response | `app/admin/client-scope/page.tsx` | exact |
| `app/api/admin/phishing-automation/route.ts` (new, GET list) | route | CRUD | `app/api/admin/company-scope/route.ts` | exact |
| `app/api/admin/phishing-automation/[companyId]/route.ts` (new, PATCH) | route | CRUD | `app/api/admin/company-scope/[companyId]/route.ts` | exact |
| `lib/services/webhook-service.ts` (modify: `triggerPhishingDetection` + new sibling stage-runner) | service (event-driven trigger) | event-driven | itself (`triggerPhishingDetection`, lines 458-495) | exact |
| `lib/services/triage-note-service.ts` (modify: new `acknowledge_user` note writer, or param on existing writer) | service (Autotask write) | request-response (external API write) | itself (`generateAndPostTriageNote`, lines 85-195) | exact |
| `components/phishing/classification-card.tsx` (modify: new verdict badge + action label) | component | request-response | itself (`VERDICT_VARIANT_CLASS`/`ACTION_LABEL`, lines 30-44) | exact |
| `components/phishing/action-area-card.tsx` (modify: `acknowledge_user` checkbox + params form case) | component | request-response | itself (`ACTION_LABEL` + `ActionParamsForm` switch, lines 67-246) | exact |
| `lib/services/remediation-default-params.ts` (modify: 8th case in `deriveDefaultParams`) | utility (pure transform) | transform | itself (existing switch, lines 23-42) | exact |

Every file's best analog is itself (same-file addition) or a near-identical sibling file already in the repo — this phase composes existing patterns, it introduces none.

## Pattern Assignments

### `lib/services/campaign-classifier.ts` (service, transform)

**Analog:** itself — extend the existing `Verdict` union and switch statements in place.

**Current verdict type + action mapping** (lines 150-186):
```typescript
export type Verdict = 'SPAM' | 'UNWANTED' | 'THREAT';

export function mapVerdictToActions(verdict: Verdict, evidence: ActionEvidence): string[] {
  switch (verdict) {
    case 'SPAM':
      return ['no_action'];
    case 'UNWANTED':
      return ['warn_user'];
    case 'THREAT': {
      const actions = ['block_sender', 'purge_message'];
      if (evidence.clicked > 0) {
        actions.push('reset_password', 'isolate_endpoint', 'disable_forwarding_rule');
      }
      return actions;
    }
  }
}
```

**Where the new verdict slots in** (D-06 allowlist short-circuit, lines 453-469 of `classifyCampaign`):
```typescript
const isSimulation = evidence.messages.some((message) => isKnownSimulationSender(message));

let verdict: Verdict;
const reasons: string[] = [];
if (isSimulation) {
  verdict = evaluateSpamVsUnwanted(evidence);   // <-- this line becomes: verdict = 'USER_AWARENESS';
  reasons.push(
    'Sender domain matches a known phishing-simulation vendor allowlist (KnowBe4/Breach Secure Now) — THREAT tier skipped'
  );
} else if (evaluateThreatTier(evidence)) {
  verdict = 'THREAT';
} else {
  verdict = evaluateSpamVsUnwanted(evidence);
}
```
**Concrete change:** add `'USER_AWARENESS'` (or discretion-chosen name) to the `Verdict` union; replace the `isSimulation` branch's call to `evaluateSpamVsUnwanted(evidence)` with a direct assignment to the new verdict (D-01/D-02 — this is the exact code path the classifier already isolates for the allowlist match, no new branching needed); add a `case 'USER_AWARENESS': return ['acknowledge_user'];` arm to `mapVerdictToActions`. `DESTRUCTIVE_ACTIONS` (line 153) and `computeRequiresApproval` are untouched — `acknowledge_user` must NOT be added to that set (D-04 exemption is enforced elsewhere, not via this invariant).

**Constant reused unchanged:** `KNOWN_SIMULATION_SENDERS` / `isKnownSimulationSender` (lines 33-81) — detection logic is explicitly out of scope (CONTEXT.md "Does NOT cover").

---

### `migrations/100_phishing_automation_gate.sql` (migration, CRUD settings table)

**Analog:** `migrations/082_company_scope.sql` (schema shape) + `migrations/081_integration_settings.sql` (audit columns, if added per Claude's Discretion).

**company_scope full pattern to adapt** (082, lines 13-25):
```sql
CREATE TABLE IF NOT EXISTS company_scope (
  company_id  BIGINT       PRIMARY KEY REFERENCES companies(id) ON DELETE CASCADE,
  in_scope    BOOLEAN      NOT NULL DEFAULT true,
  updated_by  TEXT,
  updated_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_company_scope_in_scope
  ON company_scope(in_scope)
  WHERE in_scope = false;

COMMENT ON TABLE company_scope IS
  'Opt-out scope filter for analytics. Absent row = in scope. in_scope=false = excluded from dashboard KPIs and ticket views.';
```

**Key difference to apply (D-06 opt-IN, not opt-out):** three booleans, all `NOT NULL DEFAULT false` (opposite default polarity from `company_scope`'s `true`):
```sql
CREATE TABLE IF NOT EXISTS phishing_automation_gate (
  company_id        BIGINT       PRIMARY KEY REFERENCES companies(id) ON DELETE CASCADE,
  auto_parse        BOOLEAN      NOT NULL DEFAULT false,
  auto_classify     BOOLEAN      NOT NULL DEFAULT false,
  auto_report       BOOLEAN      NOT NULL DEFAULT false,
  updated_by        TEXT,
  updated_at        TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);
```
Absent row = all three OFF (D-06) — mirror `company_scope`'s `COALESCE(cs.in_scope, true)` read pattern but with `false` as the fallback (see API route excerpt below).

**Audit-column precedent, if adopted** (081, lines 14-22):
```sql
CREATE TABLE IF NOT EXISTS integration_settings (
  key              TEXT PRIMARY KEY,
  disabled         BOOLEAN NOT NULL DEFAULT false,
  disabled_reason  TEXT,
  disabled_by      TEXT,
  disabled_at      TIMESTAMPTZ,
  created_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at       TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
If per-stage audit trail is wanted beyond a single `updated_by`/`updated_at`, that pattern is the closest existing precedent — but `company_scope`'s single-actor/single-timestamp columns are the more direct structural match since this table (like `company_scope`) is keyed by `company_id`, not by a settings `key`.

**Next migration number:** latest existing is `099_indicators_metadata.sql` — use `100_*.sql`.

---

### `app/admin/phishing-automation/page.tsx` (component, request-response)

**Analog:** `app/admin/client-scope/page.tsx` (full file, 243 lines) — near line-for-line template.

**Imports pattern** (lines 1-28): identical shadcn/PageHeader/lucide import block, reusable as-is (swap `Building2`/`EyeOff` icons for something automation-relevant, e.g. `Zap`/`Bot`).

**State + data shape** (lines 30-49): `Company` interface gets three booleans instead of one:
```typescript
interface Company {
  id: string;
  companyName: string;
  companyType: number | null;
  companyTypeLabel: string | null;
  autoParse: boolean;
  autoClassify: boolean;
  autoReport: boolean;
}
```

**Load pattern** (lines 51-76) — reuse verbatim, just repoint the fetch URL to `/api/admin/phishing-automation`.

**Toggle pattern — the one structural difference** (lines 78-100): `client-scope` has a single `toggle(company, next)`. This page needs a per-stage toggle:
```typescript
async function toggle(company: Company, stage: 'autoParse' | 'autoClassify' | 'autoReport', next: boolean) {
  setToggling(`${company.id}:${stage}`);
  try {
    const res = await fetch(`/api/admin/phishing-automation/${company.id}`, {
      method: 'PATCH',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ [stage]: next }),
    });
    // ...same error/toast handling as client-scope lines 86-98
  } finally {
    setToggling(null);
  }
}
```

**Table row pattern** (lines 200-236) — same `<Table>`/`<TableRow>` shape, but the single trailing `<TableCell>` with one `<Switch>` becomes three `<TableCell>`s, one `<Switch>` each (D-05 three independent toggles):
```tsx
<TableCell className="text-right">
  <Switch checked={company.autoParse} onCheckedChange={(next) => void toggle(company, 'autoParse', next)} aria-label={`Auto-parse for ${company.companyName}`} />
</TableCell>
<TableCell className="text-right">
  <Switch checked={company.autoClassify} onCheckedChange={(next) => void toggle(company, 'autoClassify', next)} aria-label={`Auto-classify for ${company.companyName}`} />
</TableCell>
<TableCell className="text-right">
  <Switch checked={company.autoReport} onCheckedChange={(next) => void toggle(company, 'autoReport', next)} aria-label={`Auto-report for ${company.companyName}`} />
</TableCell>
```

**Search/filter/summary chrome** (lines 102-178) — reuse the search Input + type Select verbatim; the `scopeFilter`/`excluded` summary concept can be dropped or repurposed as "companies with any stage enabled" count — Claude's discretion, not load-bearing.

---

### `app/api/admin/phishing-automation/route.ts` (route, CRUD)

**Analog:** `app/api/admin/company-scope/route.ts` (full file, 75 lines).

**Auth + query-param parsing** (lines 33-52) — reuse verbatim (`requireAdmin()`, `search`/`type` params, `conditions` array building).

**Core LEFT JOIN + COALESCE pattern — the load-bearing part to adapt** (lines 54-62):
```typescript
const result = await postgresClient.query<CompanyRow>(
  `SELECT c.id::text, c.company_name, c.company_type,
          COALESCE(cs.in_scope, true) AS in_scope
     FROM companies c
     LEFT JOIN company_scope cs ON cs.company_id = c.id
    WHERE ${conditions.join(' AND ')}
    ORDER BY c.company_name`,
  params
);
```
**Adapted for opt-in defaults (D-06 — fallback is `false`, not `true`):**
```sql
SELECT c.id::text, c.company_name, c.company_type,
       COALESCE(pag.auto_parse, false) AS auto_parse,
       COALESCE(pag.auto_classify, false) AS auto_classify,
       COALESCE(pag.auto_report, false) AS auto_report
  FROM companies c
  LEFT JOIN phishing_automation_gate pag ON pag.company_id = c.id
 WHERE ${conditions.join(' AND ')}
 ORDER BY c.company_name
```

**Response shape mapping** (lines 64-75) — same `.map()` + `NextResponse.json({...})` idiom, camelCase transform of the three booleans.

---

### `app/api/admin/phishing-automation/[companyId]/route.ts` (route, CRUD)

**Analog:** `app/api/admin/company-scope/[companyId]/route.ts` (full file, 59 lines) — PATCH pattern.

**Upsert pattern, load-bearing** (lines 14-43):
```typescript
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ companyId: string }> }) {
  const { session, error } = await requireAdmin();
  if (error) return error;

  const { companyId } = await params;
  const id = parseInt(companyId, 10);
  if (isNaN(id)) return NextResponse.json({ error: 'Invalid companyId' }, { status: 400 });

  const body = await request.json().catch(() => null);
  if (body == null || typeof body.inScope !== 'boolean') {
    return NextResponse.json({ error: 'body.inScope (boolean) required' }, { status: 400 });
  }

  const userEmail = (session?.user as any)?.email ?? null;

  await postgresClient.query(
    `INSERT INTO company_scope (company_id, in_scope, updated_by, updated_at)
          VALUES ($1, $2, $3, NOW())
     ON CONFLICT (company_id)
     DO UPDATE SET in_scope = EXCLUDED.in_scope,
                   updated_by = EXCLUDED.updated_by,
                   updated_at = NOW()`,
    [id, body.inScope, userEmail]
  );

  return NextResponse.json({ ok: true, companyId: id, inScope: body.inScope });
}
```
**Adaptation note:** since this table has three independently-toggleable booleans (not one), a partial-body PATCH (`{ autoParse: true }` alone, leaving classify/report untouched) needs an `INSERT ... ON CONFLICT DO UPDATE` that only overwrites the column(s) present in the body — e.g. `COALESCE($2, auto_parse)`-style merge, or require the client to always send all three current values (simpler, matches `client-scope`'s single-field simplicity, and the admin page's per-Switch `toggle()` call already knows the other two current values from local state — sending all three each PATCH avoids partial-update SQL complexity entirely). Recommend the latter for consistency with the one-field analog.

**DELETE (revert to all-OFF default)** (lines 45-58) — reuse verbatim, same shape, table name swapped.

---

### `lib/services/webhook-service.ts` (service, event-driven)

**Analog:** itself — `triggerPhishingDetection` (lines 458-495) is the exact shape the new automatic parse/classify/report calls should follow.

**Full existing method to extend/sibling from:**
```typescript
private async triggerPhishingDetection(payload: AutotaskWebhookPayload): Promise<void> {
  const row = await postgresClient.query<{
    id: string; ticket_number: string | null; title: string | null; description: string | null;
    company_id: number | null; contact_id: number | null; created_by_contact_id: number | null;
  }>(
    `SELECT id, ticket_number, title, description, company_id, contact_id, created_by_contact_id
       FROM tickets WHERE id = $1`,
    [payload.entityId]
  );

  const r = row.rows[0];
  if (!r) {
    console.warn(`[WEBHOOK] Skipping phishing detection — ticket ${payload.entityId} not found in Postgres yet`);
    return;
  }

  const ticket: DetectableTicket = { /* ...mapped fields... */ };

  console.log(`[WEBHOOK] Triggering phishing detection for ticket ${payload.entityId}`);
  const detection = await detectPhishingTicket(ticket);
  if (detection.flagged && detection.reportId) {
    await groupReportIntoCampaign(detection.reportId, { skipIfAlreadyGrouped: true });
  }
}
```

**Call site — fire-and-forget from `processWebhook`** (lines 114-122):
```typescript
if (payload.entityType === WebhookEntityType.TICKETS && payload.eventType === WebhookEventType.CREATE) {
  this.triggerWorkflowEngine(payload).catch(err => console.error('[WEBHOOK] Workflow engine error:', err));
  this.triggerPhishingDetection(payload).catch(err => console.error('[WEBHOOK] Phishing detection error:', err));
}
```
**Integration point for this phase (per CONTEXT.md code_context):** after `groupReportIntoCampaign` succeeds inside `triggerPhishingDetection`, look up the resulting campaign's `company_id`, query `phishing_automation_gate` (`COALESCE(..., false)` per stage), and — following this SAME method's `if (detection.flagged && ...)` gating idiom — conditionally call, in order: `parseAndStoreMessage({ reportId, ticketId })` (if `auto_parse`) → `classifyCampaign(campaignId)` (if `auto_classify`, same underlying function `app/api/phishing/campaigns/[id]/classify/route.ts` calls — do not duplicate its logic) → `generateAndPostTriageNote(campaignId)`-equivalent stage-runner for `acknowledge_user`/report-to-ticket (if `auto_report`, and ONLY when the latest classification's verdict is `USER_AWARENESS` — D-04 says the auto-report carve-out is narrowly for `acknowledge_user`, not a general "post any note automatically" gate). Every stage call should be wrapped the same way — never block `processWebhook`'s response, log-and-continue on error (matches the fire-and-forget `.catch(err => console.error(...))` idiom at lines 119-121, not a per-stage try/catch that swallows and returns void like `triggerPhishingDetection`'s own body which lets errors propagate to the caller's `.catch`).

---

### `lib/services/triage-note-service.ts` (service, external API write)

**Analog:** itself — `generateAndPostTriageNote` (lines 85-195), specifically the `TicketNotes` write call (lines 176-183).

**Existing write call:**
```typescript
await client.createEntity('TicketNotes', {
  ticketID: Number(report.ticket_id),
  title: 'Phishing Triage Summary',
  description: noteText,
  noteType: 1, // Internal
  publish: 1,
});
```

**RESOLVED (verified live against this tenant's `TicketNotes/entityInformation/fields` API, 2026-07-16) — `publish` is NOT the client-visibility field.** Its real picklist: `1="All Autotask Users"`, `2="Internal Project Team"`, `4="Internal & Co-Managed"` — all three are internal-staff tiers; there is no client-facing value in `publish` at all. The actual client-visibility field is `noteType`, which has a dedicated value `18="Client Portal Note"`. The existing triage-note's `noteType: 1` is "Task Summary" — an unrelated internal categorization, not a visibility control. **Correct implementation for `acknowledge_user`:** set `noteType: 18`, leave `publish: 1` unchanged (still required by the API, orthogonal to client visibility — do not touch it). Do not swap `publish` values; that field never controls client visibility for this entity.

**Per-ticket loop + error isolation pattern to reuse (D-05 unchanged)** (lines 172-192):
```typescript
for (const report of reports) {
  try {
    await client.createEntity('TicketNotes', { /* ... */ });
    tickets.push({ ticketId: report.ticket_id, posted: true });
  } catch (err) {
    console.error('[PHISHING-TRIAGE-NOTE] Failed to post note to ticket', report.ticket_id, err);
    tickets.push({ ticketId: report.ticket_id, posted: false, error: err instanceof Error ? err.message : 'Unknown error' });
  }
}
```
**Suggested shape for the new function:** a sibling `generateAndPostAcknowledgment(campaignId)` (mirrors `generateAndPostTriageNote`'s signature/return shape `{ noteText, tickets }`) with its own short thank-you copy (D-02, not the full evidence-dump `formatTriageNote()` template) and the flipped visibility value, OR a `visibility` param added to a shared internal helper both functions call — either is Claude's discretion per CONTEXT.md; the per-ticket try/catch-in-loop and `TriageNotePostResult[]` return shape must be preserved either way.

---

### `components/phishing/classification-card.tsx` (component, request-response)

**Analog:** itself — `VERDICT_VARIANT_CLASS` + `ACTION_LABEL` maps (lines 30-44) and the `ClassificationCardData['verdict']` type (line 15).

**Current maps to extend:**
```typescript
export interface ClassificationCardData {
  id: string;
  verdict: 'SPAM' | 'UNWANTED' | 'THREAT';   // add the new verdict literal here
  // ...
}

const VERDICT_VARIANT_CLASS: Record<ClassificationCardData['verdict'], string> = {
  SPAM: 'bg-slate-500/15 text-slate-600',
  UNWANTED: 'bg-amber-500/15 text-amber-600',
  THREAT: 'bg-destructive/15 text-destructive',
  // USER_AWARENESS: 'bg-emerald-500/15 text-emerald-600',  <- distinct color per CONTEXT.md code_context
};

const ACTION_LABEL: Record<string, string> = {
  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',
  // acknowledge_user: 'Acknowledge user',
};
```
`StatusBadge` consumption (line 115) needs no structural change — `variantClass` is already a free-form Tailwind class string (`CustomVariantProps` in `components/ui/status-badge.tsx`), so a new verdict key just needs a new entry in the Record, same pattern.

---

### `components/phishing/action-area-card.tsx` (component, request-response)

**Analog:** itself — `ACTION_LABEL` (lines 67-75) + `ActionParamsForm`'s switch (lines 111-246).

**New checkbox case to add to `ActionParamsForm`** (pattern from the existing `no_action` case, lines 126-132, since `acknowledge_user` likely also needs no operator-editable params for the manual-approval path):
```typescript
case 'acknowledge_user':
  return (
    <p className="text-sm text-muted-foreground">
      No parameters — posts a customer-visible thank-you note to the reporting employee.
    </p>
  );
```
**Also add to `ACTION_LABEL`:** `acknowledge_user: 'Acknowledge user',` (same Record as classification-card.tsx — consider extracting to a shared constant if both files need to stay in sync, though CONTEXT.md doesn't mandate that refactor).

**Note on D-04's approval exemption:** this component's checkbox/Approve-button flow (lines 375-399, `handleApprove` → `POST /api/phishing/campaigns/[id]/approve`) is the MANUAL path — CONTEXT.md D-04 says `acknowledge_user` only skips manual approval when the automation gate's report-to-ticket stage is enabled for that company. For companies WITHOUT that stage enabled, `acknowledge_user` still needs to render here exactly like every other action type (checkbox + Approve button), unchanged. No gating logic belongs in this component for the exemption — the exemption only affects the automatic webhook path (`webhook-service.ts` above), not this manual-review UI.

---

### `lib/services/remediation-default-params.ts` (utility, transform)

**Analog:** itself — the exhaustive switch (lines 23-42).

**Existing pattern (8th case to add):**
```typescript
export function deriveDefaultParams(actionType: string, evidence: DefaultParamEvidence): Record<string, unknown> {
  switch (actionType) {
    case 'no_action':
      return {};
    // ...
    case 'disable_forwarding_rule':
      return { userPrincipalName: evidence.requesterEmail ?? '', ruleName: '' };
    default:
      return {};
  }
}
```
**Add:**
```typescript
case 'acknowledge_user':
  return {};   // no operator-editable params (D-02 copy is fixed/templated server-side, not user-edited per-instance)
```
Matches `no_action`'s empty-params precedent exactly — both are informational/non-destructive actions with nothing for an operator to fill in.

---

## Shared Patterns

### Admin-role route guard
**Source:** `app/api/admin/company-scope/route.ts` lines 33-35, `.../[companyId]/route.ts` lines 18-19
**Apply to:** both new `app/api/admin/phishing-automation/*` routes
```typescript
const { session, error } = await requireAdmin();
if (error) return error;
```

### Opt-in/opt-out settings-table read (COALESCE over LEFT JOIN)
**Source:** `app/api/admin/company-scope/route.ts` lines 54-62
**Apply to:** new `phishing-automation` GET route (fallback flips from `true` to `false` per D-06) and the webhook-service.ts gate check
```sql
SELECT c.id, COALESCE(t.flag_column, <default>) AS flag
  FROM companies c LEFT JOIN <settings_table> t ON t.company_id = c.id
```

### Upsert-on-toggle with actor stamping
**Source:** `app/api/admin/company-scope/[companyId]/route.ts` lines 30-40
**Apply to:** new PATCH route
```typescript
const userEmail = (session?.user as any)?.email ?? null;
await postgresClient.query(
  `INSERT INTO <table> (company_id, <cols...>, updated_by, updated_at)
        VALUES ($1, ..., $N, NOW())
   ON CONFLICT (company_id)
   DO UPDATE SET <cols...> = EXCLUDED.<cols...>, updated_by = EXCLUDED.updated_by, updated_at = NOW()`,
  [id, ...values, userEmail]
);
```

### `hasPermission()` client-side gating (if the new admin page needs button-level gating beyond page-level `admin` role)
**Source:** `components/phishing/action-area-card.tsx` lines 300-303, `components/phishing/classification-card.tsx` lines 61-64; also referenced in Phase 22 CONTEXT.md D-06
```typescript
const { data: session } = useSession();
const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
const canX = hasPermission(role, 'phishing', 'analyze');
```
Not strictly needed here since `/admin/phishing-automation` is already `admin`-role-gated at the page/route level (D-08), but available if any finer-grained control surfaces.

### Fire-and-forget webhook stage trigger, error isolation
**Source:** `lib/services/webhook-service.ts` lines 119-121
```typescript
this.triggerPhishingDetection(payload).catch(err =>
  console.error('[WEBHOOK] Phishing detection error:', err)
);
```
**Apply to:** whatever new sibling method chains parse→classify→report; never `await` it inline in `processWebhook`'s main flow, always `.catch()` to a `console.error`.

### Audit-event write on every classify/approve/remediate/mark-false-positive state change
**Source:** `lib/services/phishing-audit.ts` (`writeAuditEvent`), consumed at `app/api/phishing/campaigns/[id]/classify/route.ts` lines 50-56
```typescript
const actor = (session?.user as { email?: string } | undefined)?.email ?? null;
await writeAuditEvent({
  campaignId: id,
  actor,
  eventType: 'campaign_classified',
  payload: { verdict: result.verdict, requiresApproval: result.requiresApproval },
});
```
**Apply to:** if the new automatic `acknowledge_user` post is treated as a state-changing event worth auditing (reasonable given every other phishing state change gets one), add a matching `eventType: 'acknowledge_user_posted'` (or similar) call — either in the new webhook stage-runner or inside the new triage-note-service function itself. Not explicitly decided in CONTEXT.md ("Claude's Discretion" — audit logging of automation-gate *toggle* changes is called out; audit logging of the *acknowledge_user post itself* is not mentioned but is a natural extension of the established pattern).

## No Analog Found

None — every file in this phase has a same-file or near-identical sibling analog already in the codebase.

## Watch-out flags for the planner (not full patterns, but load-bearing)

1. **`publish`/`noteType` semantics — RESOLVED** (see triage-note-service.ts section above): use `noteType: 18` ("Client Portal Note") for `acknowledge_user`'s customer visibility; `publish` stays `1` unchanged. Do not reintroduce the "flip publish" framing from the original discussion — verified live against Autotask's field metadata that `publish` has no client-facing value.
2. **D-04's exemption scope is narrow** — only the AUTOMATIC path (webhook-triggered, report-to-ticket stage enabled) skips approval for `acknowledge_user`. The MANUAL review UI (`action-area-card.tsx`) must still show `acknowledge_user` as a normal checkbox+Approve action for every company, regardless of gate state — do not add any gate-check logic to that component.
3. **Automatic report-to-ticket stage should only fire for `USER_AWARENESS`/`acknowledge_user`**, not as a generic "auto-post any note" toggle — the `auto_report` boolean's automatic behavior is scoped by CONTEXT.md to this one action type; other verdicts' actions still always require manual approval even when `auto_report` is on for that company (D-04 second sentence).
4. **`DESTRUCTIVE_ACTIONS` set in `campaign-classifier.ts` (line 153) must NOT include `acknowledge_user`** — it already won't by default (nothing adds it), just don't accidentally add it while wiring the new verdict/action.

## Metadata

**Analog search scope:** `lib/services/`, `app/api/admin/`, `app/api/phishing/`, `app/admin/`, `components/phishing/`, `migrations/`
**Files scanned:** ~20 (9 target files + 11 analog/reference files)
**Pattern extraction date:** 2026-07-16
