docs(23): correct acknowledge_user delivery field per live Autotask verification

Verified against TicketNotes/entityInformation/fields on the real tenant:
publish has no client-facing value (1=All Autotask Users, 2=Internal Project
Team, 4=Internal & Co-Managed -- all internal-staff tiers). Client Portal
visibility is controlled by noteType=18 ("Client Portal Note"), not publish.
Corrects D-03's original "flip the publish flag" framing before planning.
This commit is contained in:
lorentz 2026-07-16 18:59:33 -04:00
parent bd2fc240ec
commit fd92263416
2 changed files with 503 additions and 5 deletions

View file

@ -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

View file

@ -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 `<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