---
phase: 13-scheduler-admin-toggle
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - migrations/096_pax8_daily_schedule.sql
  - lib/services/sync-scheduler.ts
  - CLAUDE.md
autonomous: true
requirements: [PAX8-07, PAX8-09]
must_haves:
  truths:
    - "A pax8-daily row exists in sync_schedules (cron 0 4 * * *, is_enabled false by default) (D-04)"
    - "The scheduler's executeScheduledSync dispatches sync_type 'pax8-daily' to getPax8SyncService().fullSync('scheduled')"
    - "When integration_settings.key='pax8' has disabled=true, the pax8-daily branch skips fullSync and logs a distinct message; this is a PAX8-only inline check with no shared helper (D-01)"
    - "When PAX8 env credentials are absent, the pax8-daily branch skips fullSync and logs a distinct 'not configured' message"
    - "A sync already in progress when PAX8 is disabled runs to completion — the disabled flag only blocks the next scheduled tick, no mid-flight cancellation is added (D-03)"
    - "A failed pax8-daily run sets sync_schedules.last_status='failed'/last_error only — no new alert/notification path is added (D-05)"
  artifacts:
    - path: "migrations/096_pax8_daily_schedule.sql"
      provides: "Idempotent seed of the pax8-daily sync_schedules row"
      contains: "pax8-daily"
    - path: "lib/services/sync-scheduler.ts"
      provides: "pax8-daily dispatch branch + sync_type union member"
      contains: "pax8-daily"
  key_links:
    - from: "lib/services/sync-scheduler.ts (pax8-daily branch)"
      to: "getPax8SyncService().fullSync"
      via: "lazy dynamic import + await call with 'scheduled'"
      pattern: "getPax8SyncService\\(\\)\\.fullSync\\('scheduled'\\)"
    - from: "lib/services/sync-scheduler.ts (pax8-daily branch)"
      to: "integration_settings"
      via: "SELECT disabled ... WHERE key = 'pax8'"
      pattern: "integration_settings WHERE key = 'pax8'"
---

<objective>
Wire the already-complete `Pax8SyncService.fullSync()` (built in Phases 11-12) into the
daily cron scheduler, and make a disabled PAX8 toggle actually stop scheduled runs.

Purpose: Delivers PAX8-07 (daily scheduled sync) and the scheduler-side half of PAX8-09
(disable enforcement). PAX8 becomes the first Pulse integration where the DB toggle gates
an *action* (a scheduled run), not just health-check display.
Output: A seeded `pax8-daily` schedule row, a dual-guarded dispatch branch in
`executeScheduledSync`, and a CLAUDE.md note recording the new gating precedent.
</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/13-scheduler-admin-toggle/13-CONTEXT.md
@.planning/phases/13-scheduler-admin-toggle/13-PATTERNS.md

<interfaces>
<!-- Contracts the executor needs. Do not re-derive from the codebase. -->

From lib/services/pax8-sync-service.ts:
  isSyncInProgress(): boolean
  async fullSync(triggeredBy = 'manual'): Promise<Pax8SyncResult>   // call with 'scheduled'
  getPax8SyncService(): Pax8SyncService   // factory export

From lib/services/pax8-factory.ts:
  isPax8Configured(): boolean   // true only when PAX8_CLIENT_ID && PAX8_CLIENT_SECRET set

From lib/services/sync-scheduler.ts:
  - line 8: `import { postgresClient } from './postgres-client';`  (already imported — reuse, no new import)
  - line 25: the sync_type union that must be extended with 'pax8-daily'
  - lines 445-457: the appgate-sessions/appgate-daily branch = direct template for guard style
  - createDefaultSchedules() / defaultSchedules array (~line 168-310): DO NOT add pax8 here — virgin-table only

