---
phase: 11-company-catalog-subscription-sync
plan: 02
type: execute
wave: 2
depends_on: [11-01]
files_modified:
  - lib/services/pax8-sync-service.ts
  - app/api/pax8/sync/route.ts
autonomous: true
requirements: [PAX8-03, PAX8-04, PAX8-05, PAX8-08]

must_haves:
  truths:
    - "A full sync upserts every current PAX8 company into pax8_companies (PAX8-03)"
    - "A full sync upserts every current PAX8 subscription with product, quantity, billing term, customer price, and partner cost (PAX8-04)"
    - "The catalog table (pax8_products) holds a readable name + category for every product referenced by a subscription, populated from the full product list — not bare SKU IDs (PAX8-05, D-01)"
    - "Referenced-but-unknown products are resolved by filtering the full product list fetched once per sync run to subscription-referenced IDs, in the same sync pass — not a separate two-pass batch step; PAX8 has no per-SKU product-detail endpoint, so this is the single-pass mechanism that satisfies D-02's intent (D-02)"
    - "Companies/subscriptions/products no longer returned by PAX8 are soft-deleted (is_deleted=true, deleted_at set), never hard-removed (D-05/D-06)"
    - "fullSync is the only sync mode (no incrementalSync); PAX8 exposes no modified-since/delta filter for companies, subscriptions, or products, so every entity type falls back to full sync per D-07's per-entity-type fallback clause (D-07)"
    - "Every fullSync run performs the complete tombstone/reconciliation pass (diff current PAX8 IDs against non-deleted Postgres rows) as an unconditional step, satisfying D-08's periodic full-reconciliation requirement on every invocation (D-08)"
    - "No code path in the sync service issues a PAX8 API write; it calls only the client's read methods (PAX8-08)"
    - "POST /api/pax8/sync starts the sync fire-and-forget and returns immediately; a concurrent trigger returns 409"
  artifacts:
    - path: "lib/services/pax8-sync-service.ts"
      provides: "Pax8SyncService.fullSync orchestrating companies→subscriptions→products with tombstone reconciliation"
      exports: ["Pax8SyncService", "getPax8SyncService"]
    - path: "app/api/pax8/sync/route.ts"
      provides: "fire-and-forget POST trigger + GET status"
      exports: ["POST", "GET"]
  key_links:
    - from: "lib/services/pax8-sync-service.ts"
      to: "getPax8Client()"
      via: "constructor: this.client = client ?? getPax8Client()"
      pattern: "getPax8Client"
    - from: "lib/services/pax8-sync-service.ts"
      to: "postgresClient"
      via: "parameterized upsert + UUID-array tombstone"
      pattern: "postgresClient\\.query"
    - from: "pax8_subscriptions.product_id"
      to: "pax8_products.id"
      via: "readable-name join (D-01 referenced-only catalog)"
      pattern: "product_id"
    - from: "app/api/pax8/sync/route.ts"
      to: "getPax8SyncService().fullSync"
      via: "fire-and-forget call"
      pattern: "fullSync"
---

<objective>
Build the read-only PAX8 current-state sync: companies, subscriptions (with dual
cost), and the referenced-only product catalog — plus the fire-and-forget trigger
route. This is the phase's core deliverable.

Purpose: Populate pax8_companies / pax8_subscriptions / pax8_products so a
subscription can be shown with a readable product name and category (not a bare
SKU), with removed entities soft-deleted and zero writes back to PAX8.

Output: `lib/services/pax8-sync-service.ts` and `app/api/pax8/sync/route.ts`.
</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/11-company-catalog-subscription-sync/11-CONTEXT.md
@.planning/phases/11-company-catalog-subscription-sync/11-PATTERNS.md
@.planning/phases/11-company-catalog-subscription-sync/11-01-SUMMARY.md

