diff --git a/.planning/phases/18-campaign-grouping-phishing-analysis-api/18-05-SUMMARY.md b/.planning/phases/18-campaign-grouping-phishing-analysis-api/18-05-SUMMARY.md new file mode 100644 index 0000000..9c3b687 --- /dev/null +++ b/.planning/phases/18-campaign-grouping-phishing-analysis-api/18-05-SUMMARY.md @@ -0,0 +1,120 @@ +--- +phase: 18-campaign-grouping-phishing-analysis-api +plan: 05 +subsystem: api +tags: [postgres, vitest, campaign-grouping, phishing, idempotency, gap-closure] + +# Dependency graph +requires: + - phase: 18-01 + provides: groupReportIntoCampaign tiered matching (CAMP-01/CAMP-02) and campaigns schema (migration 097) + - phase: 18-04 + provides: reports.campaign_id on OwnReportRow and the same-campaign no-op guard this plan extends +provides: + - "CR-03 fix: own-campaign revalidation guard in groupReportIntoCampaign — re-analyzing a single-report campaign reuses the report's own existing campaign instead of creating a duplicate campaigns row with the same campaign_key" + - "CR-02 fix: decrementOriginCampaign() helper, called on cross-campaign migration AND on the signal-diverged create-new fall-through, so an abandoned origin campaign's report_count is never left stale" + - "Mocked regression tests (Tests A-H) reproducing both CR-03 and CR-02 exactly, with RED confirmed for Tests A, B, E, H against pre-fix code" +affects: [19-classification, 20-remediation-approval-audit] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Own-signal tier keys (tier1Key/tier2Key/tier3Key/currentKeys) computed once, shared between the own-campaign revalidation guard and the create-new fall-through" + - "Shared decrementOriginCampaign(client, campaignId) helper called from both abandonment sites (sibling-migration branch and signal-diverged create-new) to avoid duplicating the UPDATE statement" + +key-files: + created: [] + modified: + - lib/services/campaign-grouping-service.ts + - lib/services/campaign-grouping-service.test.ts + +key-decisions: + - "Own-campaign guard runs ONLY when matchCampaignId is null (no sibling matched) AND ownReport.campaign_id is non-null — placed after the sibling-match branch, before create-new, so it never suppresses a legitimate D-08 sibling upgrade" + - "CR-02 decrement is scope-limited to decrement-only — never deletes an origin campaign even if report_count reaches 0 (FK-cascade/detail-route risk deferred to a later phase per plan's explicit scope note)" + - "CR-02 decrement applied in BOTH abandonment sites (existing sibling-migration branch AND Task 1's signal-diverged create-new fall-through) per plan-checker guidance — not just the migration branch" + +requirements-completed: [CAMP-01, CAMP-02] + +# Metrics +duration: n/a (partial — Task 3 checkpoint pending) +completed: null +--- + +# Phase 18 Plan 05: Campaign-grouping gap closure (CR-03 + CR-02) Summary — PARTIAL (Tasks 1-2 complete, Task 3 checkpoint pending) + +**Own-campaign revalidation guard closes CR-03 (duplicate campaigns on single-report re-analyze); shared decrementOriginCampaign() helper closes CR-02 (stale report_count on migration and signal-diverged create-new) — both proven by 8 new mocked vitest regression tests with confirmed RED on Tests A, B, E, H.** + +**STATUS: Tasks 1 and 2 are complete and committed. Task 3 (BLOCKING human-verify checkpoint — live database verification) has NOT yet run. This summary will be superseded/finalized once Task 3 resolves.** + +## Performance + +- **Tasks completed:** 2 of 3 (Task 3 is the blocking checkpoint, pending) +- **Files modified:** 2 (`lib/services/campaign-grouping-service.ts`, `lib/services/campaign-grouping-service.test.ts`) + +## Accomplishments + +- **CR-03 closed (code + mocked tests):** re-analyzing a single-report campaign (no sibling report exists yet) now reuses the report's own existing `campaign_id` with `created:false` — zero `INSERT`/`UPDATE` queries issued. Root cause was every tier query's required self-exclusion (`r.id != $x`) meaning a lone report could never match itself, so `matchCampaignId` stayed null and control fell straight into "create new campaign" on every repeat `/analyze` call. +- **CR-02 closed (code + mocked tests):** cross-campaign migration (report moves from campaign A to different existing campaign B) now decrements A's `report_count` in addition to incrementing B's. The identical decrement is also applied when the signal-diverged create-new fall-through abandons an origin campaign in favor of a brand-new one (the plan-checker-flagged second abandonment site). +- **D-08 preserved:** a report with a genuinely better sibling match still upgrades to that sibling's campaign; the own-campaign guard only runs when no sibling matched, and the CR-02 decrement correctly does not fire when there is no origin campaign (never-grouped report). +- **No regression:** first-time grouping of a genuinely new report (`campaign_id = null`) still creates exactly one campaign; the existing 18-04 same-campaign no-op test (renamed here to "Test G") still fires zero mutations. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Own-campaign revalidation guard before create-new (CR-03)** — `633b48c` (fix) +2. **Task 2: Decrement origin campaign report_count on migration AND signal-diverged create-new (CR-02)** — `6945591` (fix) +3. **Task 3: Live database verification of no-duplicate-on-reanalyze** — NOT YET RUN (blocking human-verify checkpoint) + +_Both Task 1 and Task 2 followed TDD RED-before-GREEN discipline: RED confirmed via `npx vitest run` for Tests A/B (Task 1) and Tests E/H (Task 2) against the pre-fix code, then GREEN confirmed after implementing the fix._ + +## Files Created/Modified + +- `lib/services/campaign-grouping-service.ts` — added `decrementOriginCampaign()` helper; hoisted `tier1Key`/`tier2Key`/`tier3Key`/`currentKeys` computation above the find-or-create block; added the CR-03 own-campaign revalidation guard between the sibling-match branch and the create-new fall-through; added the CR-02 decrement call in both the sibling-migration branch and the signal-diverged create-new fall-through +- `lib/services/campaign-grouping-service.test.ts` — added `ownCampaign` to `MockRows` + a new SQL-substring router branch (`SELECT campaign_key`); added Tests A-D (CR-03) and Tests E-H (CR-02, with the pre-existing 18-04 same-campaign no-op test relabeled as "Test G") + +## Decisions Made + +- Own-campaign guard placed **outside** (after) the sibling-match branch, not merged into it — the plan required this to guarantee it never suppresses a legitimate D-08 sibling upgrade, since the guard must only run when `matchCampaignId` is null. +- Factored the CR-02 decrement into a single shared `decrementOriginCampaign(client, campaignId)` helper rather than duplicating the raw `UPDATE campaigns SET report_count = report_count - 1 ...` statement at both call sites — reduces duplication while keeping each call site's guard condition (`if (ownReport.campaign_id)`) explicit and readable at the call site. +- Test G (the pre-existing 18-04 regression test) was renamed/annotated in place rather than duplicated, since its assertions already fully satisfy the plan's Test G requirement (same-campaign re-match remains a zero-mutation no-op). + +## Deviations from Plan + +**1. [Rule 1 - Bug, self-caught during GREEN] Missing null-guard on the CR-02 decrement in the migration branch** + +- **Found during:** Task 2, first GREEN attempt +- **Issue:** The initial implementation called `decrementOriginCampaign(client, ownReport.campaign_id)` unconditionally after the increment/re-link in the sibling-migration branch, without checking `ownReport.campaign_id` was non-null first. This broke the existing "Tier 3 match increments..." regression test and the new Test F (D-08 upgrade with no prior campaign) — both scenarios have `ownReport.campaign_id` as `undefined`/`null` (a never-grouped report), and the unconditional call issued a spurious second `UPDATE campaigns` with `campaignId = undefined` as the bound parameter. +- **Fix:** Wrapped the call in `if (ownReport.campaign_id) { await decrementOriginCampaign(...); }`, matching the plan's explicit requirement that the decrement only fires "when there is no origin (campaign_id=null) or when the match resolves to the report's own campaign" → no decrement. +- **Files modified:** `lib/services/campaign-grouping-service.ts` +- **Verification:** Re-ran `npx vitest run lib/services/campaign-grouping-service.test.ts` — all 24 tests passed (previously 2 failing: the pre-existing Tier-3-match test and new Test F). +- **Committed in:** `6945591` (part of Task 2 commit — caught and fixed before the commit was made, not a separate follow-up commit) + +--- + +**Total deviations:** 1 auto-fixed (Rule 1 — bug caught during the plan's own TDD GREEN verification step, not a deviation from the plan's design) +**Impact on plan:** No scope creep — this was a bug in my own first-draft implementation of the plan's specified behavior, caught by the plan's own Test F before committing. Zero impact on the plan's intended design. + +## Issues Encountered + +None beyond the self-caught deviation above. `npx tsc --noEmit --pretty` exits 0 after both tasks; `npx vitest run lib/services/campaign-grouping-service.test.ts` is green with all 24 tests (16 pre-existing + Tests A-H new). + +## User Setup Required + +None — no external service configuration required. Task 3 requires a human to run a live verification against a real Postgres database (dev instance or a cleaned-up production throwaway ticket); this is not "setup" but the plan's mandatory human-verification checkpoint, detailed in `18-05-PLAN.md` Task 3's `` block. + +## Next Phase Readiness + +**NOT READY — Task 3 (blocking human-verify checkpoint) has not run.** Per the plan's `coverage_caveat`: mocked-only unit coverage was proven insufficient last time (the 18-04 mocked regression test passed while CR-03 shipped and reproduced live in production), so this gap-closure plan is explicitly not considered closed until a human confirms, against a real database: + +1. A second consecutive `/analyze` on the same single-report ticket returns the same `campaignId` with `created:false` (not a new campaign, not `created:true`). +2. A direct DB query shows exactly one `campaigns` row for that `campaign_key` with an unchanged `report_count`. +3. Any test data created during verification is cleaned up. + +This worktree's code (commits `633b48c` and `6945591`) is ready for that live verification. No further code changes are anticipated unless the live verification surfaces a divergence from the mocked tests' assumptions. + +--- +*Phase: 18-campaign-grouping-phishing-analysis-api* +*Plan: 05* +*Status: PARTIAL — Tasks 1-2 complete, Task 3 checkpoint pending human live-verification* diff --git a/lib/services/campaign-grouping-service.test.ts b/lib/services/campaign-grouping-service.test.ts index b507662..dcb868c 100644 --- a/lib/services/campaign-grouping-service.test.ts +++ b/lib/services/campaign-grouping-service.test.ts @@ -70,6 +70,8 @@ interface MockRows { tier2Candidates?: unknown[]; candidateIndicators?: unknown[]; tier3?: unknown[]; + /** CR-03: rows for the own-campaign revalidation SELECT (campaigns WHERE id = ownReport.campaign_id). */ + ownCampaign?: unknown[]; insertCampaign?: unknown[]; } @@ -102,6 +104,9 @@ function makeClient(rows: MockRows) { if (sql.includes('BETWEEN $4::timestamptz')) { return { rows: rows.tier3 ?? [], rowCount: rows.tier3?.length ?? 0 }; } + if (sql.includes('SELECT campaign_key')) { + return { rows: rows.ownCampaign ?? [], rowCount: rows.ownCampaign?.length ?? 0 }; + } if (sql.includes('UPDATE campaigns')) { return { rows: [], rowCount: 1 }; } @@ -334,7 +339,7 @@ describe('groupReportIntoCampaign', () => { expect(callsContaining('UPDATE campaigns')).toHaveLength(0); }); - it('re-analyzing a report already linked to a campaign that a sibling report re-matches does not increment report_count a second time', async () => { + it('Test G (CR-02 guard against over-firing): same-campaign re-match remains a pure no-op — the origin decrement must NOT fire when the match resolves back to the report\'s own campaign', async () => { // Own report is already linked to campaign-1. A SIBLING report (not this // report's own excluded row) also belongs to campaign-1 and matches via // Tier 3 (sender + subject + client). This is the CAMP-02/CR-01 gap: @@ -360,4 +365,194 @@ describe('groupReportIntoCampaign', () => { expect(callsContaining('INSERT INTO campaigns')).toHaveLength(0); expect(callsContaining('UPDATE reports SET campaign_id')).toHaveLength(0); }); + + // =========================================================================== + // CR-03 (gap closure, 18-05 Task 1): own-campaign revalidation guard. + // + // Root cause: every tier query above self-excludes the report's OWN row + // (`r.id != $x`). For a single-report campaign (no sibling report exists + // yet), all three tiers return zero rows on re-analyze, matchCampaignId + // stays null, and — pre-fix — control fell straight into "create new + // campaign", producing a SECOND campaigns row with the identical + // campaign_key. Tests A/B below reproduce that exact scenario and FAILED + // against the pre-fix code (confirmed during RED). + // =========================================================================== + + it('Test A (CR-03 core): single-report re-analyze via Tier-1 stored key reuses the report\'s own existing campaign with zero mutating queries', async () => { + stage({ + ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-own' }], + ownMessage: [{ id: 'own-msg-id', message_id: 'lone-message-id' }], + tier1: [], // no sibling shares this message_id — single-report campaign + ownIndicators: [], + tier2Candidates: [], + tier3: [], + ownCampaign: [{ campaign_key: 'message_id:lone-message-id', group_method: 'message_id' }], + }); + + const result = await groupReportIntoCampaign('report-own-a'); + + expect(result).toEqual({ + campaignId: 'campaign-own', + groupMethod: 'message_id', + created: false, + }); + expect(callsContaining('INSERT INTO campaigns')).toHaveLength(0); + expect(callsContaining('UPDATE campaigns')).toHaveLength(0); + expect(callsContaining('UPDATE reports SET campaign_id')).toHaveLength(0); + }); + + it('Test B (CR-03 Tier-3-only variant): single-report re-analyze via Tier-3 stored key reuses the report\'s own existing campaign with zero mutating queries', async () => { + stage({ + ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-own' }], + ownMessage: [], + tier3: [], + ownCampaign: [ + { campaign_key: 'sender_subject_client:5:invoice alert:10', group_method: 'sender_subject_client' }, + ], + }); + + const result = await groupReportIntoCampaign('report-own-b'); + + expect(result).toEqual({ + campaignId: 'campaign-own', + groupMethod: 'sender_subject_client', + created: false, + }); + expect(callsContaining('INSERT INTO campaigns')).toHaveLength(0); + expect(callsContaining('UPDATE campaigns')).toHaveLength(0); + expect(callsContaining('UPDATE reports SET campaign_id')).toHaveLength(0); + }); + + it('Test C (no regression): campaign_id=null still creates exactly one campaign and never runs the own-campaign guard', async () => { + stage({ + ownReport: [REPORT_ROW], // no campaign_id field => null/undefined, never grouped + ownMessage: [], + tier3: [], + insertCampaign: [{ id: 'brand-new-campaign' }], + }); + + const result = await groupReportIntoCampaign('report-own-c'); + + expect(result).toEqual({ + campaignId: 'brand-new-campaign', + groupMethod: 'sender_subject_client', + created: true, + }); + expect(callsContaining('INSERT INTO campaigns')).toHaveLength(1); + expect(callsContaining('UPDATE campaigns')).toHaveLength(0); + // The own-campaign guard must not fire when there is no own campaign_id. + expect(callsContaining('SELECT campaign_key')).toHaveLength(0); + }); + + it('Test D (signal genuinely diverged): stored own-campaign key no longer matches current signal, falls through to create-new', async () => { + stage({ + ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-own' }], + ownMessage: [], + tier3: [], + ownCampaign: [ + { campaign_key: 'sender_subject_client:5:old subject:10', group_method: 'sender_subject_client' }, + ], + insertCampaign: [{ id: 'diverged-new-campaign' }], + }); + + const result = await groupReportIntoCampaign('report-own-d'); + + expect(result).toEqual({ + campaignId: 'diverged-new-campaign', + groupMethod: 'sender_subject_client', + created: true, + }); + expect(callsContaining('INSERT INTO campaigns')).toHaveLength(1); + }); + + // =========================================================================== + // CR-02 (gap closure, 18-05 Task 2): decrement the origin campaign's + // report_count on cross-campaign migration, AND on the signal-diverged + // create-new fall-through added by Task 1 (CR-03) above — both are the + // same "abandoning an origin campaign" situation, just landing on an + // existing sibling's campaign vs. a brand-new one. Tests E and H below + // FAILED against the pre-Task-2 code (confirmed during RED). + // =========================================================================== + + it('Test E (CR-02 migration decrements origin): cross-campaign migration decrements the origin and increments the destination exactly once each', async () => { + stage({ + ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-A' }], + ownMessage: [], + tier3: [{ campaign_id: 'campaign-B', title: 'Invoice Alert' }], + }); + + const result = await groupReportIntoCampaign('report-e'); + + expect(result).toEqual({ + campaignId: 'campaign-B', + groupMethod: 'sender_subject_client', + created: false, + }); + + const updateCampaignCalls = callsContaining('UPDATE campaigns'); + expect(updateCampaignCalls).toHaveLength(2); + const incrementCall = updateCampaignCalls.find((c) => c.sql.includes('report_count + 1')); + const decrementCall = updateCampaignCalls.find((c) => c.sql.includes('report_count - 1')); + expect(incrementCall?.params).toEqual(['campaign-B']); + expect(decrementCall?.params).toEqual(['campaign-A']); + + const updateReportCalls = callsContaining('UPDATE reports SET campaign_id'); + expect(updateReportCalls).toHaveLength(1); + expect(updateReportCalls[0].params).toEqual(['campaign-B', 'report-e']); + }); + + it('Test F (D-08 upgrade preserved, no origin to decrement): a never-grouped report upgrading to a sibling campaign increments the destination only', async () => { + stage({ + ownReport: [REPORT_ROW], // campaign_id null/undefined — never grouped before + ownMessage: [{ id: 'own-msg-id', message_id: 'shared-message-id-upgrade' }], + tier1: [{ campaign_id: 'campaign-B' }], + }); + + const result = await groupReportIntoCampaign('report-f'); + + expect(result).toEqual({ + campaignId: 'campaign-B', + groupMethod: 'message_id', + created: false, + }); + + const updateCampaignCalls = callsContaining('UPDATE campaigns'); + expect(updateCampaignCalls).toHaveLength(1); + expect(updateCampaignCalls[0].sql).toContain('report_count + 1'); + expect(updateCampaignCalls[0].params).toEqual(['campaign-B']); + + const updateReportCalls = callsContaining('UPDATE reports SET campaign_id'); + expect(updateReportCalls).toHaveLength(1); + expect(updateReportCalls[0].params).toEqual(['campaign-B', 'report-f']); + }); + + it('Test H (plan-checker-flagged divergence case): signal-diverged create-new also decrements the abandoned origin campaign exactly once', async () => { + stage({ + ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-A' }], + ownMessage: [], + tier3: [], + ownCampaign: [ + { campaign_key: 'sender_subject_client:5:old subject:10', group_method: 'sender_subject_client' }, + ], + insertCampaign: [{ id: 'diverged-new-campaign-h' }], + }); + + const result = await groupReportIntoCampaign('report-h'); + + expect(result).toEqual({ + campaignId: 'diverged-new-campaign-h', + groupMethod: 'sender_subject_client', + created: true, + }); + + expect(callsContaining('INSERT INTO campaigns')).toHaveLength(1); + const updateCampaignCalls = callsContaining('UPDATE campaigns'); + expect(updateCampaignCalls).toHaveLength(1); + expect(updateCampaignCalls[0].sql).toContain('report_count - 1'); + expect(updateCampaignCalls[0].params).toEqual(['campaign-A']); + + const updateReportCalls = callsContaining('UPDATE reports SET campaign_id'); + expect(updateReportCalls).toHaveLength(1); + expect(updateReportCalls[0].params).toEqual(['diverged-new-campaign-h', 'report-h']); + }); }); diff --git a/lib/services/campaign-grouping-service.ts b/lib/services/campaign-grouping-service.ts index ee3a2d1..e11a59d 100644 --- a/lib/services/campaign-grouping-service.ts +++ b/lib/services/campaign-grouping-service.ts @@ -20,6 +20,7 @@ * Tier 3 (sender + normalized subject + client + 24h window). */ +import type { PoolClient } from 'pg'; import { postgresClient } from './postgres-client'; // ============================================================================= @@ -118,6 +119,24 @@ function computeTier3Key( return `sender_subject_client:${requesterContactId}:${normalizedSubject}:${companyId}`; } +/** + * CR-02: decrements a now-abandoned origin campaign's report_count. Shared + * by both places a report can be abandoned from its origin campaign — the + * sibling-migration branch (moving to a DIFFERENT existing campaign) and the + * signal-diverged create-new fall-through (moving to a brand-new campaign) — + * so neither is left with a stale, inflated count. Decrement only; never + * deletes the origin row even if its count reaches 0 (out of scope — see + * CR-02 scope note at the call sites). + */ +async function decrementOriginCampaign(client: PoolClient, originCampaignId: string): Promise { + await client.query( + `UPDATE campaigns + SET report_count = report_count - 1, updated_at = NOW() + WHERE id = $1`, + [originCampaignId] + ); +} + /** * Shared entry point called by the webhook path, the cron sweep, and the * on-demand `/analyze` route. Looks up the report's own signal (message, @@ -318,6 +337,27 @@ export async function groupReportIntoCampaign( } } + // --------------------------------------------------------------------- + // Own-signal tier keys, computed once here and reused by both the + // own-campaign revalidation guard (CR-03, below) and the create-new + // fall-through further down — avoids computing them twice. + // --------------------------------------------------------------------- + const tier1Key = computeTier1Key(ownMessage?.message_id ?? null); + const tier2Key = computeTier2Key( + ownAttachmentHashes, + ownUrlDomains, + normalizedSubject, + ownSenderValue + ); + const tier3Key = computeTier3Key( + ownReport.requester_contact_id, + normalizedSubject, + ownReport.company_id + ); + const currentKeys = [tier1Key, tier2Key, tier3Key, `report:${reportId}`].filter( + (k): k is string => k !== null + ); + // --------------------------------------------------------------------- // Find-or-create against `campaigns` (CAMP-02). // --------------------------------------------------------------------- @@ -342,26 +382,65 @@ export async function groupReportIntoCampaign( `UPDATE reports SET campaign_id = $1, updated_at = NOW() WHERE id = $2`, [matchCampaignId, reportId] ); + // CR-02: if the report had a DIFFERENT prior campaign (the + // same-campaign case returned above; a never-grouped report has no + // origin — D-08), decrement it so it doesn't keep a permanently + // stale report_count for a report it no longer holds. Scope: + // decrement only, never delete the origin row even if its count + // reaches 0 (deleting introduces FK-cascade/detail-route risk out of + // scope for this gap fix; an empty campaign is a strictly lesser + // problem than a stale count). + if (ownReport.campaign_id) { + await decrementOriginCampaign(client, ownReport.campaign_id); + } return { campaignId: matchCampaignId, groupMethod: matchGroupMethod, created: false }; } + // --------------------------------------------------------------------- + // CR-03: no sibling matched on any tier (matchCampaignId is null). + // Every tier query above self-excludes the report's own row + // (`r.id != $x`), so a single-report campaign (no sibling report + // exists yet — the common case for any brand-new phishing lure before + // a second person reports it) always reaches here with matchCampaignId + // still null. Pre-fix, control fell straight into "create new + // campaign" below, abandoning the report's still-valid campaign and + // producing a SECOND campaigns row with the identical campaign_key on + // every repeat /analyze call. This guard sits OUTSIDE the sibling- + // match branch above precisely because it must only run when NO + // sibling matched — it never suppresses a legitimate D-08 sibling + // upgrade, which returns from the branch above instead. + // --------------------------------------------------------------------- + if (!matchCampaignId && ownReport.campaign_id) { + const ownCampaignRes = await client.query<{ campaign_key: string; group_method: string }>( + `SELECT campaign_key, group_method + FROM campaigns + WHERE id = $1`, + [ownReport.campaign_id] + ); + const ownCampaign = ownCampaignRes.rows[0]; + if (ownCampaign && currentKeys.includes(ownCampaign.campaign_key)) { + // The report's own campaign still satisfies its freshly-recomputed + // current tier key — it's still validly linked, reuse it as-is. + return { + campaignId: ownReport.campaign_id, + groupMethod: ownCampaign.group_method as GroupReportResult['groupMethod'], + created: false, + }; + } + // CR-02: own campaign row is gone, or the report's signal has + // genuinely diverged from its stored key — this report is about to + // be abandoned from its origin campaign in favor of a brand-new one + // below (the same class of abandonment as the sibling-migration + // branch above, just landing on a new row instead of an existing + // sibling's). Decrement now so the origin doesn't show a stale + // count. Falls through to create-new unchanged. + await decrementOriginCampaign(client, ownReport.campaign_id); + } + // No match on any tier — create a new campaign. Prefer the strongest // available key (Tier 1 > Tier 2 > Tier 3) so future duplicates of THIS // report can match it; do NOT set/transition campaigns.status (leave // migration default 'open' — Phase 19/20's concern). - const tier1Key = computeTier1Key(ownMessage?.message_id ?? null); - const tier2Key = computeTier2Key( - ownAttachmentHashes, - ownUrlDomains, - normalizedSubject, - ownSenderValue - ); - const tier3Key = computeTier3Key( - ownReport.requester_contact_id, - normalizedSubject, - ownReport.company_id - ); - let newCampaignKey: string; let newGroupMethod: GroupReportResult['groupMethod']; if (tier1Key) {