---
phase: 15-data-model-detection-ticket-evidence
reviewed: 2026-07-15T12:14:30Z
depth: standard
files_reviewed: 3
files_reviewed_list:
  - lib/services/webhook-service.ts (triggerPhishingDetection method and its call site only)
  - lib/services/phishing-detector.ts
  - lib/services/phishing-detector.test.ts
findings:
  critical: 0
  warning: 4
  info: 1
  total: 5
status: issues_found
---

# Phase 15: Code Review Report (re-review)

**Reviewed:** 2026-07-15T12:14:30Z
**Depth:** standard
**Files Reviewed:** 3 (scoped subset per config)
**Status:** issues_found

## Summary

Re-review of three prior findings (CR-01, WR-01, WR-02) against fix commits `ecc34b4`,
`c875081`, `7c63c5f`. All three are verified fixed and correctly implemented — no evidence
of a regression or a superficial/partial fix in any of them.

- **CR-01 (fixed correctly):** `triggerPhishingDetection` no longer reads `payload.entity`
  (confirmed still structurally never populated — `normalizeWebhookPayload` in
  `lib/types/webhook.ts:137-157` never sets `.entity`). It now queries Postgres by
  `payload.entityId`, and the not-found case is handled safely: `console.warn` + early
  `return`, no throw. Traced the ordering: `handleCreateOrUpdate()` is `await`ed
  synchronously (webhook-service.ts:95) before `triggerPhishingDetection()` is invoked
  (webhook-service.ts:118) inside the same `processWebhook` call — so on the happy path the
  ticket row is guaranteed to exist with current `title`/`description` by the time it's read
  back. Confirmed correct.
- **WR-01 (fixed correctly):** `phishing-detector.ts` now imports `getAutotaskClient` from
  `./autotask-factory` (line 17) instead of the private duplicate that was removed. Verified
  `autotask-factory.ts` throws a clear config-validation error when Autotask env vars are
  missing (lines 16-20), and that error is caught by `gatherTicketEvidence`'s existing
  `try/catch` around the attachments call (phishing-detector.ts:143-153), degrading
  gracefully to `attachments: []` rather than crashing detection. No unused imports left
  behind from the removed duplicate.
- **WR-02 (fixed correctly):** The unchanged-content-hash branch (phishing-detector.ts:190-204)
  now issues `UPDATE reports SET evidence = ...` even when `content_hash` matches, touching
  only the `evidence`/`updated_at` columns — `title`, `description`, `matched_patterns`, and
  `content_hash` are left untouched, so this does not reintroduce reprocessing on pure
  status/assignee churn. Confirmed via `computePhishingContentHash`, which hashes only
  `title`+`description` (phishing-detector.ts:58-65), combined with the fact that the webhook
  trigger only fires on `CREATE` events (webhook-service.ts:114) — so status/assignee changes
  on an already-detected ticket never even reach `detectPhishingTicket` via the webhook path.
  The only caller that repeatedly exercises this branch is the cron sweep
  (`phishing-sweep-service.ts`), which is itself idempotent and hash-gated.

No new bugs were introduced by the three fixes. The findings below are gaps surfaced while
tracing the fixes (some latent in surrounding logic not touched by the fix commits, one a
still-outstanding item from the prior review pass).

## Warnings

### WR-01: `gatherTicketEvidence` doesn't filter soft-deleted notes/time entries

**File:** `lib/services/phishing-detector.ts:126-140`
**Issue:** The notes and time-entries queries select all rows for the ticket with no
`is_deleted = false` filter, even though both `ticket_notes` and `time_entries` have that
column (`migrations/025_create_ticket_notes.sql:15`, `migrations/006_add_time_entries_table.sql:47`).
A note or time entry retracted in Autotask after the fact still appears in every future
evidence snapshot — including the WR-02 refresh path, which was specifically added so
evidence doesn't go stale. Showing a retracted note as live evidence in a phishing-triage
snapshot is a correctness problem for whatever later phase reads `reports.evidence`, not
just a cosmetic one.
**Fix:**
```sql
SELECT id, title, description, note_type, creator_resource_id, created_at
  FROM ticket_notes
 WHERE ticket_id = $1 AND is_deleted = false
 ORDER BY created_at
```
(apply the same `is_deleted = false` filter to the `time_entries` query.)

### WR-02: Phishing detection is silently skipped with no retry when the ticket row isn't in Postgres yet

