---
phase: 15-data-model-detection-ticket-evidence
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - migrations/097_phishing_triage_schema.sql
autonomous: true
requirements: [DETECT-01, DETECT-02, EVID-01]
must_haves:
  truths:
    - "The 7 phishing-triage tables exist in Postgres and can be queried"
    - "A reports row can store a ticket's content_hash, matched patterns, and EVID-01 evidence"
    - "Re-running the migration is a no-op (IF NOT EXISTS on every table/index)"
  artifacts:
    - path: "migrations/097_phishing_triage_schema.sql"
      provides: "campaigns, reports, messages, indicators, classifications, remediation_actions, audit_events tables"
      contains: "CREATE TABLE IF NOT EXISTS reports"
  key_links:
    - from: "reports.ticket_id"
      to: "tickets.id"
      via: "foreign key"
      pattern: "REFERENCES tickets\\(id\\)"
    - from: "reports.content_hash"
      to: "phishing-detector idempotency (Plan 02)"
      via: "unique key on ticket_id + stored hash"
      pattern: "content_hash"
---

<objective>
Create migration `097_phishing_triage_schema.sql` — the durable phishing-triage
schema (7 tables) that every v3.0 phase reads and writes. Phase 15 only populates
`reports` (via the Plan 02 detector); `campaigns`, `messages`, `indicators`,
`classifications`, `remediation_actions`, and `audit_events` are laid down now as
stubs so later phases (16-21) have their schema ready and never need a second
foundation migration.

Purpose: Land the schema before any service writes to it (STATE.md decision:
"durable schema lands in Phase 15, before any service that writes to it").
Output: One numbered, idempotent, schema-only migration file.
</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

<interfaces>
<!-- Existing columns the reports FKs and evidence columns reference. Do not re-derive. -->

tickets (migrations/001_initial_schema.sql): id BIGINT PK, company_id BIGINT NOT NULL,
ticket_number VARCHAR(100), title VARCHAR(255), description TEXT, contact_id BIGINT,
created_by_contact_id BIGINT, assigned_resource_id BIGINT, last_activity_date TIMESTAMP.

companies (migrations/001_initial_schema.sql): id BIGINT PK, company_name VARCHAR(255).