<interfaces>
<!-- From Plan 01 (already built): -->
<!--   Pax8Client.listAllCompanies(): Promise<Pax8Company[]> -->
<!--   Pax8Client.listAllSubscriptions(): Promise<Pax8Subscription[]> -->
<!--   Pax8Client.listAllProducts(): Promise<Pax8Product[]> -->
<!--   getPax8Client(): Pax8Client   (lib/services/pax8-factory.ts) -->
<!--   Pax8SyncResult / Pax8EntitySyncResult (lib/types/pax8.ts) -->
<!-- Subscription carries: price, partnerCost, currencyCode, productName, -->
<!--   companyId, productId, quantity, billingTerm, status, startDate -->
<!-- Product carries: name, sku, vendorSku, vendorName (NO category field) -->
<!-- No product-detail (GET /products/{id}) endpoint exists — the catalog must -->
<!--   be fetched as a full list and looked up in memory by id. -->
<!-- pax8_* tables all have UUID primary keys + is_deleted/deleted_at/synced_at. -->
<!-- Generic sync_history table (migrations/001) columns: entity_type, sync_type -->
<!--   (must be 'full'|'incremental'|'entity-specific'), status ('started'| -->
<!--   'in_progress'|'completed'|'failed'), started_at, completed_at, -->
<!--   records_added, records_updated, records_deleted, error_message, triggered_by -->
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Build pax8-sync-service.ts (companies + subscriptions + referenced catalog + tombstone)</name>
  <files>lib/services/pax8-sync-service.ts</files>
  <read_first>
    - lib/services/appgate-sync-service.ts (PRIMARY analog: class + `syncing` guard + singleton getter; the UUID-keyed upsert + `id <> ALL($1::uuid[])` tombstone in syncAppliances, lines ~185-244; run()/persistHistory shape)
    - lib/services/qbo-sync-service.ts (per-entity try/catch returning a result rather than throwing; tombstone query shape)
    - lib/services/veeam-sync-service.ts lines 78-165 (generic sync_history insert 'started' → update completed/failed using only base columns entity_type/sync_type/status/records_added/records_deleted/error_message/triggered_by)
    - lib/services/postgres-client.ts (postgresClient.query signature; note softDeleteMany casts ::bigint[] and is NOT usable for UUID ids — write the tombstone query raw)
    - lib/services/pax8-client.ts + lib/services/pax8-factory.ts (getPax8Client, the new list-all methods)
    - migrations/091_pax8_tables.sql + migrations/092_pax8_subscription_costs.sql (exact column names for pax8_companies, pax8_subscriptions, pax8_products)
    - lib/types/pax8.ts (Pax8SyncResult/Pax8EntitySyncResult, entity field names)
  </read_first>
  <action>
    Create `Pax8SyncService` mirroring `appgate-sync-service.ts`'s class shape: a
    private `client: Pax8Client` set in the constructor via `this.client = client
    ?? getPax8Client()`, a private `syncing = false` guard, an `isSyncInProgress():
    boolean`, and a module-level `getPax8SyncService()` singleton. Expose one
    public entry point `async fullSync(triggeredBy = 'manual'): Promise<Pax8SyncResult>`
    — there is NO incrementalSync because no PAX8 entity supports a modified-since
    filter (D-07 resolves to full-sync-only for all entity types). fullSync must:
    throw if `this.syncing` is already true; set the guard; insert a
    `sync_history` row with `entity_type='pax8'`, `sync_type='full'`,
    `status='started'`, `triggered_by=triggeredBy` (base columns only — do not use
    entity_details); then run three entity syncs IN THIS ORDER inside try/catch,
    collecting a Pax8EntitySyncResult each, and finally update the sync_history row
    to 'completed' (or 'failed' if any entity errored) with records_added =
    total upserted and records_deleted = total tombstoned, and clear the guard in a
    finally block.

    syncCompanies(): call `this.client.listAllCompanies()`; for each company upsert
    into pax8_companies via a parameterized `INSERT ... ON CONFLICT (id) DO UPDATE
    SET ... synced_at = NOW(), is_deleted = false, deleted_at = NULL` (mapping
    name/external_id/website/status/city/state_or_province/postal_code/country and
    storing the full object in raw_payload); collect seen ids; after the loop
    tombstone with `UPDATE pax8_companies SET is_deleted=true, deleted_at=NOW()
    WHERE is_deleted=false AND id <> ALL($1::uuid[])` (skip the tombstone UPDATE
    when zero ids were seen, to avoid deleting everything on an empty/failed pull).

    syncSubscriptions(): call `this.client.listAllSubscriptions()`; for each
    subscription upsert into pax8_subscriptions mapping pax8_company_id (from
    companyId), product_id (from productId), quantity, billing_term, status,
    start_date, and the Plan-01 columns price (from `price`), partner_cost (from
    `partnerCost`), currency (from `currencyCode` ?? 'USD') plus raw_payload;
    accumulate every referenced productId into an in-memory Set and return it
    alongside the entity result so syncProducts can consume it; collect seen ids;
    tombstone unseen subscriptions with the same `<> ALL($1::uuid[])` pattern.

    syncProducts(referencedProductIds: Set<string>): because PAX8 exposes NO
    single-product-detail endpoint, call `this.client.listAllProducts()` once, build
    a Map keyed by product id, then upsert ONLY the products whose id is in
    referencedProductIds (this satisfies D-01 "referenced-only catalog" — the full
    list is fetched into memory but only referenced rows are STORED). Map name from
    `name`, sku from `sku`, vendor_sku from `vendorSku`, and category from
    `(product as any).category ?? product.vendorName ?? null` (PAX8's product list
    endpoint has no dedicated category field per Phase 11 research; vendorName is
    the catalog grouping — the full object is kept in raw_payload so a true category
    can be backfilled later). If a referenced product id is not present in the
    fetched catalog (e.g. discontinued), log it and continue — do NOT fabricate a
    row and do NOT drop the subscription (the subscription's raw_payload retains
    productName). The "seen" set for the products tombstone is referencedProductIds:
    `UPDATE pax8_products SET is_deleted=true, deleted_at=NOW() WHERE is_deleted=false
    AND id <> ALL($1::uuid[])` (skip when the set is empty) — this keeps discontinued
    catalog rows soft-deleted per D-06 while never hard-deleting them.

    Every entity method wraps its own logic in try/catch and returns a
    Pax8EntitySyncResult { entity, success, upserted, tombstoned, durationMs,
    error? } rather than throwing to the orchestrator (qbo/itglue precedent). Use
    ONLY parameterized queries ($1,$2,...) — never string-interpolate values into
    SQL. The service must call only the client's read methods; it must never issue
    an HTTP request itself and never call a mutating PAX8 verb (PAX8-08).
  </action>
  <acceptance_criteria>
    - Exports `Pax8SyncService` and `getPax8SyncService`
    - `fullSync` exists; there is no `incrementalSync` (full-sync-only per D-07)
    - Products are upserted only when referenced by a subscription (D-01) and the full catalog is fetched via listAllProducts (no /products/{id} call)
    - category is populated from `category ?? vendorName` and each of the three tables' tombstone uses `id <> ALL($1::uuid[])`
    - `grep -cE "<> ALL\(\\\$1::uuid\[\]\)" lib/services/pax8-sync-service.ts` returns 3 (companies, subscriptions, products)
    - `grep -nE "method:[[:space:]]*['\"](POST|PUT|PATCH|DELETE)|fetch\(" lib/services/pax8-sync-service.ts` returns nothing (the service issues no HTTP itself; PAX8-08)
    - No SQL string interpolation: `grep -nE "query\(\s*[\`'\"].*\\\$\{" lib/services/pax8-sync-service.ts` returns nothing
    - `npx tsc --noEmit --pretty` passes
  </acceptance_criteria>
  <verify>
    <automated>npx tsc --noEmit --pretty && ! grep -nE "method:[[:space:]]*['\"](POST|PUT|PATCH|DELETE)" lib/services/pax8-sync-service.ts && ! grep -nE "[^.]fetch\(" lib/services/pax8-sync-service.ts && echo OK</automated>
  </verify>
  <done>Pax8SyncService.fullSync populates all three tables read-only with soft-delete reconciliation and referenced-only catalog; compiles clean; no PAX8 writes.</done>
