Commit graph

701 commits

Author SHA1 Message Date
32cdf55506 docs(19): capture phase context 2026-07-16 07:14:54 -04:00
b305dd5108 docs(18): close out HUMAN-UAT.md — all items resolved after gap closure 2026-07-16 07:00:03 -04:00
7d34b33273 docs(phase-18): final verification report — status passed 2026-07-16 06:59:24 -04:00
cd9dfc3954 docs(18): update code review after CR-03/CR-02 gap closure 2026-07-16 06:56:03 -04:00
b59d9c2071 docs(18-05): finalize summary after Task 3 live verification (approved) 2026-07-16 06:49:38 -04:00
c3ad292139 chore: merge executor worktree (worktree-agent-a153b3afb38c39100) 2026-07-16 06:40:43 -04:00
4a34ba8ed2 docs(18-05): add partial SUMMARY for Tasks 1-2 (Task 3 checkpoint pending)
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.
2026-07-16 00:11:58 -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
24aa571245 docs(18): record planning completion for 18-05 2026-07-16 00:03:45 -04:00
b7a5273848 docs(18): extend 18-05 gap-closure plan to decrement origin on signal-diverged create-new too 2026-07-16 00:02:53 -04:00
9c69b9033c docs(18): document CR-03 duplicate-campaign gap found via live verification 2026-07-15 23:44:57 -04:00
a48c0583bd test(18): persist human verification items as UAT 2026-07-15 22:44:19 -04:00
cc2544c7ee docs(18): update code review report after gap-closure re-review 2026-07-15 22:36:03 -04:00
650f9b8100 fix(18): decouple campaigns count-query params from list-query placeholder numbering
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.
2026-07-15 22:35:47 -04:00
d3373ab708 docs(phase-18): update tracking after gap-closure wave 2026-07-15 22:28:22 -04:00
55ab2c2e05 chore: merge executor worktree (worktree-agent-af09d50545da22d95) 2026-07-15 22:27:01 -04:00
eb660354ef docs(18-04): record self-check results in plan summary 2026-07-15 22:26:37 -04:00
21fde9eb3d docs(18-04): complete gap-closure plan summary
- CAMP-02 report_count double-increment fix and WR-02 limit-clamp fix, with
  a passing regression test and clean type check
2026-07-15 22:26:21 -04:00
abe3d4b900 fix(18-04): clamp campaigns list limit param to [0, 200] (WR-02)
- 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
2026-07-15 22:25:29 -04:00
9ca2ccf1c7 fix(18-04): short-circuit own-campaign re-match to stop report_count double-increment
- 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
2026-07-15 22:25:04 -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
20f3e1bbb5 docs(18): create gap-closure plan 18-04 2026-07-15 22:22:17 -04:00
ea71579fb0 fix(phase-18): revert premature roadmap completion marker after gaps_found verification 2026-07-15 19:49:37 -04:00
7f51d49839 docs(phase-18): add verification report (gaps found) 2026-07-15 19:49:01 -04:00
bd90cdcb30 docs(18): add code review report 2026-07-15 19:43:12 -04:00
95859660d6 docs(phase-18): update tracking after wave 2 2026-07-15 19:34:50 -04:00
701fea04cc chore: merge executor worktree (worktree-agent-a985b30606e37e1d0) 2026-07-15 19:33:32 -04:00
c0967b5a9c chore: merge executor worktree (worktree-agent-a9f362ad9e34b1a2a) 2026-07-15 19:33:32 -04:00
a544024e75 docs(18-02): complete on-demand analyze route + automatic grouping wiring plan
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 19:33:00 -04:00
86f5166a86 docs(18-03): complete campaign read API plan
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 19:32:31 -04:00
959907d63b feat(18-03): add GET /api/phishing/campaigns/[id] nested detail
- 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)
2026-07-15 19:31:35 -04:00
c852cfee13 feat(18-03): add GET /api/phishing/campaigns paginated list
- requirePermission('phishing','read') gate (ACCESS-01)
- limit/offset clamped, optional status filter via parameterized $n placeholder (never string-interpolated)
- camelCase response { items, total, limit, offset }
2026-07-15 19:31:19 -04:00
19b8b4b415 feat(18-02): wire groupReportIntoCampaign into webhook + cron sweep paths
- 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
2026-07-15 19:31:18 -04:00
de013e6ec1 feat(18-02): add POST /api/phishing/tickets/[ticket_id]/analyze route
- Orchestrates detectPhishingTicket -> parseAndStoreMessage -> groupReportIntoCampaign
- requirePermission('phishing','analyze') gate first-line (D-06, 401/403)
- Validates ticket_id numeric (400), missing ticket (404), non-phishing ticket (400)
- No skipIfAlreadyGrouped (D-08) — always re-runs grouping on demand
2026-07-15 19:30:46 -04:00
c77b7edae7 docs(phase-18): update tracking after wave 1 2026-07-15 19:27:40 -04:00
19f05dd564 chore: merge executor worktree (worktree-agent-a7caddbc3f0fc1d30) 2026-07-15 19:25:52 -04:00
e76ab1bf43 docs(18-01): complete campaign grouping core + phishing permission resource plan
Summary for Phase 18 Plan 01 — groupReportIntoCampaign tiered matching,
self-exclusion safeguard, and the phishing permission resource. Requirements
CAMP-01, CAMP-02, ACCESS-01 complete.
2026-07-15 19:25:18 -04:00
04ec51f370 feat(18-01): add phishing permission resource + role grants
- statement gets the full D-05 vocabulary now: read/analyze/approve/remediate
- superAdminRole and adminRole grant read+analyze
- userRole grants read only (cannot trigger /analyze)
- approve/remediate declared but ungranted to any role until Phase 20
2026-07-15 19:23:09 -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
78c984dceb docs(state): record phase 18 planning session 2026-07-15 16:51:02 -04:00
a494cd0c0b docs(18): fix plan-checker warnings (tier self-exclusion + resolved open questions) 2026-07-15 16:50:46 -04:00
808604e861 docs(18): create phase plan (3 plans, 2 waves) 2026-07-15 16:45:29 -04:00
ffdd86a0ed docs(18): add pattern map 2026-07-15 16:37:37 -04:00
1c6d2fdde5 docs(phase-18): add validation strategy 2026-07-15 16:34:20 -04:00
e05d34640c docs(18): add research findings + EML-parsing scope decision 2026-07-15 16:33:49 -04:00
4ccd379dc3 docs(18): research phase domain 2026-07-15 16:22:25 -04:00
0ec690b261 docs(state): record phase 18 context session 2026-07-15 15:39:03 -04:00
a37dcebd2d docs(18): capture phase context 2026-07-15 15:39:03 -04:00