Reference migration for header framing + table/index conventions:
migrations/091_pax8_tables.sql (schema-only, multi-table, IF NOT EXISTS + trailing
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>

<tasks>

<task type="auto">
  <name>Task 1: Write migration 097 — 7-table phishing triage schema</name>
  <files>migrations/097_phishing_triage_schema.sql</files>
  <read_first>
    - migrations/091_pax8_tables.sql (header-comment framing + IF NOT EXISTS + per-table CREATE INDEX convention to copy)
    - migrations/090_ticket_reconcile_schedule.sql (ON CONFLICT idempotency convention used elsewhere)
    - migrations/001_initial_schema.sql (confirm exact tickets/companies column names the FKs reference)
    - .planning/phases/15-data-model-detection-ticket-evidence/15-PATTERNS.md (section on migrations/097 — reports column requirements)
    - CLAUDE.md (Database + Migrations sections: snake_case columns, audit-column convention, IF NOT EXISTS, next sequential number)
  </read_first>
  <action>
    Create `migrations/097_phishing_triage_schema.sql`. Open with a header comment
    (mirror migrations/091_pax8_tables.sql lines 1-18) stating this is a schema-only
    migration for v3.0 phishing triage, that Phase 15 populates only `reports`, and
    that `campaigns`/`messages`/`indicators`/`classifications`/`remediation_actions`/
    `audit_events` are stubs populated by Phases 16-21.

    Create tables in this order so FKs resolve (every table `CREATE TABLE IF NOT EXISTS`,
    every UUID PK `id UUID PRIMARY KEY DEFAULT gen_random_uuid()`, snake_case columns):

    1. campaigns — id, campaign_key TEXT, group_method TEXT, first_seen_at TIMESTAMPTZ,
       last_seen_at TIMESTAMPTZ, report_count INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'open',
       created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW().

    2. reports (the only table Phase 15 populates — design fully): id;
       ticket_id BIGINT NOT NULL REFERENCES tickets(id); ticket_number VARCHAR(100);
       company_id BIGINT; company_name VARCHAR(255); requester_contact_id BIGINT;
       created_by_contact_id BIGINT; title VARCHAR(255); description TEXT;
       matched_patterns JSONB NOT NULL DEFAULT '[]'::jsonb (the DETECT-01 pattern strings that matched);
       content_hash TEXT NOT NULL (D-04 idempotency key over title+description);
       evidence JSONB NOT NULL DEFAULT '{}'::jsonb (EVID-01 notes/time_entries/attachments capture);
       campaign_id UUID REFERENCES campaigns(id) (nullable — Phase 18 links it);
       created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW().
       Add `CONSTRAINT uq_reports_ticket_id UNIQUE (ticket_id)` so the Plan 02 detector
       can upsert one report per ticket (ON CONFLICT (ticket_id)) and enforce D-04.

    3. messages (stub for Phase 16) — id, report_id UUID REFERENCES reports(id),
       message_id TEXT, headers JSONB, urls JSONB, attachments JSONB, body_preview TEXT,
       raw_ref TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW().

    4. indicators (stub for Phase 16) — id, message_id UUID REFERENCES messages(id),
       indicator_type TEXT NOT NULL, value TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW().

    5. classifications (stub for Phase 19) — id, campaign_id UUID REFERENCES campaigns(id),
       verdict TEXT, confidence NUMERIC, summary TEXT, reasons JSONB, recommended_actions JSONB,
       requires_approval BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW().

    6. remediation_actions (stub for Phase 20) — id, campaign_id UUID REFERENCES campaigns(id),
       action_type TEXT, status TEXT NOT NULL DEFAULT 'proposed', params JSONB,
       approved_by TEXT, approved_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW().

    7. audit_events (stub for Phase 20) — id, campaign_id UUID, actor TEXT, event_type TEXT NOT NULL,
       payload JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW().

    After each CREATE TABLE add `CREATE INDEX IF NOT EXISTS` statements: reports on
    (ticket_id), (content_hash), (campaign_id); messages on (report_id), (message_id);
    indicators on (message_id); classifications on (campaign_id); remediation_actions on
    (campaign_id); campaigns on (campaign_key). Match the trailing-index style in
    migrations/091_pax8_tables.sql. Do NOT edit any committed migration; this is a new file only.
  </action>
  <verify>
    <automated>test -f migrations/097_phishing_triage_schema.sql && grep -c "CREATE TABLE IF NOT EXISTS" migrations/097_phishing_triage_schema.sql | grep -qx 7 && echo "7 tables OK"</automated>
  </verify>
  <acceptance_criteria>
    - `migrations/097_phishing_triage_schema.sql` exists
    - File contains exactly 7 `CREATE TABLE IF NOT EXISTS` statements: campaigns, reports, messages, indicators, classifications, remediation_actions, audit_events
    - `reports` table declares `ticket_id BIGINT NOT NULL REFERENCES tickets(id)`, `content_hash TEXT NOT NULL`, `matched_patterns JSONB`, `evidence JSONB`, and `CONSTRAINT uq_reports_ticket_id UNIQUE (ticket_id)`
    - Every index statement uses `CREATE INDEX IF NOT EXISTS`
    - No `DROP`, `ALTER ... DROP`, or `TRUNCATE` appears anywhere in the file (grep returns no matches)
  </acceptance_criteria>
  <done>The migration file lays down all 7 tables idempotently with the reports schema needed by Plan 02, and contains no destructive statements.</done>
</task>

<task type="auto">
  <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 `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: 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 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
    - `\d reports` (or an information_schema.columns query) shows columns `ticket_id`, `content_hash`, `matched_patterns`, `evidence`, and the unique constraint on `ticket_id`
    - Re-running the migration produces no error (IF NOT EXISTS makes it idempotent)
  </acceptance_criteria>
  <done>All 7 tables exist in the dev database; the reports table has the columns and unique constraint Plan 02 depends on.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| migration → Postgres DDL | Schema definition applied to the system-of-record DB |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-15-01 | Tampering | migration DDL against existing tables | mitigate | Schema-only, additive, every statement IF NOT EXISTS; no DROP/ALTER-DROP/TRUNCATE (enforced by acceptance criterion grep). New FKs reference existing tickets/companies only |
| T-15-02 | Denial of Service | re-applying migration on an existing volume | mitigate | Idempotent IF NOT EXISTS on tables and indexes — safe to re-run, no data loss |
| T-15-03 | Information Disclosure | reports/evidence tables hold ticket content | accept | No new external access path in this phase; standard DB access controls apply; the /api/phishing/* auth gate (ACCESS-01) lands in Phase 18 before any read surface exists |
| T-15-SC | Tampering | package installs | accept | No npm/pip/cargo installs in this plan — SQL-only migration, no new dependencies |
</threat_model>

<verification>
- `grep -c "CREATE TABLE IF NOT EXISTS" migrations/097_phishing_triage_schema.sql` returns 7
- No destructive DDL: `grep -iE "DROP|TRUNCATE" migrations/097_phishing_triage_schema.sql` returns nothing
- Dev DB shows all 7 tables + reports unique constraint on ticket_id
</verification>

<success_criteria>
Migration 097 creates all 7 phishing-triage tables with IF NOT EXISTS (ROADMAP SC#1),
the reports table is fully designed to store content_hash + matched patterns + EVID-01
evidence, and the migration is applied and verified in the dev database.
</success_criteria>

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