---
phase: 18-campaign-grouping-phishing-analysis-api
reviewed: 2026-07-16T10:54:20Z
depth: standard
files_reviewed: 2
files_reviewed_list:
  - lib/services/campaign-grouping-service.ts
  - lib/services/campaign-grouping-service.test.ts
findings:
  critical: 0
  warning: 2
  info: 3
  total: 5
status: issues_found
---

# Phase 18: Code Review Report (re-review, supersedes prior 18-REVIEW.md for these 2 files)

**Reviewed:** 2026-07-16T10:54:20Z
**Depth:** standard
**Files Reviewed:** 2
**Status:** issues_found (no Critical findings; both gap-closure fixes confirmed correct)

## Summary

Re-reviewed `campaign-grouping-service.ts` and its test file fresh, focused on the two gap-closure fixes from plan 18-05 (CR-03 own-campaign revalidation guard, CR-02 `decrementOriginCampaign` helper) and the shared tier-key-computation refactor that supports both. This review scope is 2 files (`campaign-grouping-service.ts`/`.test.ts`) — the prior `18-REVIEW.md`'s `app/api/phishing/campaigns/route.ts` findings (its own CR-01/CR-02) are out of scope here and not re-verified in this pass; this report replaces the prior document's content for the 2 files listed above only.

**CR-03 (own-campaign revalidation guard, `campaign-grouping-service.ts:399-438`):** traced correctly. Every tier query self-excludes the report's own row, so a single-report campaign (no sibling exists yet) always reaches this block with `matchCampaignId === null`. The guard recomputes `currentKeys` (tier1/tier2/tier3 + a `report:<id>` fallback, always deterministic per report — filtered non-null at `:357-359`) and checks whether the report's *own* stored `campaign_key` is still one of them. If so, it returns the existing campaign with zero mutating queries. Verified against Test A (Tier-1 stored key reused), Test B (Tier-3 stored key reused), Test C (never-grouped, guard correctly doesn't fire), and Test D (signal diverged, correctly falls through to create-new). Hand-traced additional edge cases not in the suite — empty signal falling back to the `report:<id>` key on both sides (always self-consistent since it's derived from `reportId` alone), and a stale/diverged stored key always being excluded from `currentKeys` by construction (so the diverged-fallthrough path can never collide with the abandoned key) — both consistent, no hole found.

**CR-02 (`decrementOriginCampaign`, `:122-138`):** correctly shared by both abandonment sites — the sibling-migration branch (`:393-395`) and the CR-03 signal-diverged fall-through (`:437`) — and correctly gated so it never fires on a same-campaign no-op match (the CR-01 guard at `:372-374` returns before either call site is reached) or on a never-grouped report (both call sites are guarded by `ownReport.campaign_id` being truthy). Verified against Test E (cross-campaign migration: decrement origin + increment destination, exactly one each), Test F (never-grouped upgrade: increment only, no decrement), Test G (same-campaign re-match: no mutation at all — confirms the guard interposes correctly ahead of the decrement), and Test H (signal-diverged create-new: decrement origin + insert new campaign, no increment). All four scenarios traced correctly through the code and match the described production confirmation (ticket 627088 analyzed twice, single `campaigns` row, unchanged `report_count`).

No Critical-severity defects were introduced by this round's changes. Two Warnings and three Info-level items below are worth addressing for robustness/precision but do not block shipping given the already-documented scope limits (D-04, Pitfall 2, and the CR-02 scope note in the code itself).

## Warnings

### WR-01: `decrementOriginCampaign` has no floor — `report_count` can go negative

**File:** `lib/services/campaign-grouping-service.ts:131-138`
**Issue:** The decrement is unconditional (`report_count = report_count - 1`) with no `GREATEST(...)` clamp, and there is no DB-level `CHECK (report_count >= 0)` constraint (`migrations/097_phishing_triage_schema.sql:32` — plain `INTEGER NOT NULL DEFAULT 0`). Every path in this file that reaches `decrementOriginCampaign` is, by hand-trace, reachable at most once per abandonment under single-instance, non-concurrent execution — so this is safe today. But that guarantee rests entirely on there being no concurrent double-fire of the same grouping call (see WR-02). If a race — or a future caller — ever invokes this twice for the same origin campaign, the count silently goes negative with no defensive floor and nothing downstream would catch it.
**Fix:**
```sql
UPDATE campaigns
   SET report_count = GREATEST(report_count - 1, 0), updated_at = NOW()
 WHERE id = $1
```

### WR-02: Pre-transaction `skipIfAlreadyGrouped` check is a TOCTOU race, and the new decrement logic raises its stakes

