---
phase: 23-classification-disposition-per-client-automation-gate
plan: 03
type: execute
wave: 1
depends_on: []
files_modified:
  - migrations/100_phishing_automation_gate.sql
  - app/api/admin/phishing-automation/route.ts
  - app/api/admin/phishing-automation/[companyId]/route.ts
  - .planning/REQUIREMENTS.md
autonomous: true
requirements: [AUTOGATE-01]
must_haves:
  truths:
    - "A phishing_automation_gate table exists keyed by company_id with auto_parse/auto_classify/auto_report booleans defaulting to false"
    - "GET /api/admin/phishing-automation returns every company with its three gate flags, defaulting absent rows to all-false"
    - "PATCH /api/admin/phishing-automation/{companyId} upserts the three flags with actor+timestamp stamping, admin-gated"
    - "A company with no row reads as all three stages OFF (opt-in)"
    - "REQUIREMENTS.md documents all six phase-23 requirement IDs (CLASSDISP-01/02/03, AUTOGATE-01/02/03) with Traceability rows"
  artifacts:
    - path: "migrations/100_phishing_automation_gate.sql"
      provides: "opt-in per-company automation gate table"
      contains: "phishing_automation_gate"
    - path: "app/api/admin/phishing-automation/route.ts"
      provides: "admin-gated GET list with COALESCE(..., false) defaults"
      exports: ["GET"]
    - path: "app/api/admin/phishing-automation/[companyId]/route.ts"
      provides: "admin-gated PATCH upsert + DELETE revert-to-default"
      exports: ["PATCH", "DELETE"]
    - path: ".planning/REQUIREMENTS.md"
      provides: "v3 CLASSDISP-*/AUTOGATE-* requirement entries + Traceability rows"
      contains: "AUTOGATE-01"
  key_links:
    - from: "GET route"
      to: "phishing_automation_gate"
      via: "LEFT JOIN + COALESCE(..., false)"
      pattern: "COALESCE\\(pag\\.auto_"
    - from: "PATCH route"
      to: "phishing_automation_gate"
      via: "INSERT ... ON CONFLICT DO UPDATE"
      pattern: "ON CONFLICT \\(company_id\\)"
---

<objective>
Create the per-company automation-gate persistence and admin API. A new `phishing_automation_gate` table (migration 100) stores three independent opt-in booleans per company (`auto_parse`, `auto_classify`, `auto_report`), defaulting to all-OFF. Two admin-gated routes mirror the existing `/api/admin/company-scope` pattern: a GET listing every company with its flags, and a `[companyId]` PATCH that upserts flags (plus a DELETE that reverts a company to the all-OFF default). Also backfills the REQUIREMENTS.md entries for this phase's six requirement IDs (housekeeping — they currently exist only in ROADMAP.md).

Purpose: The data + API layer the admin UI (Plan 04) and the webhook auto-pipeline (Plan 05) both depend on, plus requirement-doc consistency.
Output: migration 100 + two API routes + REQUIREMENTS.md v3 entries.
</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/23-classification-disposition-per-client-automation-gate/23-CONTEXT.md
@.planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md

<interfaces>
<!-- Analogs to mirror near line-for-line. -->
migrations/082_company_scope.sql — opt-out table shape (company_id PK -> companies(id) ON DELETE CASCADE, boolean NOT NULL DEFAULT, updated_by TEXT, updated_at TIMESTAMPTZ). This phase flips defaults to false (opt-IN) and uses THREE booleans.
Latest existing migration is 099_indicators_metadata.sql -> use 100.

app/api/admin/company-scope/route.ts (75 lines):
- requireAdmin() guard (lines 33-35), search/type param parsing + conditions[] (lines 37-52)
- SELECT c.id::text, c.company_name, c.company_type, COALESCE(cs.in_scope, true) FROM companies c LEFT JOIN company_scope cs ... (lines 54-62)
- .map() camelCase transform + NextResponse.json (lines 64-75)

app/api/admin/company-scope/[companyId]/route.ts (59 lines):
- PATCH: requireAdmin, parseInt companyId, body validation, INSERT ... ON CONFLICT (company_id) DO UPDATE with updated_by = session email (lines 14-43)
- DELETE: revert to default (lines 45-58)