</task>

<task type="auto">
  <name>Task 2: Add the fire-and-forget /api/pax8/sync route</name>
  <files>app/api/pax8/sync/route.ts</files>
  <read_first>
    - app/api/itglue/sync/route.ts (cleanest fire-and-forget analog: 409 guard, background .catch, GET status with counts + recent history)
    - app/api/veeam/sync/route.ts lines 13-46 (409 in-progress guard shape)
    - lib/services/pax8-sync-service.ts (getPax8SyncService, isSyncInProgress, fullSync — built in Task 1)
    - middleware.ts (public-route allowlist — confirm /api/pax8/sync is NOT added; it stays behind the session-cookie check like other sync routes, unlike webhooks)
  </read_first>
  <action>
    Create `app/api/pax8/sync/route.ts` exporting `POST` and `GET`. POST: read the
    JSON body defensively (`await req.json().catch(() => ({}))`), take
    `triggeredBy = body.triggeredBy || 'manual'`, get the service via
    `getPax8SyncService()`, return `NextResponse.json({ error: 'Sync already in
    progress' }, { status: 409 })` if `isSyncInProgress()`, otherwise call
    `svc.fullSync(triggeredBy).catch(err => console.error('[Pax8Sync] Background
    sync error:', err.message))` WITHOUT awaiting (fire-and-forget) and return
    `NextResponse.json({ ok: true, message: 'PAX8 sync started' })`. GET: return
    `{ inProgress: svc.isSyncInProgress(), counts, history }` where counts are
    non-deleted row counts from pax8_companies/pax8_subscriptions/pax8_products
    (single parameterized/constant query) and history is the last 10 `sync_history`
    rows WHERE `entity_type='pax8'` ordered by started_at DESC, wrapped in
    try/catch returning `{ error }` with status 500 on failure. Do NOT add
    `/api/pax8/sync` to middleware.ts's public-route allowlist — it must remain
    behind the existing session-cookie check.
  </action>
  <acceptance_criteria>
    - Exports both `POST` and `GET`
    - POST returns 409 when `isSyncInProgress()` is true and calls `fullSync` without `await` otherwise
    - `grep -c "pax8/sync\|/api/pax8" middleware.ts` returns 0 (route is not made public)
    - `npx tsc --noEmit --pretty` passes
  </acceptance_criteria>
  <verify>
    <automated>npx tsc --noEmit --pretty && grep -q "export async function POST" app/api/pax8/sync/route.ts && grep -q "export async function GET" app/api/pax8/sync/route.ts && ! grep -qE "/api/pax8" middleware.ts && echo OK</automated>
  </verify>
  <done>POST /api/pax8/sync triggers fullSync fire-and-forget with a 409 guard; GET reports status/counts/history; route stays session-gated.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Pulse server → PAX8 API | Outbound read-only GET calls via getPax8Client() |
