- gatherCampaignEvidence: bulk-fetches reports (earliest-first, joined to
contacts for requester email) -> messages (report_id = ANY) -> indicators
(message_id = ANY), parses messages.headers JSONB into bounded
ParsedMessage fields, and runs one getBlastRadius() lookup keyed off the
earliest report's sender/subject/±24h window (research A6); synthesizes
unavailable/not_configured with no Mimecast call when no report is linked
- evaluateThreatTier (D-03): blastRadius.status==='ok' AND
(delivered>0 OR clicked>0) AND (hasHardAuthFail via effectiveAuthResults
OR hasKnownBadIndicatorMatch — same attachment_hash/url value spanning
>=2 distinct messages, cross-report correlation only, no external
reputation lookup per research A4)
- evaluateSpamVsUnwanted (D-04): UNWANTED when any attachment/url indicator
matches or delivery is contained to the reporter(s) only; SPAM otherwise
- classifyCampaign: D-06 simulation short-circuit -> D-03 -> D-04 ->
computeConfidence -> mapVerdictToActions -> computeRequiresApproval ->
append-only INSERT into classifications (D-02, no ON CONFLICT), wrapped
in try/catch logging [CAMPAIGN-CLASSIFIER] + err.message and rethrowing
- isKnownSimulationSender relaxed to a narrower SenderIdentity shape so both
the full NormalizedMessage fixtures and the bounded ParsedMessage type
can share it
- All 39 tests green; tsc clean; full `npm test` suite green except 2
pre-existing, unrelated itglue-search.test.ts failures (see
deferred-items.md)
Documents the CR-03 own-campaign revalidation guard and CR-02 origin
decrement fix (Tasks 1-2, committed and green), ahead of the blocking
Task 3 human live-database-verification checkpoint.
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).
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
The count query reused statusFilter (built with $3 against the list query's
3-element params array) but only passed a 1-element params array, causing a
Postgres bind-parameter mismatch (500) on any `?status=` filtered request.
Pre-existing since 18-03; surfaced by the 18-04 gap-closure code re-review.
Gives the count query its own independent param array/placeholder numbering.
- Parse limit once with Number.isFinite instead of `|| 50`, so an explicit
limit=0 is honored instead of silently replaced by the default
- Math.max/min clamps to [0, 200], preventing a negative limit from reaching
the SQL LIMIT clause and raising an unhandled 500
- OwnReportRow now selects campaign_id::text; match branch no-ops (created:
false, no UPDATE) when the tiered match resolves to the report's own
current campaign_id via a sibling row
- Closes CAMP-02 gap / CR-01: /analyze can be re-run indefinitely without
inflating campaigns.report_count
- requirePermission('phishing','read') gate (ACCESS-01)
- UUID-validated id (400 on malformed), 404 when campaign absent
- bulk-fetch reports/messages/indicators via ANY($1::uuid[]) keyed by parent id array (device-link-conflicts pattern)
- requesterEmail derived via reports.requester_contact_id -> contacts join (campaigns has no recipients column)
- messages.subject pulled from headers->>'subject' JSONB (no subject column)
- classifications included in shape (Phase 19 stub, expected empty)
- webhook-service.ts: triggerPhishingDetection calls groupReportIntoCampaign
with skipIfAlreadyGrouped:true after a flagged detection (D-01, D-08)
- phishing-sweep-service.ts: per-ticket sweep loop calls the same, inside the
existing try/catch so a grouping failure counts against result.errors
without aborting the sweep