---
phase: 15-data-model-detection-ticket-evidence
plan: 03
type: execute
wave: 3
depends_on: ["15-02"]
files_modified:
  - lib/services/phishing-sweep-service.ts
  - lib/services/webhook-service.ts
  - lib/services/sync-scheduler.ts
  - migrations/098_phishing_sweep_schedule.sql
autonomous: true
requirements: [DETECT-01, DETECT-02]
must_haves:
  truths:
    - "A newly created ticket webhook fires phishing detection without blocking the webhook response"
    - "A scheduled cron sweep re-scans recently-modified tickets through the same detector core"
    - "The sweep is bounded (row limit) and reports a scanned/flagged/skipped/errors summary"
    - "A phishing-sweep schedule row exists for both fresh installs and existing installs"
  artifacts:
    - path: "lib/services/phishing-sweep-service.ts"
      provides: "sweepPhishingTickets() — bounded reconciliation loop over recently-modified tickets"
      min_lines: 40
    - path: "migrations/098_phishing_sweep_schedule.sql"
      provides: "phishing-sweep sync_schedules seed row (ON CONFLICT DO NOTHING)"
      contains: "phishing-sweep"
  key_links:
    - from: "webhook-service.ts ticket.created handler"
      to: "detectPhishingTicket (Plan 02)"
      via: "fire-and-forget .catch()"
      pattern: "detectPhishingTicket|triggerPhishingDetection"
    - from: "sync-scheduler.ts dispatch"
      to: "sweepPhishingTickets"
      via: "else-if branch on sync_type === 'phishing-sweep' with dynamic import"
      pattern: "phishing-sweep"
---

<objective>
Wire the Plan 02 detector into Pulse's two established scan triggers (D-01):
(1) the ticket webhook path (near-real-time, fire-and-forget) and (2) a scheduled
cron reconciliation sweep over recently-modified tickets — plus the schedule seed
row for existing installs. This is what makes DETECT-01 scanning and DETECT-02
idempotency actually run in production.

Purpose: Mirror the "webhook primary, cron reconciles" pattern already used for
ticket_notes and the pax8-daily / tickets-reconcile schedule registration.
Output: A sweep service, a webhook hook-in, a scheduler branch, and migration 098.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/15-data-model-detection-ticket-evidence/15-CONTEXT.md
@.planning/phases/15-data-model-detection-ticket-evidence/15-PATTERNS.md
@.planning/phases/15-data-model-detection-ticket-evidence/15-02-SUMMARY.md

<interfaces>
<!-- Exact contracts to wire against. Do not re-explore. -->

Plan 02 export (lib/services/phishing-detector.ts):
detectPhishingTicket(ticket: DetectableTicket): Promise<{ flagged: boolean; reportId?: string; skippedUnchanged?: boolean }>
DetectableTicket = { id, ticket_number, title, description, company_id, contact_id?, created_by_contact_id? }

webhook-service.ts (existing precedent to copy verbatim):
- Fire-and-forget block, lines 112-117: on payload.entityType === WebhookEntityType.TICKETS &&
  payload.eventType === WebhookEventType.CREATE, `this.triggerWorkflowEngine(payload).catch(err => console.error(...))`.
- triggerWorkflowEngine (lines 398-420): builds typed ticket data from payload.entity when present,
  else uses payload.entityId — copy this "prefer inline entity, else id" shape. Note the Autotask
  entity field names it reads: payload.entity.title, .description, .ticketNumber, .companyID, .contactID.
- Imports at top, lines 13-16.

Autotask ticket field → DB column mapping (lib/utils/entity-mapper.ts):
- payload.entity.createdByContactID → created_by_contact_id (entity-mapper.ts:211). The field is
  `createdByContactID`. There is NO `creatorContactID` field — using it compiles cleanly (entity is
  Record<string, any>) but silently yields undefined at runtime.

sync-scheduler.ts (existing precedent):
- ScheduleConfig.sync_type union, line 25 (currently ends `... | 'tickets-reconcile' | 'pax8-daily'`).
- defaultSchedules array — tickets-reconcile entry at lines 294-301 (id, name, description,
  cron_expression, sync_type, is_enabled: false). Seeded via ON CONFLICT (id) DO NOTHING (lines 304-318).
- Dispatch chain — tickets-reconcile branch at lines 458-463: `} else if (config.sync_type === 'tickets-reconcile') {
  const { reconcileStaleTickets } = await import('@/lib/services/ticket-reconciliation-service');
  const result = await reconcileStaleTickets(); console.log('[SCHEDULER] tickets-reconcile: ...'); }`.
  Every branch uses dynamic `await import()` — required per CLAUDE.md (no eager worker/scheduler imports).

