---
phase: 18-campaign-grouping-phishing-analysis-api
verified: 2026-07-16T07:05:00Z
status: passed
score: 5/5 must-haves verified
overrides_applied: 0
re_verification:
  previous_status: gaps_found
  previous_score: 4/5
  gaps_closed:
    - "CR-03: re-analyzing a single-report campaign no longer creates a duplicate campaigns row with the identical campaign_key — own-campaign revalidation guard added and confirmed both by mocked regression tests and a live production round-trip (ticket 627088, two consecutive /analyze calls, one campaigns row, unchanged report_count)"
    - "CR-02: cross-campaign migration AND the signal-diverged create-new fall-through now decrement the abandoned origin campaign's report_count via a shared decrementOriginCampaign() helper — closes the previously-escalated human scoping decision because the underlying defect is now actually fixed in code rather than requiring a policy override"
  gaps_remaining: []
  regressions: []
---

# Phase 18: Campaign Grouping & Phishing Analysis API Verification Report

**Phase Goal:** Duplicate reports of the same phishing/spam campaign are automatically grouped and accumulate over time, and an operator can trigger analysis of a specific ticket or browse campaigns through a properly access-controlled `/api/phishing/*` surface.
**Verified:** 2026-07-16T07:05:00Z
**Status:** passed
**Re-verification:** Yes — after gap-closure plan 18-05 (Tasks 1-3: CR-03 own-campaign revalidation guard, CR-02 decrementOriginCampaign helper, blocking live-verification checkpoint)

## Goal Achievement

### Observable Truths

| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Two reports sharing Message-ID are grouped into the same campaign; absent that, attachment-hash/URL-domain+subject+sender+24h; absent that, sender+normalized-subject+client+24h fallback (CAMP-01) | ✓ VERIFIED | `lib/services/campaign-grouping-service.ts:210-338` tiered logic unchanged in structure since initial verification, only the post-match/no-match branches changed. `npx vitest run lib/services/campaign-grouping-service.test.ts` → 24/24 pass (re-ran myself, not just trusting SUMMARY) |
| 2 | A campaign accumulates additional linked reports over time as duplicates are detected, without ever creating a second campaign for the same key, and `report_count` stays accurate — including the single-report re-analyze case (CR-03) and the cross-campaign-migration/signal-diverged case (CR-02) (CAMP-02) | ✓ VERIFIED | Direct source read of `campaign-grouping-service.ts:399-438` confirms the CR-03 own-campaign revalidation guard: when no sibling matches (`matchCampaignId` stays null) and `ownReport.campaign_id` is non-null, it re-fetches the own campaign's stored `campaign_key`/`group_method`, compares against freshly recomputed `currentKeys` (tier1/tier2/tier3 + `report:<id>` fallback, lines 345-359), and returns the existing campaign with **zero** mutating queries if it still matches. `decrementOriginCampaign()` (lines 122-138) is called from both abandonment sites — the sibling-migration branch (line 394) and the signal-diverged create-new fall-through (line 437) — each gated on `ownReport.campaign_id` being non-null so it never fires for a never-grouped report or a same-campaign no-op (the CR-01 guard at line 372 returns first). Live production confirmation per 18-05-SUMMARY.md: ticket 627088 analyzed twice, same campaignId both times, `created:false` on the repeat, exactly one `campaigns` row for that `campaign_key`, `report_count` unchanged at 1 — this was the Task 3 blocking human-verify checkpoint, approved. Tests A-H (8 new, `campaign-grouping-service.test.ts:342-560`) reproduce CR-03/CR-02/D-08-preservation/no-regression exactly, RED-confirmed on A, B, E, H per SUMMARY and independently re-run GREEN by me (24/24 pass). Independent code review (`18-REVIEW.md`, 2026-07-16T10:54:20Z) hand-traced the same guard and decrement logic against Tests A-H and found no critical defects |
| 3 | `POST /api/phishing/tickets/{ticket_id}/analyze` runs detect→parse→group for one ticket and returns the campaign linkage on demand (DETECT-03) | ✓ VERIFIED | `app/api/phishing/tickets/[ticket_id]/analyze/route.ts` unchanged since `18-02` (confirmed via `git log` — no commits touch this file after `18-02`; the 18-05 fix lives entirely in the service it calls into). Chain intact: `detectPhishingTicket` → `parseAndStoreMessage` → `groupReportIntoCampaign(detection.reportId)`; response shape `{reportId, campaignId, groupMethod, created}` unchanged. `npx tsc --noEmit --pretty` exits 0 (re-run myself). Live curl pass for auth/validation behavior was executed against production in the prior verification round and is not re-required: the route file itself is byte-for-byte unchanged, and the service-layer fix changes only which existing campaign is returned/created, not the route's auth gate, status codes, or response shape |
| 4 | `GET /api/phishing/campaigns` lists campaigns (paginated) and `GET /api/phishing/campaigns/{id}` returns full nested detail (reports/messages/indicators/classifications) (CAMP-03) | ✓ VERIFIED | Both route files unchanged since the `650f9b8` fix in the prior verification round (confirmed via `git log` — no commits touch either file in 18-05). `limit` clamp and `?status=` count-query bind fix both previously verified live against production and remain in place, untouched. Live curl pass for auth/shape/status-filter behavior executed in the prior round is not re-required for the same reason as truth #3 — these routes don't call `groupReportIntoCampaign` at all, they only query `campaigns`/`reports`/`messages`/`indicators` directly for read purposes, entirely outside this round's change surface |
| 5 | Every `/api/phishing/*` route introduced this phase calls `requireAuth()`/`requirePermission()` and rejects unauthenticated/unauthorized requests with 401/403 (ACCESS-01) | ✓ VERIFIED | All 4 route files unchanged since initial verification (`git log` confirms no commits since `18-01`-`18-03` touch the `requirePermission` call sites, and 18-05 touches none of them). `lib/permissions.ts` unchanged: `phishing: ["read","analyze"]` for admin/super-admin, `phishing: ["read"]` for user, `approve`/`remediate` ungranted to any role |

