# Phase 13: Scheduler & Admin Toggle - Context

**Gathered:** 2026-07-11
**Status:** Ready for planning

<domain>
## Phase Boundary

PAX8 sync runs automatically once a day like every other Pulse integration
(`pax8-daily` cron entry in `sync-scheduler.ts`), and can be turned on/off from
`/admin/integrations` without a container restart. Unlike every existing
integration's toggle today, disabling PAX8 must actually stop future sync
runs (not just suppress health-check display) — this phase introduces that
enforcement, scoped to PAX8 only. No `/pax8` UI, no manual company-match
resolution (Phase 14). `Pax8SyncService.fullSync()` already performs the
complete companies + subscriptions + products + orders + company-matching
sequence (built in Phases 11-12) — this phase only wires it into the
scheduler and the admin toggle, it does not change sync logic itself.

</domain>

<decisions>
## Implementation Decisions

### Toggle Enforcement Scope
- **D-01:** The disabled-check is a PAX8-only inline check inside
  `executeScheduledSync`'s `pax8-daily` case — query `integration_settings`
  for `key='pax8'` before calling `fullSync()`, skip + log if disabled.
  Mirrors the `isAppgateConfigured()`/`isMsgraphConfigured()` guard style
  already used for `appgate-daily`/`engagement-daily`, just checking the DB
  toggle instead of env vars. Explicitly **not** a general mechanism applied
  to all integrations — that would change behavior for ~10 existing
  integrations in a phase whose requirements (PAX8-07, PAX8-09) only ask for
  PAX8. Do not touch the shared `executeScheduledSync` switch's other cases.
- **D-02:** The manual trigger route (`POST /api/pax8/sync`) must also
  refuse to run while PAX8 is disabled — return 403 with a clear message.
  "Disabled" means fully off; no side door via the manual route while the
  toggle says off.
- **D-03:** If PAX8 gets disabled while a sync is already in progress, let
  it finish normally — no mid-flight cancellation. The disabled flag only
  prevents the *next* scheduled tick or *next* manual trigger from
  starting. `isSyncInProgress()`'s existing 409 guard already treats a
  running sync as atomic; don't add cancellation logic to `fullSync()`'s
  per-entity loop for this rare edge case.

### Daily Sync Timing
- **D-04:** `pax8-daily` cron fires at **4:00 AM** (`0 4 * * *`), grouping
  with the earlier "backend reconciliation" cluster (`contract-services` at
  4am, `tickets-reconcile` at 4:30am) rather than the 6-7:30am
  "reporting/digest" cluster (`engagement-daily`, `zoom-daily`,
  `ticket-digest-daily`, `integration-health`).

### Failure Visibility
- **D-05:** No dedicated alert/notification on sync failure. A failed
  `pax8-daily` run sets `sync_schedules.last_status = 'failed'` /
  `last_error` — visible on `/admin` only, same silent-failure pattern
  already used by Veeam full sync, contract-services, and most other
  integrations. Do not add a new Teams webhook alert path for this phase —
  that's a bigger cross-cutting concern than PAX8-07/PAX8-09 ask for.

### Claude's Discretion
- **Migration for seeding the `sync_schedules` row** — follow the exact
  precedent in `migrations/089_appgate_tables.sql`: seed via
  `INSERT INTO sync_schedules (...) SELECT ... WHERE NOT EXISTS (SELECT 1
  FROM sync_schedules WHERE name = '...')` inside the phase's own numbered
  migration (next number after `095`). `is_enabled` defaults to `false` —
  every existing precedent except one legacy row (`contract-services`)
  ships new schedules disabled by default; an admin must opt in via the
  schedule editor (`/api/sync/schedules`) separately from the
  `integration_settings` toggle. Not discussed explicitly this session
  since it's a clear, unambiguous precedent match — flagging here so the
  planner doesn't re-derive it from scratch.
- **`/admin/integrations` row wiring** — add PAX8 to the
  `checkConfigOnly()` list in `checkIntegrationHealth()`
  (`lib/services/integration-health.ts`), following the `qbo`/`appgate`
  entries exactly (key, display name, category, required env vars). This
  is what makes PAX8 appear as a toggleable row per SC#2 — mechanical, not
  discussed as it has no meaningful alternative given the established
  pattern.
- **Env var names for the `checkConfigOnly()` call** — confirm the exact
  `PAX8_*` env var names against `lib/services/pax8-factory.ts`'s
  `isPax8Configured()` implementation (established in Phase 10) rather than
  guessing from convention.

</decisions>

<canonical_refs>
## Canonical References

**Downstream agents MUST read these before planning or implementing.**

### Project scope & requirements
- `.planning/PROJECT.md` — Current Milestone: v2.0 PAX8 Integration section
- `.planning/REQUIREMENTS.md` — PAX8-07, PAX8-09 (this phase's requirement
  IDs); traceability table confirms both map to Phase 13
- `.planning/ROADMAP.md` — Phase 13 section (goal, 4 success criteria,
  depends on Phase 12)

### Prior phase foundation this phase builds on
- `lib/services/pax8-sync-service.ts` — `Pax8SyncService.fullSync()`
  (line ~74) already runs companies → subscriptions → products → orders →
  company-matching in one call (Phases 11-12 built this); this phase's
  scheduler entry calls `getPax8SyncService().fullSync('scheduled')`
  directly, no new orchestration needed