sweep source (ticket-reconciliation-service.ts:51-151): bounded SELECT (LIMIT), per-row try/catch,
aggregate result object, createSyncLogger. tickets columns: id, ticket_number, title, description,
company_id, contact_id, created_by_contact_id, last_activity_date.

migration seed precedent: migrations/090_ticket_reconcile_schedule.sql (INSERT INTO sync_schedules
(id, name, description, cron_expression, sync_type, years_back, is_enabled) VALUES (...) ON CONFLICT (id) DO NOTHING).
sync_schedules.id is a text PK (values like 'tickets-reconcile').
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: phishing-sweep-service.ts — bounded reconciliation sweep</name>
  <files>lib/services/phishing-sweep-service.ts</files>
  <read_first>
    - lib/services/ticket-reconciliation-service.ts (full file — SELECT-with-LIMIT, per-row try/catch, ReconcileResult aggregate shape, createSyncLogger usage to mirror)
    - lib/services/phishing-detector.ts (detectPhishingTicket signature + DetectableTicket shape from Plan 02)
    - lib/services/postgres-client.ts (query API + parameterized-query convention)
  </read_first>
  <action>
    Create `lib/services/phishing-sweep-service.ts`. Export
    `interface PhishingSweepResult { scanned: number; flagged: number; skippedUnchanged: number; errors: number }`
    and `export async function sweepPhishingTickets(): Promise<PhishingSweepResult>`.

    Implementation (mirror reconcileStaleTickets structure):
    - Use `createSyncLogger({ component: 'PhishingSweep' })`.
    - Define module constants `SWEEP_WINDOW_DAYS = 7` and `SCAN_LIMIT = 500` (bounded — DoS guard).
    - Query recently-modified, non-deleted tickets:
      `SELECT id, ticket_number, title, description, company_id, contact_id, created_by_contact_id
       FROM tickets WHERE is_deleted = false AND last_activity_date > NOW() - INTERVAL '7 days'
       ORDER BY last_activity_date DESC LIMIT $1` with `[SCAN_LIMIT]` (parameterized; the interval
       may be inlined as a literal since it is a constant, or bound as a parameter).
    - For each row, call `detectPhishingTicket({ id: Number(row.id), ticket_number, title, description,
      company_id, contact_id, created_by_contact_id })` inside try/catch. Increment
      result.scanned always; result.flagged when `res.flagged && !res.skippedUnchanged`;
      result.skippedUnchanged when `res.skippedUnchanged`; result.errors on caught exception
      (logger.warn with the ticket id, never rethrow — one bad ticket must not abort the sweep).
    - Log a final summary line and return the result.

    Do NOT re-implement matching/hashing — call the shared detectPhishingTicket only (CONTEXT.md:
    "no duplicated pattern-matching logic"). Do NOT add a side-effect self-init or eager import.
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty && grep -q "detectPhishingTicket" lib/services/phishing-sweep-service.ts && grep -q "LIMIT" lib/services/phishing-sweep-service.ts && echo "sweep OK"</automated>
  </verify>
  <acceptance_criteria>
    - `lib/services/phishing-sweep-service.ts` exports `sweepPhishingTickets` returning `{ scanned, flagged, skippedUnchanged, errors }`
    - The ticket query is bounded by `LIMIT` (<= 500) and filters `is_deleted = false` and a recent `last_activity_date` window
    - Each ticket is processed by calling `detectPhishingTicket` (the file imports it from `./phishing-detector`) — no duplicated match/hash logic
    - A per-row try/catch increments `errors` and logs without aborting the loop (no rethrow)
    - No module-level side-effect / self-init / eager worker import
    - `npx tsc --noEmit --pretty` passes
  </acceptance_criteria>
  <done>sweepPhishingTickets scans a bounded set of recently-modified tickets through the shared detector and returns an aggregate summary.</done>
</task>