DB conventions: snake_case columns, camelCase API responses, postgresClient.query(sql, params) from lib/services/postgres-client.ts.
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create migration 100 phishing_automation_gate + apply to dev DB</name>
  <files>migrations/100_phishing_automation_gate.sql</files>
  <read_first>
    - migrations/082_company_scope.sql (full — the schema-shape analog to adapt)
    - .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md (migration section)
    - CLAUDE.md ("Migrations" + the migration-apply caveat: Postgres init only runs migrations on first volume boot; existing DB must be applied manually)
  </read_first>
  <action>
    Create `migrations/100_phishing_automation_gate.sql` defining `CREATE TABLE IF NOT EXISTS phishing_automation_gate` with columns: `company_id BIGINT PRIMARY KEY REFERENCES companies(id) ON DELETE CASCADE`, `auto_parse BOOLEAN NOT NULL DEFAULT false`, `auto_classify BOOLEAN NOT NULL DEFAULT false`, `auto_report BOOLEAN NOT NULL DEFAULT false`, `updated_by TEXT`, `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`. All three booleans default false (D-06 opt-in — opposite polarity from company_scope). Add a `COMMENT ON TABLE` explaining: opt-in per-company phishing pipeline automation gate; absent row = all three stages manual-only; auto_report auto-posts ONLY acknowledge_user for USER_AWARENESS verdicts (D-04), not arbitrary actions. Use `IF NOT EXISTS` throughout. After writing, apply it to the running dev Postgres (this is an existing long-lived volume, so the file will not auto-run) — apply via the project's migration-apply path (check scripts/apply-migrations behavior first) or `docker exec pulse-postgres psql` per the CLAUDE.md caveat, then confirm the table exists.
  </action>
  <verify>
    <automated>cd /opt/stacks/pulse && grep -q "CREATE TABLE IF NOT EXISTS phishing_automation_gate" migrations/100_phishing_automation_gate.sql && grep -c "NOT NULL DEFAULT false" migrations/100_phishing_automation_gate.sql | grep -qx 3 && echo OK</automated>
  </verify>
  <acceptance_criteria>
    - File migrations/100_phishing_automation_gate.sql exists with `phishing_automation_gate` table, PK company_id referencing companies(id) ON DELETE CASCADE
    - Exactly three `BOOLEAN NOT NULL DEFAULT false` columns: auto_parse, auto_classify, auto_report
    - updated_by TEXT and updated_at TIMESTAMPTZ columns present
    - Table applied to dev DB: `docker exec pulse-postgres psql -U <user> -d <db> -c "\d phishing_automation_gate"` shows the table (or the project's chosen apply-verification command confirms existence)
  </acceptance_criteria>
  <done>phishing_automation_gate table exists in the schema file and is applied to the dev database with opt-in (all-false) defaults.</done>
</task>

<task type="auto">
  <name>Task 2: Admin GET list route + [companyId] PATCH/DELETE route</name>
  <files>app/api/admin/phishing-automation/route.ts, app/api/admin/phishing-automation/[companyId]/route.ts</files>
  <read_first>
    - app/api/admin/company-scope/route.ts (full — GET analog)
    - app/api/admin/company-scope/[companyId]/route.ts (full — PATCH/DELETE analog)
    - lib/auth-utils.ts (requireAdmin signature/return shape)
    - .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md (both route sections + the "send all three values each PATCH" recommendation)
  </read_first>
  <action>
    Create `app/api/admin/phishing-automation/route.ts` exporting `GET`, mirroring company-scope/route.ts: `requireAdmin()` guard, same `search`/`type` query param + `conditions[]` building, then `SELECT c.id::text, c.company_name, c.company_type, COALESCE(pag.auto_parse, false) AS auto_parse, COALESCE(pag.auto_classify, false) AS auto_classify, COALESCE(pag.auto_report, false) AS auto_report FROM companies c LEFT JOIN phishing_automation_gate pag ON pag.company_id = c.id WHERE ... ORDER BY c.company_name`. Map rows to camelCase `{ id, companyName, companyType, companyTypeLabel, autoParse, autoClassify, autoReport }` and return via NextResponse.json (match company-scope's response envelope). Create `app/api/admin/phishing-automation/[companyId]/route.ts` exporting `PATCH` and `DELETE`, mirroring company-scope/[companyId]/route.ts. PATCH: `requireAdmin()`, parseInt+NaN-guard companyId, validate the body carries the three booleans `autoParse`/`autoClassify`/`autoReport` (client always sends all three current values — the admin page knows them from local state, avoiding partial-update SQL), then `INSERT INTO phishing_automation_gate (company_id, auto_parse, auto_classify, auto_report, updated_by, updated_at) VALUES ($1,$2,$3,$4,$5,NOW()) ON CONFLICT (company_id) DO UPDATE SET auto_parse = EXCLUDED.auto_parse, auto_classify = EXCLUDED.auto_classify, auto_report = EXCLUDED.auto_report, updated_by = EXCLUDED.updated_by, updated_at = NOW()` with `updated_by` = `(session?.user as any)?.email ?? null`. DELETE: `requireAdmin()`, delete the company's row (reverts to all-OFF default), return `{ ok: true }`.
  </action>
  <verify>
    <automated>cd /opt/stacks/pulse && npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - GET route: `grep -c "COALESCE(pag.auto_" app/api/admin/phishing-automation/route.ts` >= 3 and `grep -q "requireAdmin" app/api/admin/phishing-automation/route.ts`
    - PATCH route: `grep -q "ON CONFLICT (company_id)" app/api/admin/phishing-automation/[companyId]/route.ts` and updates all three auto_* columns
    - Both routes call `requireAdmin()` and return its error when present
    - DELETE handler present in the [companyId] route
    - `npx tsc --noEmit` exits 0
  </acceptance_criteria>
  <done>GET returns all companies with COALESCE(false) gate defaults; PATCH upserts all three flags with actor stamping; DELETE reverts to default; both admin-gated; type-check passes.</done>
</task>

<task type="auto">
  <name>Task 3: Backfill v3 CLASSDISP-*/AUTOGATE-* requirement entries + Traceability rows in REQUIREMENTS.md</name>
  <files>.planning/REQUIREMENTS.md</files>
  <read_first>
    - .planning/REQUIREMENTS.md (v2 Requirements section lines 140-162, Traceability table lines 174-216 — mirror its row format and the Coverage footer)
    - .planning/ROADMAP.md (Phase 23 block lines 529-541 — source the six requirement descriptions)
  </read_first>
  <action>
    REQUIREMENTS.md has no entries for the six phase-23 requirement IDs — they exist only in ROADMAP.md (WARNING 1). REQUIREMENTS.md's convention: `## v1 Requirements` holds all currently-roadmapped work (Phases 15-22, e.g. Phase 22's `### Approval UI (LiveLink)` subsection), while `## v2 Requirements` is explicitly "Deferred to future release. Tracked but not in current roadmap." Phase 23 is active v3.0 roadmap work, not deferred — do NOT create a new `## v3 Requirements` top-level section (that would misleadingly read as deferred/future, the opposite of reality). Instead add a new `### Classification Disposition + Automation Gate` subsection UNDER the existing `## v1 Requirements` heading (immediately after Phase 22's `### Approval UI (LiveLink)` subsection, mirroring its format exactly) with entries for all six requirements, sourced from the ROADMAP Phase 23 block: CLASSDISP-01 (dedicated USER_AWARENESS verdict for confirmed phishing-simulation-vendor reports, replacing the forced-UNWANTED disposition), CLASSDISP-02 (USER_AWARENESS maps to the new non-destructive acknowledge_user action that posts a customer-visible noteType-18 thank-you note), CLASSDISP-03 (review UI surfaces USER_AWARENESS + acknowledge_user distinctly), AUTOGATE-01 (per-company phishing_automation_gate table + admin GET/PATCH/DELETE API, opt-in defaults), AUTOGATE-02 (/admin/phishing-automation page with three per-company Switch toggles), AUTOGATE-03 (webhook auto-runs the gated parse->classify->acknowledge chain with the D-04 acknowledge_user auto-post carve-out). Then add six rows to the Traceability table mapping each ID to Phase 23 / Pending, and update the Coverage footer counts to include the six new requirements. Keep the existing entry + table format and wording style verbatim; do NOT remove or alter any existing v1/v2 entry or row.
  </action>
  <verify>
    <automated>cd /opt/stacks/pulse && grep -Eq "CLASSDISP-01|CLASSDISP-02|CLASSDISP-03" .planning/REQUIREMENTS.md && grep -Eq "AUTOGATE-01|AUTOGATE-02|AUTOGATE-03" .planning/REQUIREMENTS.md && [ "$(grep -c 'Phase 23' .planning/REQUIREMENTS.md)" -ge 6 ] && echo OK</automated>
  </verify>
  <acceptance_criteria>
    - All six IDs (CLASSDISP-01/02/03, AUTOGATE-01/02/03) appear both as requirement entries and as Traceability rows mapped to Phase 23
    - The Coverage footer reflects the added requirements
    - No existing v1/v2 entries or Traceability rows are removed or altered
  </acceptance_criteria>
  <done>REQUIREMENTS.md documents the six phase-23 requirement IDs with descriptions and Traceability rows mapped to Phase 23; ROADMAP and REQUIREMENTS are consistent.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| operator browser -> admin API | authenticated admin toggles per-company automation flags |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-23-04 | Elevation of Privilege | /api/admin/phishing-automation GET+PATCH+DELETE | mitigate | Every handler calls requireAdmin() and returns its error before any DB access — no non-admin can read or change gate state |
| T-23-05 | Tampering | PATCH body (arbitrary company id / non-boolean flags) | mitigate | parseInt+NaN-guard on companyId; body validated to carry three booleans; parameterized query prevents injection; FK ON DELETE CASCADE keeps rows consistent with companies |
| T-23-06 | Repudiation | who changed a company's gate | mitigate | updated_by (session email) + updated_at stamped on every upsert |
</threat_model>

<verification>
- Migration file present + applied; `npx tsc --noEmit --pretty` passes
- GET defaults absent rows to all-false; PATCH upserts with actor stamp; both admin-gated
- REQUIREMENTS.md lists all six phase-23 IDs with Traceability rows
</verification>

<success_criteria>
- phishing_automation_gate table exists with opt-in (all-false) defaults
- GET lists companies with three flags; PATCH upserts; DELETE reverts; all admin-gated
- REQUIREMENTS.md and ROADMAP.md agree on the phase-23 requirement IDs
</success_criteria>

<output>
Create `.planning/phases/23-classification-disposition-per-client-automation-gate/23-03-SUMMARY.md` when done
</output>