**File:** `lib/services/campaign-grouping-service.ts:165-175`
**Issue:** The `skipIfAlreadyGrouped` short-circuit reads `reports.campaign_id` via a plain `postgresClient.query` *outside* the transaction, before `BEGIN` (transaction body starts at `:175`). If the webhook or cron sweep fires twice in quick succession for the same report (webhook redelivery, or overlapping cron sweep ticks — both real call sites: `webhook-service.ts:493`, `phishing-sweep-service.ts:91`), both invocations can observe `campaign_id IS NULL` and both proceed into their own transaction. Neither transaction takes a row lock on the `reports` row or the candidate `campaigns` row, so both can independently reach the same tier match. This race pre-dates this round (already flagged as a known limitation via the "Pitfall 2 — no UNIQUE constraint on `campaign_key`" comment), but the new `decrementOriginCampaign` logic makes a concrete double-fire strictly worse than before: previously a double-fire risked only a duplicate `campaigns` row (visible/annoying); now a double-fire on the migration or CR-03-diverged branch can double-decrement an origin campaign's count, silently under-reporting it (compounded by WR-01's missing floor).
**Fix:** Out of scope to fully close here (would need `SELECT ... FOR UPDATE` on the `reports` row inside the transaction, or an advisory lock keyed on `reportId`), but worth a follow-up given the new decrement path raises the impact of the existing race. At minimum, consider re-reading `reports.campaign_id` with `FOR UPDATE` as the first statement inside the transaction (`:176-181`) so a concurrent second call blocks until the first commits, instead of relying solely on the non-transactional outer pre-check.

## Info

### IN-01: Unchecked cast from DB `TEXT` to the `groupMethod` union

**File:** `lib/services/campaign-grouping-service.ts:414, 426`
**Issue:** `ownCampaignRes` is typed `{ campaign_key: string; group_method: string }`, and the return value casts it with `ownCampaign.group_method as GroupReportResult['groupMethod']` — no runtime check that the stored value is actually one of `'message_id' | 'attachment_or_url' | 'sender_subject_client'`. In practice the column is only ever written by this same function's own `INSERT` (`:460-465`) with a value drawn from the union, so it's safe today, but it's an unchecked type assertion rather than a validated narrowing — the kind of blind cast CLAUDE.md's "don't use `any` — use specific types" guidance is meant to discourage in spirit.
**Fix:**
```ts
const VALID_GROUP_METHODS = new Set(['message_id', 'attachment_or_url', 'sender_subject_client']);
function asGroupMethod(v: string): GroupReportResult['groupMethod'] {
  if (!VALID_GROUP_METHODS.has(v)) throw new Error(`Unexpected group_method value: ${v}`);
  return v as GroupReportResult['groupMethod'];
}
```

### IN-02: CR-02 comment describes an "own campaign row is gone" scenario that the current schema makes unreachable

**File:** `lib/services/campaign-grouping-service.ts:430-436`
**Issue:** The comment states "own campaign row is gone, or the report's signal has genuinely diverged" as two alternative reasons the guard's `if (ownCampaign && ...)` can fail. Checked against the schema: `reports.campaign_id` is `UUID REFERENCES campaigns(id)` with no `ON DELETE` clause (`migrations/097_phishing_triage_schema.sql:67`), and there is no `DELETE FROM campaigns` anywhere in the codebase today (confirmed via repo-wide grep) — so a referenced `campaigns` row cannot currently be deleted out from under a report, and `ownCampaignRes.rows[0]` will always be present whenever `ownReport.campaign_id` is non-null. The defensive `ownCampaign &&` check is harmless to keep (cheap insurance against a future admin/cleanup script), but the comment overstates a currently-live code path as an expected runtime scenario.
**Fix:** Tighten the comment to note the "row is gone" branch is currently unreachable defensive code (no cascade delete, nothing deletes `campaigns` rows yet) rather than implying there's a live repro path for it — avoids a future reader assuming this needs its own test/repro.

### IN-03: Test suite doesn't cover the CR-01/CR-03 guards via a Tier 2 match, only Tier 1 and Tier 3

**File:** `lib/services/campaign-grouping-service.test.ts:342-424`
**Issue:** Test G (CR-01/CAMP-02 same-campaign no-op guard) and Tests A/B (CR-03 own-campaign revalidation) are covered for the Tier-3 same-campaign case (Test G) and the Tier-1/Tier-3 stored-key cases (A/B) respectively, but no test exercises either guard when the match/stored key is the Tier-2 (`attachment_or_url`) key. Both guards are generic (plain string/reference equality independent of which tier produced the value), so this is a low-risk coverage gap rather than a suspected bug — the existing 12-test suite simply doesn't touch this one branch combination.
**Fix:** Optional: add a Tier-2 variant of Test G and Test A for completeness, reusing the Tier-2 indicator fixture shape already present in the "extracts URL-domain" test.

---

_Reviewed: 2026-07-16T10:54:20Z_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: standard_