<task type="auto">
  <name>Task 2: Webhook hook-in — fire-and-forget phishing detection on ticket.created</name>
  <files>lib/services/webhook-service.ts</files>
  <read_first>
    - lib/services/webhook-service.ts (full file — imports at 13-16, ticket.created fire-and-forget block at 112-117, triggerWorkflowEngine payload-shaping at 398-420)
    - lib/utils/entity-mapper.ts (line 211 — createdByContactID → created_by_contact_id mapping)
    - lib/services/phishing-detector.ts (detectPhishingTicket + DetectableTicket shape)
  </read_first>
  <action>
    Modify `lib/services/webhook-service.ts` to trigger phishing detection on ticket
    creation, exactly mirroring the existing workflow-engine fire-and-forget precedent.

    - Add an import for `detectPhishingTicket` from `./phishing-detector` alongside the
      existing service imports at the top of the file (lines 13-16).
    - Add a private method `triggerPhishingDetection(payload: AutotaskWebhookPayload): Promise<void>`
      that builds a DetectableTicket the same "prefer inline payload.entity, else use payload.entityId"
      way triggerWorkflowEngine does (lines 398-420): if payload.entity is present, map
      title←payload.entity.title, description←payload.entity.description,
      ticket_number←payload.entity.ticketNumber, company_id←payload.entity.companyID,
      contact_id←payload.entity.contactID, and created_by_contact_id←payload.entity.createdByContactID.
      IMPORTANT: the Autotask ticket field is `createdByContactID` (mapped to `created_by_contact_id`
      at lib/utils/entity-mapper.ts:211). Do NOT use `creatorContactID` — that field does not exist,
      and because AutotaskWebhookPayload.entity is typed `Record<string, any>` a typo would compile
      cleanly but silently produce undefined at runtime, degrading EVID-01 requester/reporter capture
      on the primary (webhook) detection path.
      Otherwise pass a minimal ticket with `id: payload.entityId` and null fields (the detector's
      evidence/hash still works, and the cron sweep reconciles anything the webhook payload lacked).
      Call `await detectPhishingTicket(ticket)`.
    - In the existing ticket.created fire-and-forget block (lines 112-117), add a second
      identically-shaped call immediately after the triggerWorkflowEngine call:
      `this.triggerPhishingDetection(payload).catch(err => console.error('[WEBHOOK] Phishing detection error:', err));`.
      Do NOT `await` it in the request path — detection runs after the webhook response, matching the
      workflow-engine precedent. Do not change the workflow-engine call or any other webhook behavior.
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty && grep -q "triggerPhishingDetection" lib/services/webhook-service.ts && grep -q "createdByContactID" lib/services/webhook-service.ts && grep -q "Phishing detection error" lib/services/webhook-service.ts && echo "hook OK"</automated>
  </verify>
  <acceptance_criteria>
    - `webhook-service.ts` imports `detectPhishingTicket` from `./phishing-detector`
    - A `triggerPhishingDetection` method exists and builds ticket data using the "prefer payload.entity, else payload.entityId" shape
    - The method reads `payload.entity.createdByContactID` (NOT `creatorContactID`) into `created_by_contact_id` — grep confirms `createdByContactID` is present and `creatorContactID` is absent
    - The ticket.created handler calls `this.triggerPhishingDetection(payload).catch(...)` as fire-and-forget (not awaited in the request path), immediately alongside the existing `triggerWorkflowEngine` call
    - The existing workflow-engine trigger and all other webhook behavior are unchanged (grep still finds `triggerWorkflowEngine`)
    - `npx tsc --noEmit --pretty` passes
  </acceptance_criteria>
  <done>New ticket webhooks fire phishing detection without blocking the webhook response, mirroring the workflow-engine trigger precedent, with the requester/reporter contact read from the correct `createdByContactID` field.</done>
</task>