| PAX8 response → Postgres | Untrusted external data written via bulk upsert |
| Client (browser/session) → /api/pax8/sync | Authenticated trigger; session-cookie gated by middleware |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-11-04 | Elevation of Privilege | pax8-sync-service.ts (ROADMAP SC#4 write-invariant) | mitigate | Service calls only client read methods; issues no HTTP itself; grep gate asserts no POST/PUT/PATCH/DELETE and no `fetch(` in the service — upholds PAX8-08 |
| T-11-05 | Tampering (SQL injection) | pax8-sync-service.ts upsert/tombstone | mitigate | All queries parameterized ($1,$2,…); UUID arrays passed as `$1::uuid[]` params, never interpolated; grep gate asserts no `${` template interpolation inside query() |
| T-11-06 | Spoofing/Repudiation | app/api/pax8/sync route | mitigate | Route NOT added to middleware public allowlist; remains behind session-cookie check (matches itglue/veeam sync routes; not a public webhook) |
| T-11-07 | Denial of Service | PAX8 1000/min rate limit during pagination | mitigate | Entities synced sequentially (companies→subscriptions→products), not fanned out concurrently; existing 429 Retry-After backoff in the client's fetchJson is inherited |
| T-11-08 | Denial of Service (self-inflicted) | concurrent sync triggers | mitigate | `syncing` guard + route 409 prevent overlapping runs that would double-hit PAX8 and race the tombstone pass |
| T-11-SC | Tampering | package installs | accept | This plan installs zero npm/pip/cargo packages (native fetch + existing pg) — package legitimacy gate not triggered |
</threat_model>

<verification>
- `npx tsc --noEmit --pretty` passes
- No POST/PUT/PATCH/DELETE method and no bare `fetch(` in pax8-sync-service.ts (PAX8-08)
- Three UUID-array tombstone passes present (companies/subscriptions/products)
- /api/pax8/sync absent from middleware.ts public allowlist
- POST and GET exported from the route
</verification>

<success_criteria>
Pax8SyncService.fullSync reads companies/subscriptions/products from PAX8 (read-only),
upserts them into Postgres with dual cost columns and a referenced-only readable
catalog, soft-deletes anything PAX8 no longer returns, and is triggerable via a
session-gated fire-and-forget POST /api/pax8/sync with a 409 in-progress guard.
</success_criteria>

<output>
Create `.planning/phases/11-company-catalog-subscription-sync/11-02-SUMMARY.md` when done
</output>