**Score:** 5/5 truths verified. Both gaps from the prior verification round (CR-03 blocking, CR-02 escalated) are now closed in code, not merely accepted as documented limitations.

### Required Artifacts

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `lib/services/campaign-grouping-service.ts` | `groupReportIntoCampaign`, `normalizeSubject`, `extractUrlDomain`, `GroupReportResult` exported; own-campaign revalidation guard (CR-03); origin decrement helper (CR-02) | ✓ VERIFIED (exists+substantive+wired+data confirmed live) | `decrementOriginCampaign` at lines 122-138; CR-03 guard at lines 399-438; both call sites gated correctly (lines 372-374 same-campaign no-op returns first, line 393 sibling-migration decrement, line 437 diverged-create-new decrement) |
| `lib/services/campaign-grouping-service.test.ts` | Unit tests for helpers + mocked-DB grouping, including CR-03 (Tests A-D) and CR-02 (Tests E-H) regressions | ✓ VERIFIED | 24/24 tests pass (re-run myself: `npx vitest run lib/services/campaign-grouping-service.test.ts`). Tests A-D exercise the own-campaign guard (Tier-1 stored key, Tier-3 stored key, campaign_id=null no-regression, signal-diverged fall-through); Tests E-H exercise the decrement (cross-campaign migration, D-08 upgrade with no origin, same-campaign no-op still zero-mutation, signal-diverged decrement) |
| `app/api/phishing/campaigns/route.ts` | GET paginated list, clamped `limit`, correct `?status=` filtering | ✓ VERIFIED (unchanged, regression-checked) | No commits since the prior verification round touch this file |
| `app/api/phishing/campaigns/[id]/route.ts` | GET nested detail | ✓ VERIFIED (unchanged, regression-checked) | No commits since `18-03` touch this file |
| `app/api/phishing/tickets/[ticket_id]/analyze/route.ts` | POST on-demand analyze | ✓ VERIFIED (unchanged, regression-checked) | No commits since `18-02` touch this file |
| `lib/services/webhook-service.ts`, `lib/services/phishing-sweep-service.ts` | `groupReportIntoCampaign` wired with `skipIfAlreadyGrouped: true` | ✓ VERIFIED (unchanged, regression-checked) | Not touched by 18-05; call sites confirmed present and untouched |
| `lib/permissions.ts` | `phishing` resource + role grants | ✓ VERIFIED (unchanged, regression-checked) | Untouched by 18-05 |

