diff --git a/.planning/phases/23-classification-disposition-per-client-automation-gate/23-CONTEXT.md b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-CONTEXT.md
index 52a81ac..7dd4124 100644
--- a/.planning/phases/23-classification-disposition-per-client-automation-gate/23-CONTEXT.md
+++ b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-CONTEXT.md
@@ -49,12 +49,21 @@ webhook fix (already shipped as quick task 260716-n46 in this same session).
reporting it and reinforcing that their vigilance helps keep the company
secure. Exact copy is Claude's discretion; tone should be genuinely
appreciative, not templated-sounding.
-- **D-03:** `acknowledge_user` delivers as a **customer-visible (Publish=true)
- Autotask ticket note** — not an internal-only note like Phase 21's existing
+- **D-03:** `acknowledge_user` delivers as a **customer-visible Autotask
+ ticket note** — not an internal-only note like Phase 21's existing
triage-note, and not a separate email. Reuses the existing `TicketNotes`
- write path from `triage-note-service.ts`, with the visibility flag flipped
- for this specific action type so the reporting employee actually sees it
- (via their normal ticket notification/portal).
+ write path from `triage-note-service.ts`.
+ **Verified against Autotask's live `TicketNotes/entityInformation/fields`
+ API (not assumed from code/migration comments, which conflicted):**
+ `publish` is NOT a client-visibility field — its full picklist is
+ `1=All Autotask Users`, `2=Internal Project Team`, `4=Internal & Co-Managed`,
+ all internal-staff-only tiers. The actual client-visibility field is
+ `noteType`, which has a dedicated value `18="Client Portal Note"`. Phase
+ 21's existing triage-note uses `noteType: 1` ("Task Summary") — an
+ unrelated internal categorization. **Correct implementation:** set
+ `noteType: 18` for `acknowledge_user` (instead of copying Phase 21's
+ `noteType: 1`); leave `publish: 1` unchanged (still required by the API,
+ orthogonal to client visibility).
- **D-04:** `acknowledge_user` is exempt from Phase 20's proposed-only /
manual-approval model — for companies with the automation gate's
"report-to-ticket" stage enabled, it posts automatically without an
diff --git a/.planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md
new file mode 100644
index 0000000..1151faf
--- /dev/null
+++ b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md
@@ -0,0 +1,489 @@
+# 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 `
`/`` shape, but the single trailing `` with one `` becomes three ``s, one `` each (D-05 three independent toggles):
+```tsx
+
+ void toggle(company, 'autoParse', next)} aria-label={`Auto-parse for ${company.companyName}`} />
+
+
+ void toggle(company, 'autoClassify', next)} aria-label={`Auto-classify for ${company.companyName}`} />
+
+
+ void toggle(company, 'autoReport', next)} aria-label={`Auto-report for ${company.companyName}`} />
+
+```
+
+**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(
+ `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 {
+ 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 = {
+ 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 = {
+ 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 (
+
+ No parameters — posts a customer-visible thank-you note to the reporting employee.
+
+ );
+```
+**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 {
+ 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, ) AS flag
+ FROM companies c LEFT JOIN 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 (company_id, , updated_by, updated_at)
+ VALUES ($1, ..., $N, NOW())
+ ON CONFLICT (company_id)
+ DO UPDATE SET = EXCLUDED., 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