- `app/api/pax8/sync/route.ts` — existing fire-and-forget manual trigger
  (`POST`) and status (`GET`); D-02 requires adding a disabled-check to the
  `POST` handler
- `lib/services/pax8-factory.ts` — `isPax8Configured()` from Phase 10; the
  scheduler case should check this too (like `appgate-daily` does with
  `isAppgateConfigured()`), in addition to the new `integration_settings`
  disabled check from D-01
- `migrations/091_pax8_tables.sql` through `095_...` — existing PAX8
  schema; next migration number for this phase's `sync_schedules` seed is
  096

### Existing patterns to follow
- `lib/services/sync-scheduler.ts` — `executeScheduledSync` (line ~385):
  the `appgate-sessions`/`appgate-daily` and `engagement-daily` branches are
  the direct template for the new `pax8-daily` branch, including the
  `isXConfigured()` skip-with-log style; `defaultSchedules` array (line
  ~180) only seeds on a virgin table (`createDefaultSchedules()` — "already
  exist, skipping defaults") so the actual seed for an existing DB must go
  through the migration, not this array
- `migrations/089_appgate_tables.sql` (lines 137-150) — exact precedent for
  seeding a new `sync_schedules` row via `INSERT ... WHERE NOT EXISTS`
  inside a feature migration, `is_enabled: false`
- `lib/services/integration-health.ts` — `checkConfigOnly()` (line ~239)
  and the `checkConfigOnly('qbo', ...)`/`checkConfigOnly('appgate', ...)`
  call sites (line ~344-347) in `checkIntegrationHealth()` — template for
  adding the PAX8 row; `getDbDisabledKeys()` (line ~295) and
  `applyDisableOverlay()` (line ~310) show the exact `integration_settings`
  query shape D-01's scheduler check should reuse
- `app/api/admin/integrations/route.ts` — existing `integration_settings`
  CRUD (list, toggle with `disabled_by`/`disabled_at`/`disabled_reason`);
  no changes needed here, PAX8 just needs a row like any other integration
- `CLAUDE.md` "Operator config" section — documents the current two-source
  disable model (env var + DB) and that "live auth checks still run" for
  disabled integrations; note D-01 makes PAX8 the **first** integration
  where the DB toggle actually gates something beyond display — call this
  out explicitly if updating this doc during/after the phase, since it's a
  behavior precedent, not just a new integration following an old one

</canonical_refs>

<code_context>
## Existing Code Insights

### Reusable Assets
- `getPax8SyncService().fullSync('scheduled')` — the entire scheduled-run
  entry point; no new sync orchestration code needed in this phase
- `getDbDisabledKeys()` / `applyDisableOverlay()` pattern in
  `integration-health.ts` — the exact SQL shape (`SELECT key FROM
  integration_settings WHERE disabled = true`) to reuse for D-01's
  scheduler-side check (querying for `key = 'pax8'` specifically, or
  reusing the existing helper if it's exported/exportable)

### Established Patterns
- New integration schedules always ship `is_enabled: false`, seeded via
  `INSERT ... WHERE NOT EXISTS` in that feature's own migration (see
  `089_appgate_tables.sql`) — never rely on the in-code `defaultSchedules`
  array to reach an already-provisioned Postgres volume
- `is<Name>Configured()` env-var guard + `integration_settings.disabled`
  DB guard are two independent checks; both should gate `pax8-daily`
  (config missing → skip + log distinctly from disabled → skip + log)
- `/admin/integrations` rows come from `checkIntegrationHealth()`'s
  `checkConfigOnly()` calls, not a separate registry — adding PAX8 there
  is the entire SC#2 requirement

### Integration Points
- `lib/services/sync-scheduler.ts` — new `pax8-daily` branch in
  `executeScheduledSync`
- `app/api/pax8/sync/route.ts` — `POST` handler gains a disabled check
- `lib/services/integration-health.ts` — new `checkConfigOnly('pax8', ...)`
  call site in `checkIntegrationHealth()`
- New migration `096_pax8_daily_schedule.sql` (or similar name) — seeds the
  `sync_schedules` row per D-04's cron expression

</code_context>

<specifics>
## Specific Ideas

No UI mockups or specific behavioral scripts — this is scheduler + toggle
wiring, no new page. The five numbered decisions (D-01 through D-05) plus
the three Claude's Discretion items are the concrete specifics: PAX8-scoped
(not general) toggle enforcement covering both the cron and the manual
route, let-it-finish semantics on mid-run disable, 4am cron slot, and
silent (dashboard-only) failure visibility matching existing precedent.

</specifics>

<deferred>
## Deferred Ideas

None — discussion stayed within Phase 13's scope. (The `/pax8` UI and
manual company-match resolution are already sequenced into Phase 14 per
ROADMAP.md/REQUIREMENTS.md, not deferred from this discussion. Generalizing
the toggle-gates-scheduler mechanism to other integrations, discussed as
an explicit alternative under Toggle Enforcement Scope, was deliberately
rejected as out of scope for this phase — not deferred to a specific future
phase, just noted as a possible future direction if another integration
needs the same guarantee.)

</deferred>

---

*Phase: 13-scheduler-admin-toggle*
*Context gathered: 2026-07-11*
