From 266fc1915335ddb3999d4f38220692ed31144636 Mon Sep 17 00:00:00 2001 From: lorentz Date: Fri, 17 Jul 2026 07:27:02 -0400 Subject: [PATCH] docs(quick-260717-a19): phishing allowlist/timing/idempotency fixes + Seubert reclassification --- .planning/STATE.md | 3 +- .../260717-a19-PLAN.md | 281 ++++++++++++++++++ .../260717-a19-SUMMARY.md | 60 ++++ 3 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 .planning/quick/260717-a19-fix-phishing-simulation-vendor-allowlist/260717-a19-PLAN.md create mode 100644 .planning/quick/260717-a19-fix-phishing-simulation-vendor-allowlist/260717-a19-SUMMARY.md diff --git a/.planning/STATE.md b/.planning/STATE.md index e8afc8f..29377eb 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -28,7 +28,7 @@ See: .planning/PROJECT.md (updated 2026-07-14) Phase: Milestone v3.0 complete Plan: — Status: Awaiting next milestone -Last activity: 2026-07-17 — Milestone v3.0 completed and archived +Last activity: 2026-07-17 — Completed quick task 260717-a19: phishing allowlist/timing/idempotency fixes ## Performance Metrics @@ -126,6 +126,7 @@ None yet. | 260712-ash | Add PAX8 to admin sync overview page + detail page | 2026-07-12 | 6ed6c66 | [260712-ash-add-pax8-to-the-admin-sync-overview-page](./quick/260712-ash-add-pax8-to-the-admin-sync-overview-page/) | | 260716-n46 | Fix Mimecast blast-radius future-end-date swallow bug + multi-tenant gap (per-company tenant resolution) | 2026-07-16 | 9951e53 | [260716-n46-fix-mimecast-blast-radius-date-window-fu](./quick/260716-n46-fix-mimecast-blast-radius-date-window-fu/) | | 260716-pgr | Fix confidence display bug (0-1 scale rendered as raw percent, e.g. "1%" instead of "100%") | 2026-07-16 | 3f16268 | [260716-pgr-fix-confidence-display-bug-in-classifica](./quick/260716-pgr-fix-confidence-display-bug-in-classifica/) | +| 260717-a19 | Fix phishing simulation-vendor allowlist gaps (3 missing KnowBe4 domains), auto-parse timing race (retry on ticket.update), and parseAndStoreMessage idempotency; reclassified 6 stale Seubert campaigns (all flipped UNWANTED → USER_AWARENESS) | 2026-07-17 | (pending) | [260717-a19-fix-phishing-simulation-vendor-allowlist](./quick/260717-a19-fix-phishing-simulation-vendor-allowlist/) | ## Deferred Items diff --git a/.planning/quick/260717-a19-fix-phishing-simulation-vendor-allowlist/260717-a19-PLAN.md b/.planning/quick/260717-a19-fix-phishing-simulation-vendor-allowlist/260717-a19-PLAN.md new file mode 100644 index 0000000..53cdd39 --- /dev/null +++ b/.planning/quick/260717-a19-fix-phishing-simulation-vendor-allowlist/260717-a19-PLAN.md @@ -0,0 +1,281 @@ +--- +phase: quick-260717-a19 +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - lib/services/campaign-classifier.ts + - lib/services/campaign-classifier.test.ts + - lib/services/phishing-eml-service.ts + - lib/services/phishing-eml-service.test.ts + - lib/services/webhook-service.ts + - scripts/reclassify-seubert-campaigns.ts +autonomous: true +requirements: [PHISH-FIX-ALLOWLIST, PHISH-FIX-PARSE-RETRY, PHISH-FIX-IDEMPOTENCY, PHISH-RECLASSIFY] + +must_haves: + truths: + - "The 3 confirmed KnowBe4 lookalike domains classify as USER_AWARENESS, not UNWANTED" + - "parseAndStoreMessage returns early without a second messages row when one already exists for the report" + - "A ticket.update webhook re-attempts parsing when a flagged report still has no messages row and the company's auto_parse gate is on" + - "The 6 stale Seubert campaigns are reclassified against the corrected allowlist with before/after verdicts reported" + artifacts: + - path: "lib/services/campaign-classifier.ts" + provides: "Expanded knowbe4 domain allowlist" + contains: "customer-portal.info" + - path: "lib/services/phishing-eml-service.ts" + provides: "Idempotency existence guard in parseAndStoreMessage" + contains: "already-parsed" + - path: "lib/services/webhook-service.ts" + provides: "Retry-parse on ticket.update for unparsed flagged reports" + - path: "scripts/reclassify-seubert-campaigns.ts" + provides: "One-off reclassification script for the 6 affected campaigns" + key_links: + - from: "lib/services/webhook-service.ts" + to: "parseAndStoreMessage" + via: "retry path on WebhookEventType.UPDATE" + pattern: "parseAndStoreMessage" + - from: "lib/services/phishing-eml-service.ts" + to: "messages table" + via: "SELECT id FROM messages WHERE report_id existence check" + pattern: "report_id" +--- + + +Fix three verified defects in the phishing-triage pipeline discovered while +investigating Seubert ticket 699456, then reclassify the 6 affected campaigns. + +Purpose: Confirmed KnowBe4 simulation campaigns are being routed to technician +review (UNWANTED) instead of auto-acknowledged (USER_AWARENESS) because of a +data gap in the sim-vendor allowlist, a timing race that permanently starves +the classifier of parsed .eml evidence, and a missing idempotency guard that +blocks safe retries. This undermines the milestone goal of eliminating +technician work for confirmed simulations. + +Output: Updated allowlist data + test (Defect 1), idempotency guard + test +(Defect 3), a retry-on-update parse path reusing existing webhook update-event +traffic (Defect 2), and a one-off reclassification script for the 6 stale +Seubert campaigns. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/STATE.md +@./CLAUDE.md + + +Direct DB inspection of 8 Seubert phishing reports from a single burst on +2026-07-16/17 showed all 8 share an identical KnowBe4 URL fingerprint — +`https://{domain}/render-template/?csu={token}&status_id=n` and +`https://{domain}/tracking/?csu={token}&status_id=a` — differing only by +domain + csu token. Domains observed: +- it-support.care (already allowlisted, confirmed KnowBe4) +- customer-portal.info (tickets 699421, 699422, 699456) — MISSING +- cloud-service-care.com (tickets 699419, 699433) — MISSING +- bankonlinesupport.com (ticket 699435) — MISSING + +The `parseAndStoreMessage` call fired within ~1-2s of ticket.created returned a +silent no-op (attachment not yet available via Autotask getAttachments), and +calling the same function minutes later succeeded — there is no retry. Ticket +699456 received 5+ ticket.update webhook events in the minutes after creation. + + + +From lib/services/campaign-classifier.ts: +```typescript +export const KNOWN_SIMULATION_SENDERS: readonly { vendor: string; domains: readonly string[] }[] +export function domainMatchesAllowlist(domain: string): boolean // exact-or-subdomain, do NOT change +export function classifyCampaign(campaignId: string): Promise // ClassifyResult.verdict, .id +``` + +From lib/services/phishing-eml-service.ts: +```typescript +export interface ParseAndStoreResult { stored: boolean; messageId?: string; reason?: string; } +export async function parseAndStoreMessage(input: { reportId: string; ticketId: number }): Promise +``` + +From lib/services/phishing-automation-gate.ts: +```typescript +export async function getCompanyAutomationGate(companyId: number | null): Promise<{ autoParse: boolean; autoClassify: boolean; autoReport: boolean }> +``` + +reports table: id (UUID), ticket_id (BIGINT, UNIQUE), company_id, campaign_id (UUID) +messages table: id (UUID), report_id (UUID FK) +webhook types: WebhookEventType.CREATE | WebhookEventType.UPDATE, WebhookEntityType.TICKETS + + + + + + + Task 1: Add 3 confirmed KnowBe4 domains to simulation allowlist (Defect 1) + lib/services/campaign-classifier.ts, lib/services/campaign-classifier.test.ts + + - lib/services/campaign-classifier.ts lines 33-56 (KNOWN_SIMULATION_SENDERS + domainMatchesAllowlist) + - lib/services/campaign-classifier.test.ts lines 41-77 (existing allowlist / domainMatchesAllowlist / isKnownSimulationSender tests) + + + - domainMatchesAllowlist('customer-portal.info') === true + - domainMatchesAllowlist('cloud-service-care.com') === true + - domainMatchesAllowlist('bankonlinesupport.com') === true + - Subdomain still matches: domainMatchesAllowlist('mail.customer-portal.info') === true + - Spoof guard still holds: domainMatchesAllowlist('customer-portal.info.attacker.net') === false + + + In `KNOWN_SIMULATION_SENDERS`, extend ONLY the `knowbe4` vendor entry's + `domains` array to include `'customer-portal.info'`, + `'cloud-service-care.com'`, and `'bankonlinesupport.com'` alongside the + existing `'it-support.care'`. This is a pure data update — do NOT modify + `domainMatchesAllowlist`, `isKnownSimulationSender`, or any matching logic + (they already do exact-or-subdomain matching correctly, guarded by T-19-01 + tests). Update the inline comment on the knowbe4 entry to note these 3 + domains were confirmed via the shared /render-template/ + /tracking/ URL + fingerprint across Seubert tickets 699419/699421/699422/699433/699435/699456 + on 2026-07-16/17. In campaign-classifier.test.ts, add assertions to the + `domainMatchesAllowlist` describe block covering the 5 behaviors above. + + + cd /opt/stacks/pulse && npx vitest run lib/services/campaign-classifier.test.ts + + All 3 new domains (and a subdomain of one) match the allowlist; the suffix-spoof case still returns false; existing tests still pass. + + + + Task 2: Add idempotency guard (Defect 3) + retry-parse on ticket.update (Defect 2) + lib/services/phishing-eml-service.ts, lib/services/phishing-eml-service.test.ts, lib/services/webhook-service.ts + + - lib/services/phishing-eml-service.ts lines 49-95 (parseAndStoreMessage entry + no-op return shapes) + - lib/services/phishing-eml-service.test.ts lines 12-130 (mock setup; note beforeEach default `queryMock.mockResolvedValue({ rows: [{ id: 'message-uuid-1' }] })`) + - lib/services/webhook-service.ts lines 94-126 (event switch + fire-and-forget CREATE-only triggers) + - lib/services/webhook-service.ts lines 462-537 (triggerPhishingDetection + runGatedPhishingStages auto_parse path) + + + - parseAndStoreMessage returns `{ stored: false, reason: 'already-parsed' }` and performs NO getAttachments/getAttachmentContent/INSERT when a messages row already exists for the reportId. + - parseAndStoreMessage happy path still returns `{ stored: true, messageId }` when no existing messages row. + - On a ticket.update webhook, when a reports row exists for that ticket_id, has no linked messages row, and the company's auto_parse gate is on → parseAndStoreMessage is called exactly once. + - On a ticket.update webhook where the report already has a messages row → parseAndStoreMessage is NOT called (the existence check short-circuits). + - On a ticket.update webhook for a ticket with no reports row → no phishing work is triggered. + + + DEFECT 3 (idempotency), in phishing-eml-service.ts: at the very top of the + `try` block in `parseAndStoreMessage` (before the `getAttachments` call, ~line + 58), add an existence check: `SELECT id FROM messages WHERE report_id = $1 + LIMIT 1` with `[reportId]`. If `rows.length > 0`, return `{ stored: false, + reason: 'already-parsed' }` immediately — mirroring the existing + `{ stored: false, reason: ... }` no-op shape. Do not add ON CONFLICT to the + INSERT (the guard is the fix). + + DEFECT 3 tests, in phishing-eml-service.test.ts: NOTE the beforeEach default + `queryMock.mockResolvedValue({ rows: [{ id: 'message-uuid-1' }] })` — with the + new leading SELECT, this default now makes EVERY existing happy-path test + short-circuit as 'already-parsed' and fail. Fix by making the existence check + return empty for the pre-existing tests: change the default mock (or use + `queryMock.mockImplementation`) so a query whose SQL contains + `FROM messages WHERE report_id` resolves to `{ rows: [] }` while INSERT ... + RETURNING calls still resolve to `{ rows: [{ id: 'message-uuid-1' }] }`. Then + add ONE new test: existence check returns a row → assert result equals + `{ stored: false, reason: 'already-parsed' }`, and assert getAttachmentsMock + was NOT called and no `INSERT INTO messages` call occurred (reuse the existing + `callsContaining` helper). + + DEFECT 2 (retry on update), in webhook-service.ts: add a new private method + `retryPhishingParseOnUpdate(payload: AutotaskWebhookPayload): Promise` + that: (1) SELECTs `id::text, company_id` from `reports WHERE ticket_id = $1` + using `payload.entityId`; return if no row (not a flagged phishing ticket). + (2) SELECTs `id FROM messages WHERE report_id = $1 LIMIT 1`; return if a row + exists (already parsed). (3) calls `getCompanyAutomationGate(company_id)` and + returns unless `gate.autoParse` is true. (4) calls + `parseAndStoreMessage({ reportId, ticketId: Number(payload.entityId) })` + inside its own try/catch (log on error, never throw). Do NOT run detection, + grouping, classify, or report on update — only the missing-EML retry-parse. + + Wire it in the fire-and-forget block (lines ~118-126): the existing CREATE + branch keeps calling `triggerWorkflowEngine` + `triggerPhishingDetection`. + Add a sibling branch: when `payload.entityType === WebhookEntityType.TICKETS + && payload.eventType === WebhookEventType.UPDATE`, call + `this.retryPhishingParseOnUpdate(payload).catch(err => console.error('[WEBHOOK] Phishing retry-parse error:', err))`. + parseAndStoreMessage is now idempotent (Defect 3), so repeated update events + are safe. + + + cd /opt/stacks/pulse && npx vitest run lib/services/phishing-eml-service.test.ts && npx tsc --noEmit --pretty + + parseAndStoreMessage short-circuits with reason 'already-parsed' when a messages row exists; existing eml-service tests pass with the adjusted mock; webhook update events invoke retry-parse only for flagged, unparsed, auto_parse-gated tickets; tsc clean. + + + + Task 3: One-off reclassify script for the 6 stale Seubert campaigns + scripts/reclassify-seubert-campaigns.ts + + - lib/services/campaign-classifier.ts lines 451-526 (classifyCampaign signature + ClassifyResult) + - Existing scripts/*.ts for the `postgresClient` + `POSTGRES_HOST=localhost npx tsx` run convention + + + Create a one-off ops script (NOT a test, matches scripts/ convention). It must + run AFTER Task 1's allowlist fix is committed (it depends on the new code path). + + For the 6 affected tickets [699419, 699421, 699422, 699433, 699435, 699456]: + (1) Look up each campaign_id LIVE at execution time via + `SELECT campaign_id FROM reports WHERE ticket_id = $1` — do NOT hardcode + campaign UUIDs. Skip with a warning if campaign_id is null. + (2) Read the current/latest verdict for that campaign_id: + `SELECT verdict FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC LIMIT 1` + (this is the "before" verdict). + (3) Call `classifyCampaign(campaignId)` (appends a fresh classifications row — + D-02 append-only, correct behavior; the newest row becomes current). + (4) Print a per-ticket line: ticketId, campaignId, before verdict → after + verdict (result.verdict), and confidence. + (5) Print a summary count of how many flipped to USER_AWARENESS. + Guard the campaign UUIDs against duplicates (699421+699422 and 699419+699433 + may share a campaign) — dedupe campaign_ids before classifying so a shared + campaign isn't classified twice, but still report per-ticket mapping. + Exit cleanly (process.exit(0)) so the pg pool doesn't hang the process. + + + cd /opt/stacks/pulse && npx tsc --noEmit --pretty 2>&1 | grep -v '^#' | grep -c "scripts/reclassify-seubert-campaigns" | grep -q '^0$' + + Script type-checks clean; running `POSTGRES_HOST=localhost npx tsx scripts/reclassify-seubert-campaigns.ts` prints before/after verdicts for all 6 tickets and the flip-count summary. (Live DB run is a human verification step — the automated gate only proves the script compiles.) + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Autotask webhook → webhook-service | Untrusted external POST; already HMAC-verified upstream (middleware/handler). | +| Parsed .eml content → DB | Message content is persisted, never dereferenced (T-16-03 invariant preserved). | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-A19-01 | Tampering | Allowlist data (KNOWN_SIMULATION_SENDERS) | mitigate | Only add exact confirmed domains; keep exact-or-subdomain matching (domainMatchesAllowlist untouched) so suffix-spoof `customer-portal.info.attacker.net` still fails — covered by test. | +| T-A19-02 | Denial of Service | retry-parse on every ticket.update | accept | Retry short-circuits immediately once a messages row exists (Defect 3 guard); no new polling/cron; reuses existing update traffic. Bounded to one parseAndStoreMessage attempt per update, gated on auto_parse. | +| T-A19-03 | Elevation/Repudiation | classifyCampaign append-only history | accept | Reclassification appends new classifications rows (D-02), never overwrites — full audit trail preserved. | + + + +- `npx vitest run lib/services/campaign-classifier.test.ts lib/services/phishing-eml-service.test.ts` passes. +- `npx tsc --noEmit --pretty` is clean. +- `domainMatchesAllowlist` still rejects suffix-spoofed domains. +- No new messages row is created on a second parseAndStoreMessage call for the same report. +- Reclassification script prints before/after verdicts for all 6 tickets. + + + +- The 3 confirmed KnowBe4 domains classify as USER_AWARENESS via the allowlist short-circuit. +- parseAndStoreMessage is idempotent (returns `already-parsed`, no duplicate messages row). +- ticket.update webhooks retry parsing for flagged, unparsed, auto_parse-gated tickets — with no new cron/queue. +- The 6 Seubert campaigns are reclassified against the corrected allowlist with reported before/after verdicts. + + + +Create `.planning/quick/260717-a19-fix-phishing-simulation-vendor-allowlist/260717-a19-SUMMARY.md` when done. + diff --git a/.planning/quick/260717-a19-fix-phishing-simulation-vendor-allowlist/260717-a19-SUMMARY.md b/.planning/quick/260717-a19-fix-phishing-simulation-vendor-allowlist/260717-a19-SUMMARY.md new file mode 100644 index 0000000..a872c10 --- /dev/null +++ b/.planning/quick/260717-a19-fix-phishing-simulation-vendor-allowlist/260717-a19-SUMMARY.md @@ -0,0 +1,60 @@ +--- +phase: quick-260717-a19 +plan: 01 +subsystem: phishing +tags: [phishing, classifier, webhook, eml-parser, allowlist] +key-decisions: + - Added 3 confirmed KnowBe4 lookalike domains (customer-portal.info, cloud-service-care.com, bankonlinesupport.com) to the existing knowbe4 vendor entry rather than creating a new vendor or a URL-pattern-based detector — confirmed via identical /render-template/?csu=X&status_id=n + /tracking/?csu=X&status_id=a URL fingerprint shared with the already-allowlisted it-support.care + - parseAndStoreMessage idempotency guard implemented as a leading existence check (SELECT id FROM messages WHERE report_id) rather than an ON CONFLICT — the guard is also the fix, no schema change needed + - Auto-parse retry reuses existing ticket.update webhook traffic rather than adding a new cron/polling mechanism — bounded to one parseAndStoreMessage attempt per update event, gated on auto_parse and short-circuited by the idempotency guard once evidence exists +key-files: + created: + - scripts/reclassify-seubert-campaigns.ts (one-off ops script, not committed as permanent app code) + modified: + - lib/services/campaign-classifier.ts (KNOWN_SIMULATION_SENDERS knowbe4 domains expanded) + - lib/services/campaign-classifier.test.ts (allowlist coverage for 3 new domains + subdomain + suffix-spoof rejection) + - lib/services/phishing-eml-service.ts (idempotency existence guard in parseAndStoreMessage) + - lib/services/phishing-eml-service.test.ts (mock adjusted for new leading query; new already-parsed test) + - lib/services/webhook-service.ts (retryPhishingParseOnUpdate wired into ticket.update fire-and-forget path) +--- + +# Quick Task 260717-a19: Fix phishing simulation-vendor allowlist gaps, auto-parse timing race, and parseAndStoreMessage idempotency + +**One-liner:** Closed three verified defects in the phishing-triage pipeline (missing KnowBe4 allowlist domains, a silent auto-parse timing race with no retry, and a non-idempotent parse function) discovered while investigating Seubert ticket 699456, then reclassified the 6 campaigns those defects had misclassified — all 6 flipped from UNWANTED to USER_AWARENESS. + +## What was done + +### Task 1 — Simulation-vendor allowlist gap (Defect 1) +`KNOWN_SIMULATION_SENDERS`'s `knowbe4` vendor entry in `lib/services/campaign-classifier.ts` was extended from `['it-support.care']` to include `customer-portal.info`, `cloud-service-care.com`, and `bankonlinesupport.com`. Confirmed via direct DB inspection: all 8 Seubert phishing reports from the 2026-07-16/17 burst shared an identical URL fingerprint (`/render-template/?csu={token}&status_id=n` + `/tracking/?csu={token}&status_id=a`), differing only by sender domain and token — a platform fingerprint for KnowBe4's rotating lookalike-domain simulation templates. `domainMatchesAllowlist()` (exact-or-subdomain matching) was left untouched — this was a pure data update. Added test coverage for exact match, subdomain match, and suffix-spoof rejection (`customer-portal.info.attacker.net` still correctly fails) on all 3 new domains. + +### Task 2 — Idempotency guard (Defect 3) + retry-on-update (Defect 2) +`parseAndStoreMessage()` in `lib/services/phishing-eml-service.ts` now does a leading `SELECT id FROM messages WHERE report_id = $1 LIMIT 1` before any Autotask attachment fetch; if a row already exists it returns `{ stored: false, reason: 'already-parsed' }` immediately, matching the function's existing no-op return shape. This closes the duplicate-`messages`-row risk and is the safety prerequisite for Defect 2. + +`webhook-service.ts` gained a new private method `retryPhishingParseOnUpdate()`, wired into the `ticket.update` fire-and-forget path: for a ticket that already has a `reports` row, has no linked `messages` row yet, and whose company has `auto_parse` gated on, it calls `parseAndStoreMessage` once. This closes the timing race where the original `ticket.created` webhook fires `parseAndStoreMessage` within ~1-2 seconds of ticket creation — before Autotask's attachment is reliably available — with no prior retry path. Ticket update events (confirmed via logs to arrive reliably within seconds-to-minutes of every ticket creation) now serve as the natural retry, made safe by the Defect 3 idempotency guard. No new cron/polling infrastructure was added. Detection, grouping, classify, and report stages are unchanged on update events — only the missing-EML retry-parse was added. + +Test mock adjustment: `phishing-eml-service.test.ts`'s `beforeEach` default (`queryMock.mockResolvedValue({ rows: [{ id: 'message-uuid-1' }] })`) would have made the new leading existence-check short-circuit every pre-existing happy-path test as `already-parsed`. Fixed by branching the mock so `FROM messages WHERE report_id` queries return `{ rows: [] }` by default while `INSERT ... RETURNING` still resolves normally; added one new test asserting the already-parsed short-circuit (no `getAttachments`/`getAttachmentContent`/`INSERT` calls). + +### Task 3 — Reclassify the 6 affected Seubert campaigns +Created `scripts/reclassify-seubert-campaigns.ts` (one-off ops script per repo convention, not permanent app code) that looks up each affected ticket's `campaign_id` live (no hardcoded UUIDs), reads the prior verdict, calls `classifyCampaign()` (D-02 append-only — appends a new `classifications` row, never overwrites), and prints a before → after report. Ran live against Postgres: + +| Ticket | Campaign | Before | After | Confidence | +|--------|----------|--------|-------|------------| +| 699419 | a74edbea-4c41-4197-b431-d2c037ae07f1 | UNWANTED | USER_AWARENESS | 1 | +| 699421 | 1d1d778d-cf36-4bc4-bbe8-e11d23c3c190 | UNWANTED | USER_AWARENESS | 1 | +| 699422 | ab6f095d-4199-460d-bded-86d3dfc4bbfe | UNWANTED | USER_AWARENESS | 1 | +| 699433 | db03aaa2-b2a1-4959-b411-608381cd5f8f | UNWANTED | USER_AWARENESS | 1 | +| 699435 | 6a8fdb5c-aee9-4262-b695-7378a22bda83 | UNWANTED | USER_AWARENESS | 1 | +| 699456 | 5cdb31a3-64c9-4237-8edc-7e317f4f614c | UNWANTED | USER_AWARENESS | 1 | + +All 6 flipped to USER_AWARENESS. Each ticket mapped to a distinct campaign_id (no dedup collisions in practice, though the script's dedup logic handles the shared-campaign case). + +## Verification + +- `npx vitest run lib/services/campaign-classifier.test.ts lib/services/phishing-eml-service.test.ts` — 53/53 passing (post-merge re-run by orchestrator) +- `npx tsc --noEmit --pretty` — clean (post-merge re-run by orchestrator) +- Live reclassification run confirmed all 6 target campaigns now correctly read USER_AWARENESS + +## Deviations from plan + +- The dispatched worktree's branch was initially stale (missing the source files this task needed to modify, per the executor's own report) — corrected via the plan's own worktree-branch-check protocol (hard-reset to the orchestrator-supplied base commit) before starting Task 1. No work was lost; this ran before any task commits. +- **Orchestrator note (not an executor deviation):** this SUMMARY.md file itself was lost when the orchestrator removed the git worktree without first rescuing the uncommitted file (the standard cleanup script's rescue-before-remove step was skipped during a manual cleanup). Reconstructed after the fact from the executor's final report and the actual `git show` output of its three commits — content should be accurate but is not verbatim the original.