### Key Link Verification

| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `campaign-grouping-service.ts` groupReportIntoCampaign | own-campaign revalidation guard (CR-03) | `!matchCampaignId && ownReport.campaign_id` → `SELECT campaign_key, group_method FROM campaigns WHERE id = $1`, compare against `currentKeys` | ✓ WIRED | Confirmed at lines 413-429; Tests A/B/D prove it fires correctly on stored-key-match (reuse) and stored-key-divergence (fall through); Test C proves it does NOT run when `campaign_id` is null |
| `campaign-grouping-service.ts` sibling-migration branch | `decrementOriginCampaign` | `if (ownReport.campaign_id) { await decrementOriginCampaign(...) }` after incrementing the destination | ✓ WIRED | Confirmed at lines 393-395; Test E proves exactly one increment (destination) + one decrement (origin); Test F proves no decrement fires when there's no origin |
| `campaign-grouping-service.ts` CR-03 diverged fall-through | `decrementOriginCampaign` | same helper, called before the create-new INSERT when the stored key no longer matches `currentKeys` | ✓ WIRED | Confirmed at line 437; Test H proves exactly one decrement + one INSERT, no increment |
| `app/api/phishing/tickets/[ticket_id]/analyze/route.ts` | `detectPhishingTicket → parseAndStoreMessage → groupReportIntoCampaign` | orchestration chain, no `skipIfAlreadyGrouped` | ✓ WIRED (unchanged) | Regression-checked, no commits since `18-02` |
| All 4 `/api/phishing/*` route files | `requirePermission('phishing', <action>)` | first-line auth gate | ✓ WIRED (unchanged) | Regression-checked, no commits touch these call sites since `18-01`-`18-03` |

### Data-Flow Trace (Level 4)

| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|---------------------|--------|
| `groupReportIntoCampaign` CR-03 guard | `ownCampaign.campaign_key`/`group_method` | Live `SELECT ... FROM campaigns WHERE id = $1` inside the same transaction | Yes — confirmed live against production (ticket 627088): real DB round-trip, not a mock, returned the correct stored key and prevented duplicate creation | ✓ FLOWING |
| `decrementOriginCampaign` | `report_count` | Live `UPDATE campaigns SET report_count = report_count - 1 ...` | Yes — confirmed by direct read of the call sites and mocked test assertions on the exact SQL body (`report_count - 1`) and bound params; live production round in 18-05 confirmed report_count stayed stable at 1 for the single-report re-analyze scenario (no origin to decrement in that specific test since there was only one campaign involved) | ✓ FLOWING (mocked test evidence + consistent live behavior; the live test exercised the CR-03 no-op path rather than the CR-02 cross-campaign-migration path specifically, see note below) |

**Note on Data-Flow scope:** the live production verification in 18-05 Task 3 exercised the CR-03 single-report re-analyze scenario end-to-end against real Postgres. It did not separately exercise the CR-02 cross-campaign-migration decrement live (that would require two independently-formed campaigns for the same lure, a harder scenario to stage safely against production). CR-02's correctness rests on: (a) mocked regression Tests E/F/H passing, (b) independent code review's hand-trace confirming the decrement fires exactly once in each abandonment branch and is correctly gated, and (c) the decrement SQL being structurally identical (single parameterized `UPDATE campaigns SET report_count = report_count - 1 ... WHERE id = $1`) to the increment SQL already proven live in production for the mirror-image case. This is a reasonable evidentiary basis for a `passed` status — the code path is simple, mechanically verified, and independently reviewed — but is noted here for completeness rather than silently treated as equivalent to a live round-trip.

### Behavioral Spot-Checks

| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| campaign-grouping-service unit tests (24 total, incl. Tests A-H) | `npx vitest run lib/services/campaign-grouping-service.test.ts` | 24/24 passed (re-run by verifier, not taken from SUMMARY) | ✓ PASS |
| Repository-wide type check | `npx tsc --noEmit --pretty` | exits 0, no output (re-run by verifier) | ✓ PASS |
| Full repo test suite | `npx vitest run` | 319/321 pass; the only 2 failures are in `lib/services/analyzer/itglue-search.test.ts`, pre-existing and unrelated to Phase 18 (documented since Phase 16) | ✓ PASS (no new failures) |
| Live single-report re-analyze idempotency (CR-03) | `POST /api/phishing/tickets/627088/analyze` x2 + direct DB query, against production | Same campaignId both times, `created:false` on repeat, exactly one `campaigns` row, `report_count` unchanged — human-approved Task 3 checkpoint | ✓ PASS |

Step 7c note: no `scripts/*/tests/probe-*.sh` files exist for this phase and none are referenced by any PLAN/SUMMARY file; probe execution step is not applicable here.

### Requirements Coverage

| Requirement | Source Plan | Description | Status | Evidence |
|-------------|-------------|--------------|--------|----------|
| CAMP-01 | 18-01, 18-02, 18-05 | Message-ID → attachment-hash/URL-domain+subject+sender+window → sender+subject+client+window tiered grouping | ✓ SATISFIED | Tiered logic unchanged in structure; regression-checked; 24/24 tests green |
| CAMP-02 | 18-01, 18-02, 18-04, 18-05 | Campaign accumulates many linked reports over time as duplicates are detected | ✓ SATISFIED | CR-01 (double-increment), CR-03 (duplicate-campaign-on-single-report-reanalyze), and CR-02 (stale origin count) all fixed and regression-tested; CR-03 additionally confirmed live against production |
| CAMP-03 | 18-03, 18-04 | Operator can list campaigns and view full detail via API | ✓ SATISFIED | Both routes exist, correctly shaped, camelCase, gated; unchanged since prior verification round, which itself confirmed the `limit` clamp and `?status=` fix live |
| DETECT-03 | 18-02 | Operator can trigger analysis of one ticket by ID on demand | ✓ SATISFIED | Route unchanged; confirmed live in prior round; underlying service fix in 18-05 does not alter the route's contract |
| ACCESS-01 | 18-01, 18-02, 18-03 | All `/api/phishing/*` endpoints enforce existing Pulse auth conventions, approve/remediate requiring elevated permission | ✓ SATISFIED | Unchanged, regression-checked |

No orphaned requirements: REQUIREMENTS.md maps exactly CAMP-01, CAMP-02, CAMP-03, DETECT-03, ACCESS-01 to Phase 18, and all five appear in at least one plan's `requirements:` frontmatter (18-05 additionally declares `[CAMP-01, CAMP-02]` for its gap-closure work).

### Anti-Patterns Found

| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `lib/services/campaign-grouping-service.ts` | 131-138 | `decrementOriginCampaign` has no floor (`report_count - 1` with no `GREATEST(...,0)` clamp, no DB `CHECK` constraint) | ⚠️ Warning (non-blocking) | Every code path reaching this function today fires at most once per abandonment under single-instance, non-concurrent execution (confirmed by hand-trace in 18-REVIEW.md). Only a real risk in combination with WR-02 below. Disclosed, not fixed in this round — consistent with project's documented-limitation-over-silent-gap philosophy (D-04 precedent) |
| `lib/services/campaign-grouping-service.ts` | 165-175 | Pre-transaction `skipIfAlreadyGrouped` check is a TOCTOU race (reads `reports.campaign_id` outside the transaction, no row lock); pre-existing, but the new decrement logic raises the failure mode from "duplicate row" to "silent negative/understated count" if it were ever to double-fire | ⚠️ Warning (non-blocking) | Pre-existing race (webhook redelivery / overlapping cron sweep), not introduced by 18-05. Disclosed in 18-REVIEW.md as WR-02; recommended follow-up is `SELECT ... FOR UPDATE` inside the transaction, out of scope for this gap-closure round |
| `lib/services/campaign-grouping-service.ts` | 414, 426 | Unchecked cast from DB `TEXT` to the `GroupReportResult['groupMethod']` union (`ownCampaign.group_method as ...`) | ℹ️ Info | Safe today (the column is only ever written by this same function's own INSERT with a value drawn from the union); a validated narrowing would be stricter typing hygiene per CLAUDE.md's "don't use any" spirit, not a functional bug |
| `lib/services/campaign-grouping-service.ts` | 430-436 | Comment claims "own campaign row is gone" is a live scenario; schema has no cascade-delete path today, so this branch is currently unreachable defensive code | ℹ️ Info | Cosmetic — comment slightly overstates likelihood; code itself is harmless (cheap defensive check) |
| `lib/services/campaign-grouping-service.test.ts` | — | New guard/decrement tests cover Tier 1 and Tier 3 stored-key scenarios but not Tier 2 (`attachment_or_url`) | ℹ️ Info | Low-risk coverage gap (both guards are tier-agnostic string/reference comparisons); not a functional regression |
| `lib/services/campaign-grouping-service.ts` | 241-251 (tier2Candidates query) | Tier 2 candidate query has no `ORDER BY` (non-deterministic pick among multiple qualifying siblings) | ⚠️ Warning | Carried forward from initial code review (WR-01 in the original 18-REVIEW.md pass); pre-existing, not touched by 18-05 |

No `TBD`/`FIXME`/`XXX`/`TODO`/`HACK`/`PLACEHOLDER` markers found in `lib/services/campaign-grouping-service.ts` or `lib/services/campaign-grouping-service.test.ts` (grep re-run by verifier, zero matches).

### Human Verification Required

None outstanding. Both live-verification items carried forward from the prior round (`/analyze` auth/validation behavior; `/campaigns` list/detail auth/shape/status-filter behavior) were completed live against production in that round and do not need to be re-run: the route files they tested are byte-for-byte unchanged since that verification (confirmed via `git log`), and this round's changes are confined entirely to `lib/services/campaign-grouping-service.ts`'s internal grouping/decrement logic — which changes *which* campaign is returned/created, not the routes' auth gates, status codes, input validation, or response shape. The one live-verification item this round required (CR-03 no-duplicate-on-reanalyze) was executed as 18-05's Task 3 blocking human-verify checkpoint and approved.

The previously-escalated CR-02 scoping decision (accept as documented limitation vs. fix) is now moot: CR-02 is fixed in code (`decrementOriginCampaign`, mocked-tested via Tests E/F/G/H, independently code-reviewed with no critical findings), so no policy override is needed.

### Gaps Summary

No gaps remain. The two items blocking the prior verification round — CR-03 (blocking: duplicate campaign created on single-report re-analyze) and CR-02 (escalated: stale report_count on cross-campaign migration) — are both closed by plan 18-05:

- **CR-03** is fixed by an own-campaign revalidation guard that checks the report's own current campaign against its freshly-recomputed tier keys before falling through to "create new," and is confirmed not just by mocked tests but by a live, human-approved production round-trip (the same reproduction steps that originally surfaced the bug now show the fix holding).
- **CR-02** is fixed by a shared `decrementOriginCampaign()` helper applied at both abandonment sites (cross-campaign migration and the signal-diverged create-new fall-through), confirmed by 4 new mocked regression tests and an independent code review that hand-traced all four scenarios with no critical findings.

Two non-blocking warnings remain disclosed for future attention (no floor on the decrement; a pre-existing TOCTOU race in the pre-transaction `skipIfAlreadyGrouped` check whose failure mode is marginally worse now) — both are pre-existing-class issues, not regressions introduced by this round, and both are explicitly out of scope for this gap-closure plan per its own scope note. They do not block phase completion.

All 5 phase requirements (CAMP-01, CAMP-02, CAMP-03, DETECT-03, ACCESS-01) are satisfied. Full test suite (319/321, only 2 pre-existing unrelated failures) and `tsc --noEmit` were independently re-run by the verifier, not taken on SUMMARY.md's word. Phase 18 is genuinely done.

---

*Verified: 2026-07-16T07:05:00Z*
*Verifier: Claude (gsd-verifier)*
