docs(15): create phase plan

This commit is contained in:
lorentz 2026-07-15 07:31:25 -04:00
parent 3f8e8dc826
commit 13208b3a92
4 changed files with 43 additions and 18 deletions

View file

@ -2,14 +2,14 @@
gsd_state_version: 1.0
milestone: v3.0
milestone_name: Phishing Triage Automation
status: planning
status: executing
stopped_at: Phase 15 context gathered
last_updated: "2026-07-15T10:56:47.034Z"
last_activity: 2026-07-14 — v3.0 ROADMAP.md created, 26/26 requirements mapped across Phases 15-21
last_updated: "2026-07-15T11:30:38.677Z"
last_activity: 2026-07-15 -- Phase 15 planning complete
progress:
total_phases: 7
completed_phases: 0
total_plans: 0
total_plans: 3
completed_plans: 0
percent: 0
---
@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-07-14)
Phase: 15 of 21 (Data Model, Detection & Ticket Evidence)
Plan: — (not yet planned)
Status: Roadmap approved, ready to plan Phase 15
Last activity: 2026-07-14 — v3.0 ROADMAP.md created, 26/26 requirements mapped across Phases 15-21
Status: Ready to execute
Last activity: 2026-07-15 -- Phase 15 planning complete
Progress: [░░░░░░░░░░] 0%

View file

@ -67,6 +67,10 @@ migrations/091_pax8_tables.sql (schema-only, multi-table, IF NOT EXISTS + traili
CREATE INDEX IF NOT EXISTS per table, "future phases populate this" header comment).
Postgres 16 has built-in gen_random_uuid() — no extension needed for UUID PKs.
Dev DB connection (docker-compose.yml): container `pulse-postgres`, POSTGRES_USER
defaults to `pulse_user`, POSTGRES_DB defaults to `pulse_autotask`. These env vars are
NOT exported to a bare shell, so verify commands must hardcode these as the fallback.
</interfaces>
</context>
@ -149,21 +153,22 @@ Postgres 16 has built-in gen_random_uuid() — no extension needed for UUID PKs.
<name>Task 2: Apply migration 097 to the dev database and verify tables exist</name>
<files>migrations/097_phishing_triage_schema.sql</files>
<read_first>
- docker-compose.yml (Postgres container name + POSTGRES_USER/POSTGRES_DB values for the psql apply command)
- docker-compose.yml (Postgres container name `pulse-postgres`; POSTGRES_USER=pulse_user, POSTGRES_DB=pulse_autotask for the psql apply command)
- CLAUDE.md (Migrations section: "Postgres init applies migrations on first boot only — for an existing DB, run via scripts/apply-migrations; check first, behavior varies")
- MEMORY note: existing DB volumes do NOT auto-apply new migrations; apply manually via `docker exec pulse-postgres psql ...`
</read_first>
<action>
Apply migration 097 to the running dev Postgres. First check for a project apply
helper (`ls scripts/apply-migrations*`); if one exists and matches convention, use it.
Otherwise pipe the file into the container's psql: read the Postgres container name
and POSTGRES_USER / POSTGRES_DB from docker-compose.yml, then run the migration with
`docker exec -i <pg-container> psql -U <user> -d <db>` fed from
Otherwise pipe the file into the container's psql: the Postgres container is
`pulse-postgres`, the user is `pulse_user`, and the database is `pulse_autotask`
(per docker-compose.yml). Run the migration with
`docker exec -i pulse-postgres psql -U pulse_user -d pulse_autotask` fed from
migrations/097_phishing_triage_schema.sql. Because the migration is fully IF NOT EXISTS,
re-applying it must be safe. Do NOT drop or recreate any existing table.
</action>
<verify>
<automated>docker exec -i "$(grep -oiE 'container_name:\s*\S*postgres\S*' docker-compose.yml | head -1 | awk '{print $2}')" psql -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-pulse}" -tAc "SELECT count(*) FROM information_schema.tables WHERE table_name IN ('campaigns','reports','messages','indicators','classifications','remediation_actions','audit_events');" | grep -qx 7 && echo "all 7 tables present"</automated>
<automated>docker exec -i pulse-postgres psql -U "${POSTGRES_USER:-pulse_user}" -d "${POSTGRES_DB:-pulse_autotask}" -tAc "SELECT count(*) FROM information_schema.tables WHERE table_name IN ('campaigns','reports','messages','indicators','classifications','remediation_actions','audit_events');" | grep -qx 7 && echo "all 7 tables present"</automated>
</verify>
<acceptance_criteria>
- Querying `information_schema.tables` returns all 7 phishing-triage table names present in the dev DB

View file

@ -191,6 +191,9 @@ The 8 locked DETECT-01 patterns (case-insensitive substrings — all must be cov
created_by_contact_id, title, description, matched_patterns, content_hash, evidence)
VALUES ($1..$11) ON CONFLICT (ticket_id) DO UPDATE SET ... , updated_at = NOW()
RETURNING id. matched_patterns and evidence bind as JSON (JSON.stringify or ::jsonb cast).
Column bindings from the DetectableTicket: `requester_contact_id` binds from `ticket.contact_id`
(the Autotask requester/reporter of the ticket), and `created_by_contact_id` binds from
`ticket.created_by_contact_id`. company_name comes from the gathered evidence's company_name.
Return { flagged: true, reportId }.
Do not duplicate the matcher/hash logic — call the Task 1 functions. Use console.error with a
@ -203,6 +206,7 @@ The 8 locked DETECT-01 patterns (case-insensitive substrings — all must be cov
- `detectPhishingTicket` is exported and returns `{ flagged, reportId?, skippedUnchanged? }`
- Idempotency branch present: a SELECT of reports by ticket_id compares stored `content_hash` to the freshly computed hash and returns `skippedUnchanged: true` without writing when equal
- The reports upsert uses `ON CONFLICT (ticket_id) DO UPDATE ... updated_at = NOW()` and RETURNING id
- `requester_contact_id` is bound from `ticket.contact_id` and `created_by_contact_id` from `ticket.created_by_contact_id` in the INSERT
- Evidence gathering reads ticket_notes and time_entries via parameterized `$1` queries (no string interpolation of ticket_id) and stores only attachment metadata (fullPath/title/contentType), never base64 `data`
- The Autotask getAttachments call is wrapped so a failure yields an empty attachments array instead of throwing
- `npx tsc --noEmit --pretty` passes and the Task 1 vitest suite still passes

View file

@ -71,9 +71,15 @@ 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.
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,
@ -145,6 +151,7 @@ sync_schedules.id is a text PK (values like 'tickets-reconcile').
<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>
@ -156,10 +163,17 @@ sync_schedules.id is a text PK (values like 'tickets-reconcile').
- 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/description/ticketNumber/companyID/contactID/creatorContactID from it; 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)`.
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));`.
@ -167,16 +181,17 @@ sync_schedules.id is a text PK (values like 'tickets-reconcile').
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 "Phishing detection error" lib/services/webhook-service.ts && echo "hook OK"</automated>
<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.</done>
<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">
@ -252,6 +267,7 @@ sync_schedules.id is a text PK (values like 'tickets-reconcile').
- `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>