<task type="auto">
  <name>Task 3: Scheduler branch + defaultSchedules entry + migration 098 seed</name>
  <files>lib/services/sync-scheduler.ts, migrations/098_phishing_sweep_schedule.sql</files>
  <read_first>
    - lib/services/sync-scheduler.ts (sync_type union line 25; defaultSchedules tickets-reconcile entry lines 294-301 + seeding loop 304-318; dispatch chain tickets-reconcile branch 458-463)
    - migrations/090_ticket_reconcile_schedule.sql (seed-row shape + ON CONFLICT (id) DO NOTHING)
    - lib/services/phishing-sweep-service.ts (sweepPhishingTickets export from Task 1)
  </read_first>
  <action>
    Register the phishing reconciliation sweep as a scheduled sync, using the
    `tickets-reconcile` entry as the closest analog (no external-integration config gate needed).

    In `lib/services/sync-scheduler.ts`:
    - Extend the `ScheduleConfig.sync_type` union (line 25) by adding `| 'phishing-sweep'`.
    - Add a new entry to the `defaultSchedules` array (copy the tickets-reconcile shape at
      lines 294-301): id `'phishing-sweep'`, name `'Phishing Detection Sweep'`, a description stating
      it reconciles recently-modified tickets through the phishing detector to catch reports missed by
      the webhook path (bounded to 500 tickets, idempotent on content hash), cron_expression a daily
      time distinct from other jobs (e.g. `'0 5 * * *'`), sync_type `'phishing-sweep'`,
      is_enabled: false (disabled-by-default, matching tickets-reconcile precedent).
    - Add a dispatch branch mirroring the tickets-reconcile branch (lines 458-463): 
      `} else if (config.sync_type === 'phishing-sweep') {
        const { sweepPhishingTickets } = await import('@/lib/services/phishing-sweep-service');
        const result = await sweepPhishingTickets();
        console.log('[SCHEDULER] phishing-sweep: scanned=... flagged=... skippedUnchanged=... errors=...'); }`
      using dynamic `await import` (required — no eager import).

    Create `migrations/098_phishing_sweep_schedule.sql` (copy migrations/090's shape): a header
    comment noting createDefaultSchedules only seeds a virgin table so this covers existing installs,
    then `INSERT INTO sync_schedules (id, name, description, cron_expression, sync_type, years_back,
    is_enabled) VALUES ('phishing-sweep', 'Phishing Detection Sweep', '<same description>', '0 5 * * *',
    'phishing-sweep', NULL, false) ON CONFLICT (id) DO NOTHING;`. Keep the description text identical to
    the defaultSchedules entry. Do not edit any committed migration.
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty && grep -q "'phishing-sweep'" lib/services/sync-scheduler.ts && grep -q "sweepPhishingTickets" lib/services/sync-scheduler.ts && test -f migrations/098_phishing_sweep_schedule.sql && grep -q "ON CONFLICT (id) DO NOTHING" migrations/098_phishing_sweep_schedule.sql && echo "scheduler+migration OK"</automated>
  </verify>
  <acceptance_criteria>
    - `sync_type` union in sync-scheduler.ts includes `'phishing-sweep'`
    - `defaultSchedules` contains a `phishing-sweep` entry with `is_enabled: false` and a valid cron expression
    - A dispatch branch `else if (config.sync_type === 'phishing-sweep')` dynamically imports and calls `sweepPhishingTickets` and logs a scanned/flagged/skipped/errors summary
    - `migrations/098_phishing_sweep_schedule.sql` exists, inserts the `phishing-sweep` row, and uses `ON CONFLICT (id) DO NOTHING`
    - The migration description text matches the defaultSchedules entry description
    - `npx tsc --noEmit --pretty` passes
  </acceptance_criteria>
  <done>The phishing sweep is registered in the scheduler (disabled by default) and seeded for existing installs via migration 098, invoking the shared sweep service on each tick.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Autotask webhook → detection trigger | External webhook event initiates a background code path |
| cron scheduler → sweep | Time-triggered bulk processing over local tickets |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-15-09 | Denial of Service | repeated/replayed ticket.created webhooks | mitigate | Detection is fire-and-forget (never blocks the webhook response) AND idempotent via the Plan 02 content-hash guard — replays of an unchanged ticket skip before any write. Webhook HMAC verification (existing middleware/webhook-service) still gates the endpoint |
| T-15-10 | Denial of Service | unbounded cron sweep | mitigate | sweepPhishingTickets is bounded by `LIMIT 500` and a 7-day recent-activity window; per-row try/catch isolates failures so one ticket cannot abort the run |
| T-15-11 | Tampering | new schedule row on existing installs | mitigate | migration 098 is `ON CONFLICT (id) DO NOTHING` (idempotent) and additive; disabled-by-default (is_enabled=false) so it does nothing until an admin opts in |
| T-15-12 | Repudiation | sweep/detection produces no trace | accept | Both paths log via createSyncLogger / console.error with `[PHISHING-DETECT]`/`[SCHEDULER]` prefixes; full audit_events wiring is Phase 20 scope |
| T-15-SC | Tampering | package installs | accept | No new npm packages — reuses postgresClient, AutotaskClient, node-cron scheduler, and the Plan 02 detector. No install task |
</threat_model>

<verification>
- `npx tsc --noEmit --pretty` clean across all three modified/created files
- `grep "'phishing-sweep'" lib/services/sync-scheduler.ts` finds union member, default entry, and dispatch branch
- `grep "triggerPhishingDetection" lib/services/webhook-service.ts` finds the fire-and-forget hook
- `grep "createdByContactID" lib/services/webhook-service.ts` confirms the correct requester field (and `creatorContactID` is absent)
- `grep "ON CONFLICT (id) DO NOTHING" migrations/098_phishing_sweep_schedule.sql` matches
- Sweep query bounded by LIMIT (DoS guard)
</verification>

<success_criteria>
The detector runs on both triggers per D-01: near-real-time via the webhook ticket.created
path (fire-and-forget, ROADMAP SC#2) and via a bounded scheduled reconciliation sweep, both
calling the same shared detectPhishingTicket so idempotency (SC#3) holds on either path. The
phishing-sweep schedule is registered for fresh and existing installs.
</success_criteria>

<output>
Create `.planning/phases/15-data-model-detection-ticket-evidence/15-03-SUMMARY.md` when done.
</output>
