Commit graph

6 commits

Author SHA1 Message Date
5f308f836f fix(phishing-recipient-seubert): scope campaign grouping Tier 3 to company, not reporting contact
Tier 3 is the only tier automatic (webhook-triggered) grouping ever
reaches, since grouping runs before message parsing. It was scoped to
reports.requester_contact_id, so the same campaign reported by
different employees at the same company never consolidated into one
campaign — each report's evidence/blast-radius view silently
under-reported the campaign's true recipients.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 12:34:33 -04:00
6945591f76 fix(18-05): decrement origin campaign report_count on migration + diverged create-new (CR-02)
Cross-campaign migration (a report moves from campaign A to a
different existing campaign B on re-analyze) never decremented A's
report_count, leaving it permanently stale. The same abandonment
happens on the signal-diverged create-new fall-through added for
CR-03 (Task 1) — landing on a brand-new campaign instead of an
existing sibling's, but the same class of staleness.

Add a shared decrementOriginCampaign() helper and call it in both
locations, guarded so it only fires when there is a real origin
(ownReport.campaign_id non-null) and the match doesn't resolve back to
the report's own campaign (the existing same-campaign no-op guard is
unchanged). D-08 sibling upgrades (never-grouped report -> sibling
campaign) increment the destination only, with no origin to
decrement.

- Test E: cross-campaign migration decrements origin, increments destination
- Test F: D-08 upgrade with no prior campaign increments destination only
- Test G (renamed from existing 18-04 test): same-campaign re-match remains zero mutations
- Test H: signal-diverged create-new also decrements the abandoned origin (plan-checker-flagged case)

Tests E and H failed against the pre-fix code (confirmed during RED).
2026-07-16 00:10:45 -04:00
633b48c3b2 fix(18-05): add own-campaign revalidation guard before create-new (CR-03)
Re-analyzing a single-report campaign (no sibling report exists yet)
fell through to "create new campaign" because every tier query
self-excludes the report's own row, so matchCampaignId stayed null and
the report's still-valid campaign was abandoned in favor of a
duplicate campaigns row sharing the same campaign_key.

Add a guard that runs only when no sibling matched and the report
already has campaign_id: recompute the report's current tier keys
once, look up its own campaign row, and reuse it (created:false) when
the stored campaign_key still matches. Falls through to create-new
unchanged when the campaign row is gone or the signal has genuinely
diverged. D-08 sibling upgrades are untouched (guard only runs when no
sibling matched).

- Tests A/B reproduce the exact CR-03 duplicate-campaign scenario and
  failed against the pre-fix code (confirmed during RED)
- Tests C/D guard the create-new no-regression and signal-diverged
  fall-through paths
2026-07-16 00:08:36 -04:00
afd7af70c8 test(18-04): add failing regression test for campaign report_count double-increment
- Sibling report already in campaign-1 re-matches via Tier 3; asserts zero
  UPDATE campaigns / INSERT campaigns / UPDATE reports calls (CAMP-02/CR-01)
2026-07-15 22:24:17 -04:00
da926bbe97 feat(18-01): implement groupReportIntoCampaign tiered matching
- Tiered find-or-create inside postgresClient.transaction (Pitfall 2 —
  campaigns.campaign_key has no UNIQUE constraint): Tier 1 Message-ID,
  Tier 2 attachment-hash/URL-domain + subject + sender + 24h, Tier 3
  sender + normalized subject + client + 24h (CAMP-01)
- Match path bumps report_count/last_seen_at and links reports.campaign_id
  without creating a second campaign; no-match path inserts a new
  campaigns row keyed by the strongest available tier signal (CAMP-02)
- skipIfAlreadyGrouped short-circuits before the transaction (D-08); the
  /analyze route path always re-runs full tiered matching
- Every tier query excludes the report's own id (r.id != $n) so a
  self-match against a report's own messages/indicators can never
  double-increment its already-linked campaign on re-run
- D-07 doc comment states the Tier-3-only automatic-path limitation:
  parseAndStoreMessage is not wired into the webhook/cron path this phase
2026-07-15 19:22:31 -04:00
ea677b7aba test(18-01): add campaign-grouping tier-key helpers + test scaffold
- normalizeSubject strips repeated Re:/Fwd:/Fw: prefixes case-insensitively, lowercases, trims (D-03)
- extractUrlDomain returns hostname or null (never throws) for malformed URLs (Pitfall 4)
- test file mocks ./postgres-client before import, mirroring phishing-eml-service.test.ts's discipline
2026-07-15 19:19:44 -04:00