DB toggle query shape (from integration-health.ts getDbDisabledKeys, lines 295-308):
  `SELECT disabled FROM integration_settings WHERE key = 'pax8'`  (single-key form)
  Read result as: rows[0]?.disabled === true  (no row => not disabled => runs — correct default)
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Seed the pax8-daily schedule via migration 096</name>
  <files>migrations/096_pax8_daily_schedule.sql</files>
  <read_first>
    - migrations/089_appgate_tables.sql (lines 136-150 — the exact INSERT ... SELECT ... WHERE NOT EXISTS precedent to mirror)
    - migrations/090_ticket_reconcile_schedule.sql (the alternate ON CONFLICT style — do NOT use; 089's WHERE NOT EXISTS is the chosen precedent per D-discretion note)
    - scripts/apply-migrations.sh (how migrations are applied to an existing dev DB)
    - .planning/phases/13-scheduler-admin-toggle/13-PATTERNS.md (migration section, exact target SQL)
  </read_first>
  <action>
    Create `migrations/096_pax8_daily_schedule.sql` (096 is the next number — highest existing is 095_pax8_order_items_partner_cost_numeric.sql). Add a header comment explaining this covers existing installs because sync-scheduler.createDefaultSchedules() only seeds a virgin sync_schedules table, and that sync_schedules has no unique constraint on name so the seed is guarded with NOT EXISTS.

    Write a single `INSERT INTO sync_schedules (id, name, description, cron_expression, sync_type, is_enabled) SELECT ... WHERE NOT EXISTS (SELECT 1 FROM sync_schedules WHERE name = 'PAX8 Daily Sync')` with these exact literal values:
    - id: 'pax8-daily'
    - name: 'PAX8 Daily Sync'
    - description: 'Full PAX8 sync — companies, subscriptions, products, orders, and company matching, daily at 4 AM.'
    - cron_expression: '0 4 * * *' (per D-04 — 4:00 AM, grouping with the backend-reconciliation cluster)
    - sync_type: 'pax8-daily'
    - is_enabled: false (per D-discretion — every new integration schedule ships disabled; an admin opts in via the schedule editor)

    Use the 089 `WHERE NOT EXISTS` style, NOT 090's `ON CONFLICT (id) DO NOTHING`. All values are static literals — no parameters, no string interpolation of any input (this keeps the seed non-injectable, see threat T-13-02).

    Then apply the migration to the running dev DB: `docker exec -i pulse-postgres psql -U pulse_user -d pulse_autotask < migrations/096_pax8_daily_schedule.sql` (POSTGRES_USER defaults to pulse_user and POSTGRES_DB to pulse_autotask per docker-compose.yml; if either differs in .env, read the real value from .env first). Re-running the file must be a no-op (idempotent) — verify by running it twice.
  </action>
  <verify>
    <automated>docker exec pulse-postgres psql -U pulse_user -d pulse_autotask -tAc "SELECT id, cron_expression, sync_type, is_enabled FROM sync_schedules WHERE sync_type = 'pax8-daily'"</automated>
    Expected single row: `pax8-daily|0 4 * * *|pax8-daily|f`
  </verify>
  <acceptance_criteria>
    - The file `migrations/096_pax8_daily_schedule.sql` exists.
    - `grep -c "pax8-daily" migrations/096_pax8_daily_schedule.sql` returns >= 2 (id + sync_type).
    - The migration contains `WHERE NOT EXISTS (SELECT 1 FROM sync_schedules WHERE name = 'PAX8 Daily Sync')` and does NOT contain `ON CONFLICT`.
    - The migration contains `'0 4 * * *'` and `is_enabled` seeded false (literal `false` in the SELECT projection).
    - Running the automated query returns exactly one row: sync_type `pax8-daily`, cron `0 4 * * *`, is_enabled `f`.
    - Applying the file a second time changes no rows (count of pax8-daily rows stays 1).
  </acceptance_criteria>
  <done>The pax8-daily schedule row exists in sync_schedules with cron 0 4 * * * and is_enabled=false, seeded idempotently.</done>
</task>

<task type="auto">
  <name>Task 2: Add dual-guarded pax8-daily dispatch branch to executeScheduledSync</name>
  <files>lib/services/sync-scheduler.ts</files>
  <read_first>
    - lib/services/sync-scheduler.ts (line 25 sync_type union; lines 413-419 engagement-daily inline-config-guard analog; lines 445-457 appgate dual-branch analog; lines 466-476 success-status update; the defaultSchedules array ~168-310)
    - lib/services/pax8-factory.ts (isPax8Configured export + exact env var names)
    - lib/services/pax8-sync-service.ts (getPax8SyncService + fullSync signature, line 74)
    - lib/services/integration-health.ts (getDbDisabledKeys lines 295-308 — the integration_settings query shape to adapt)
    - .planning/phases/13-scheduler-admin-toggle/13-PATTERNS.md (sync-scheduler section, exact target branch)
  </read_first>
  <action>
    Two edits, per D-01 and D-03:

    (1) Extend the `sync_type` union at line 25 by adding the literal `'pax8-daily'` (append to the existing union — it currently ends with `... | 'appgate-sessions' | 'appgate-daily' | 'tickets-reconcile'`). This is the only place the type needs extending.

    (2) Add a new `else if (config.sync_type === 'pax8-daily')` branch inside `executeScheduledSync`, placed immediately after the appgate branch (after line 457) to keep integration-toggle blocks together. The branch performs TWO independent guards before running, matching the lazy-dynamic-import style of the appgate branch:
      - Lazy `await import('@/lib/services/pax8-factory')` for `isPax8Configured`. If NOT configured: `console.log('[SCHEDULER] Skipping pax8-daily — PAX8 not configured')` and do nothing else.
      - Otherwise query `postgresClient.query<{ disabled: boolean }>("SELECT disabled FROM integration_settings WHERE key = 'pax8'")` (postgresClient is already imported at line 8 — do NOT add an import; use a constant literal SQL string, no interpolation). If `rows[0]?.disabled === true`: `console.log('[SCHEDULER] Skipping pax8-daily — PAX8 disabled via /admin/integrations')` and do nothing else.
      - Otherwise lazy `await import('@/lib/services/pax8-sync-service')` for `getPax8SyncService`, then `await getPax8SyncService().fullSync('scheduled')`.

    Constraints (do NOT violate):
    - Do NOT add a pax8-daily entry to the `defaultSchedules` array / createDefaultSchedules() — the seed lives only in migration 096 (Task 1).
    - Do NOT add mid-flight cancellation to fullSync's per-entity loop (D-03 — a sync already running is allowed to finish; the guard only prevents the NEXT tick from starting).
    - Do NOT add a failure alert / Teams webhook (D-05 — a failed run just sets sync_schedules.last_status='failed'/last_error via the existing shared success/failure path; leave that path untouched).
    - Do NOT modify getDbDisabledKeys()/applyDisableOverlay() in integration-health.ts, and do NOT extract a shared helper (D-01 — this is a PAX8-only inline check).
    - Do NOT touch any other branch in the switch.
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - `npx tsc --noEmit --pretty` passes (proves `'pax8-daily'` is in the union and the branch type-checks).
    - `grep -c "pax8-daily" lib/services/sync-scheduler.ts` returns >= 2 (union member + branch condition).
    - The file contains `getPax8SyncService().fullSync('scheduled')`.
    - The file contains `integration_settings WHERE key = 'pax8'` and both skip log strings: `PAX8 not configured` and `PAX8 disabled via /admin/integrations`.
    - `grep -n "pax8-daily" lib/services/sync-scheduler.ts` shows NO match inside the defaultSchedules array line range (the seed must not be added in-code).
    - The pax8-daily branch calls fullSync with the literal argument `'scheduled'` (not `'manual'`).
  </acceptance_criteria>
  <done>executeScheduledSync dispatches pax8-daily to fullSync('scheduled') only when PAX8 is both configured and not DB-disabled; tsc passes.</done>
</task>

<task type="auto">
  <name>Task 3: Record the DB-toggle-gates-action precedent in CLAUDE.md</name>
  <files>CLAUDE.md</files>
  <read_first>
    - CLAUDE.md ("Operator config" > "Integration disable" section, and the "Watch out for" section)
    - .planning/phases/13-scheduler-admin-toggle/13-CONTEXT.md (canonical_refs note flagging this as a behavior precedent)
  </read_first>
  <action>
    In CLAUDE.md's "Operator config" > "Integration disable" section, add a short note that until now the DB toggle (`integration_settings`) only suppressed health-check *display*, and that PAX8 is the first integration where disabling it actually stops an action: the `pax8-daily` scheduler branch skips `fullSync()` and `POST /api/pax8/sync` returns 403 when `key='pax8'` is disabled. Keep it to 2-3 sentences; do not restructure the section or duplicate content from ARCHITECTURE.md. This is documentation only — no behavior change.
  </action>
  <verify>
    <automated>grep -in "pax8" CLAUDE.md</automated>
  </verify>
  <acceptance_criteria>
    - `grep -in "pax8" CLAUDE.md` returns at least one line inside the Operator config / Integration disable area.
    - The note mentions both the scheduler skip and the 403 on the manual route.
    - No other CLAUDE.md section is restructured (only the Operator config note is added).
  </acceptance_criteria>
  <done>CLAUDE.md documents PAX8 as the first integration whose DB toggle gates an action, not just display.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| cron scheduler → Pax8SyncService | Scheduled dispatch must respect the operator's disable toggle before triggering a sync |
| migration seed → Postgres | Static DDL/DML applied to the sync_schedules table on an existing volume |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-13-01 | Elevation of Privilege | pax8-daily scheduler branch | mitigate | Branch checks `integration_settings.key='pax8'` disabled flag AND `isPax8Configured()` before calling fullSync; a disabled toggle stops the next tick (Task 2) |
| T-13-02 | Tampering (SQL injection) | migration 096 seed | mitigate | Seed is static literals only — no parameters, no interpolation of any input; nothing user-controlled reaches the INSERT (Task 1) |
| T-13-03 | Denial of Service | repeated scheduled/manual runs | accept | Existing `isSyncInProgress()` 409 guard makes a running sync atomic (D-03); no new surface added this plan |
| T-13-SC | Tampering | npm/pip/cargo installs | accept | No package installs in this plan — all four edits use existing dependencies; no Package Legitimacy Gate needed |
</threat_model>

<verification>
- `npx tsc --noEmit --pretty` passes.
- `SELECT * FROM sync_schedules WHERE sync_type='pax8-daily'` returns one is_enabled=false row.
- sync-scheduler.ts pax8-daily branch is present with both guards and calls fullSync('scheduled').
- Live cron firing + disable-skip behavior is proven in the Wave 2 verification plan (13-03).
</verification>

<success_criteria>
- Migration 096 seeds the pax8-daily row idempotently (is_enabled false, cron 0 4 * * *).
- executeScheduledSync has a dual-guarded pax8-daily branch calling fullSync('scheduled').
- Disabling PAX8 in integration_settings makes the branch skip with a distinct log.
- No changes to defaultSchedules, no cancellation logic, no failure alerts, no shared helper.
</success_criteria>

<output>
Create `.planning/phases/13-scheduler-admin-toggle/13-01-SUMMARY.md` when done.
</output>