**File:** `lib/services/webhook-service.ts:457-476`
**Issue:** If `handleCreateOrUpdate()`'s Autotask entity fetch returns `null` (API hiccup,
rate limit, transient unavailability — see `webhook-service.ts:173-176`, which already
warns-and-continues without upserting or retrying), a genuinely new ticket's row never lands
in Postgres. `triggerPhishingDetection` then finds no row, logs a `console.warn`, and returns
— no retry, no re-queue. The call is fire-and-forget
(`.catch(err => console.error(...))` at the call site), so nothing else observes the miss.
The only backstop is the `phishing-sweep` cron job, registered `is_enabled: false` by default
(`sync-scheduler.ts:308`). In the default configuration, a brand-new phishing ticket that
races the entity-fetch failure is never detected until an operator manually enables the sweep.
**Fix:** At minimum, log this case with the same visibility as a failed webhook (not just
`console.warn`) so ops can spot the gap, and/or surface in the admin UI that `phishing-sweep`
should be enabled for detection reliability. Longer-term, consider re-queuing the ticket ID
for a delayed re-check instead of relying solely on the next scheduled sweep.

### WR-03: Webhook path only triggers phishing detection on ticket CREATE, never on UPDATE

**File:** `lib/services/webhook-service.ts:113-121`
**Issue:** `triggerPhishingDetection` only fires when
`payload.eventType === WebhookEventType.CREATE`. If a ticket's title/description changes
*after* creation (a technician retitles a miscategorized ticket to include a locked phishing
phrase, or a customer's subject line gets corrected), the webhook path never re-runs
detection for that ticket — the content-hash gate in `detectPhishingTicket` never even gets a
chance to see the change via the near-real-time path. Detection of that case depends entirely
on the disabled-by-default nightly sweep, the same gap noted in the prior review's WR-02
discussion of staleness, just from the opposite direction (missed detection vs. stale
evidence).
**Fix:** Either extend the trigger condition to also cover `WebhookEventType.UPDATE` (cheap
and safe given the content-hash gate makes unrelated updates a no-op), or explicitly document
that UPDATE-triggered re-detection is intentionally deferred to the cron sweep, and make sure
that sweep ships enabled before relying on it.

### WR-04: New evidence-refresh logic (WR-02 fix) still has zero test coverage

**File:** `lib/services/phishing-detector.test.ts` (whole file), `lib/services/phishing-detector.ts:174-247`
**Issue:** `phishing-detector.test.ts` only exercises the two pure functions
(`matchesPhishingPatterns`, `computePhishingContentHash`) — unchanged from the previous
review pass (previously flagged as IN-02). `detectPhishingTicket`, including the exact branch
this re-review was asked to verify (evidence refresh on an unchanged content hash), still has
no test coverage. A regression to the hash-gate condition, the
`UPDATE reports SET evidence = ...` statement, or the `ON CONFLICT (ticket_id) DO UPDATE`
upsert would not be caught by `npm test`. Given this exact code path was the subject of two
review cycles now, it's the highest-value place in this file to add coverage.
**Fix:** Add tests using the `postgresClient`-mocking pattern already established in
`lib/services/pax8-sync-service.test.ts` / `pax8-company-matcher.test.ts`, covering: new-ticket
insert, unchanged-hash evidence-only refresh (assert `title`/`content_hash` unchanged but
`evidence`/`updated_at` updated, and that `gatherTicketEvidence` is still called exactly once),
and changed-hash full reprocessing.

## Info

### IN-01: `reportId` now populated on the unchanged-hash branch (behavior improvement, not a defect)

**File:** `lib/services/phishing-detector.ts:203`
**Issue:** Not a bug — flagging for completeness since it changed as a side effect of the
WR-02 fix. Previously the unchanged-hash branch returned
`{ flagged: true, skippedUnchanged: true }` with no `reportId`. The fix now also returns
`reportId: existing.rows[0].id`, which is strictly more useful to callers. Nothing currently
consumes it incorrectly (`phishing-sweep-service.ts` doesn't read it), but worth noting in the
review trail since it's an unrequested (though harmless) API surface change.
**Fix:** None needed.

## Post-Review Fix Note

The `WR-01 (new)` finding above (missing `is_deleted = false` filter on
`ticket_notes`/`time_entries` in `gatherTicketEvidence`) was fixed directly
after this re-review, commit `9c4584d`. Remaining items (`WR-02 (new)`
ticket-not-found race with the sweep disabled by default, `WR-03 (new)`
webhook fires on CREATE only, `WR-04`/test coverage, `IN-01`) are accepted
as follow-up/backlog items rather than blocking this phase:
- Disabled-by-default sweep matches this codebase's established convention
  for new scheduled integrations (e.g. `pax8-daily`) — an admin opts in via
  `/admin` once ready; not unique to this phase.
- CREATE-only webhook trigger matches the explicit `D-01` decision in
  `15-CONTEXT.md` (hook into `ticket.created`), not an oversight.
- Orchestration-path test coverage gap is consistent with most of this
  codebase per CLAUDE.md (no tests outside `analyzer/`, `rmm/`, `b2/`).

---

_Reviewed: 2026-07-15T12:14:30Z_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: standard_
