---
phase: 10-pax8-client-auth-foundation
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
  - migrations/091_pax8_tables.sql
autonomous: true
requirements: [PAX8-01, PAX8-02]

must_haves:
  truths:
    - "(D-01) Two-table header/line-item design: pax8_orders (order id, company, order date, total, status) + pax8_order_items (order_id FK, product/SKU, quantity, unit price, line total) — migration 091 creates all six PAX8 tables: pax8_companies, pax8_subscriptions, pax8_products, pax8_orders, pax8_order_items, pax8_company_match_review"
    - "Every table is created with IF NOT EXISTS so the migration is idempotent on re-apply (no error on second run)"
    - "pax8_order_items.order_id is a hard FK to pax8_orders(id) ON DELETE CASCADE (header/line relationship)"
    - "pax8_company_match_review carries candidate_company_ids BIGINT[], match_confidences TEXT[], a hard FK to pax8_companies(id) ON DELETE CASCADE, and a nullable resolved_to_company_id BIGINT REFERENCES companies(id)"
    - "(D-02) No lookback-window bound — no 'earliest synced' or window-config column is added; sync pulls full order history on first sync in a later phase"
    - "(D-03) pax8_orders and pax8_order_items both carry a raw_payload JSONB column as a safety net for PAX8 fields not yet modeled, alongside typed columns"
    - "Monetary columns are NUMERIC(12,2) with a companion currency CHAR(3) DEFAULT 'USD' (D-04); orders/order_items pricing+status columns carry PAX8 Invoice/InvoiceItem semantics, flagged inline (RESEARCH Pitfall 1)"
    - "Each of the four primary entity tables carries raw_payload JSONB + synced_at + is_deleted + deleted_at audit columns and an idx_<table>_is_deleted index"
  artifacts:
    - path: "migrations/091_pax8_tables.sql"
      provides: "PAX8 schema DDL (6 tables, indexes, review-queue comment)"
      contains: "CREATE TABLE IF NOT EXISTS pax8_companies"
  key_links:
    - from: "pax8_order_items.order_id"
      to: "pax8_orders(id)"
      via: "FK ON DELETE CASCADE"
      pattern: "REFERENCES pax8_orders\\(id\\) ON DELETE CASCADE"
    - from: "pax8_company_match_review.resolved_to_company_id"
      to: "companies(id)"
      via: "FK ON DELETE SET NULL"
      pattern: "REFERENCES companies\\(id\\) ON DELETE SET NULL"
    - from: "pax8_company_match_review.pax8_company_id"
      to: "pax8_companies(id)"
      via: "FK ON DELETE CASCADE"
      pattern: "REFERENCES pax8_companies\\(id\\) ON DELETE CASCADE"
---

<objective>
Create the numbered SQL migration that lays down the full PAX8 schema — four
entity tables (companies, subscriptions, products, orders + order_items) plus a
company-match/review queue — all with `IF NOT EXISTS`, ready for Phase 11+ sync
services to populate. Then apply it to the existing dev DB (whose volume already
booted, so migrations do not auto-run) and confirm idempotency.

Purpose: Delivers Phase 10 Success Criterion #4 (the migration exists and creates
the PAX8 tables). Schema-only by design — no sync logic, no matching logic, no
indexes beyond what the `device_link_review` precedent demonstrates. The schema
here is the foundation Phases 11-12 (PAX8-03..06) will populate.

Output: `migrations/091_pax8_tables.sql`, applied and verified against the dev DB.
</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/10-pax8-client-auth-foundation/10-CONTEXT.md
@.planning/phases/10-pax8-client-auth-foundation/10-RESEARCH.md
@.planning/phases/10-pax8-client-auth-foundation/10-PATTERNS.md

# Analog migrations (10-PATTERNS.md quotes the exact line ranges to model)
@migrations/089_appgate_tables.sql
@migrations/080_device_xref_company_id.sql
@migrations/051_create_qbo_tables.sql

<interfaces>
<!-- FK target types verified in the codebase:
       companies.id  -> BIGINT PRIMARY KEY   (migrations/001_initial_schema.sql)
       "user".id     -> TEXT   PRIMARY KEY   (migrations/012_create_auth_tables.sql)
     device_link_review shape (migrations/080) is the verbatim template for
     pax8_company_match_review — map device_external_id->pax8_company_id,
     candidate_ci_ids->candidate_company_ids, resolved_to_ci_id->resolved_to_company_id.
     Apply mechanism (existing dev volume, migrations do NOT auto-run):
       bash scripts/apply-migrations.sh 091_pax8_tables.sql
     which runs: docker exec -i pulse-postgres psql -U pulse_user -d pulse_autotask < <file> -->
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Author migrations/091_pax8_tables.sql</name>
  <files>migrations/091_pax8_tables.sql</files>
  <read_first>
    - migrations/089_appgate_tables.sql (header-comment bullet-list style; primary-entity table shape with `raw JSONB` + `synced_at`/`is_deleted`/`deleted_at` audit columns + `idx_<table>_is_deleted` index — the template for the four pax8_* entity tables)
    - migrations/080_device_xref_company_id.sql (device_link_review, lines ~62-86: copy near-verbatim for pax8_company_match_review — gen_random_uuid() default, BIGINT[]/TEXT[] arrays, three indexes including the partial-unique "one open review per source row", COMMENT ON TABLE)
    - migrations/051_create_qbo_tables.sql (qbo_invoices, lines ~14-33: NUMERIC(12,2) + status + currency precedent for the monetary columns)
    - migrations/001_initial_schema.sql (companies table: id is BIGINT — the FK target type for candidate_company_ids and resolved_to_company_id)
    - .planning/phases/10-pax8-client-auth-foundation/10-CONTEXT.md (D-01..D-04 locked decisions)
    - .planning/phases/10-pax8-client-auth-foundation/10-RESEARCH.md (Pitfall 1: model orders/order_items pricing+status columns on Invoice/InvoiceItem; Open Question 3: keep match_confidences as TEXT[])
  </read_first>
  <action>
    Create `migrations/091_pax8_tables.sql`. Open with a header comment (089-style bullet
    list) mapping PAX8 entities to tables, and an inline note that `pax8_orders`/
    `pax8_order_items` column names carry Invoice/InvoiceItem semantics (total, status,
    unit price) per RESEARCH Pitfall 1 — table names stay orders per D-01, but Phase 12
    sync will source these from PAX8's `/invoices` resource, not `/orders`. Then create
    six tables, all `CREATE TABLE IF NOT EXISTS`:

    1. `pax8_companies`: id UUID PRIMARY KEY (PAX8's own company id), name TEXT NOT NULL,
       external_id TEXT (partner-writable — leave nullable, do not assume name is the only
       join key), website TEXT, status TEXT, city TEXT, state_or_province TEXT,
       postal_code TEXT, country TEXT, raw_payload JSONB, synced_at TIMESTAMPTZ NOT NULL
       DEFAULT NOW(), is_deleted BOOLEAN NOT NULL DEFAULT false, deleted_at TIMESTAMPTZ.

    2. `pax8_products`: id UUID PRIMARY KEY, sku TEXT, vendor_sku TEXT, name TEXT,
       category TEXT, raw_payload JSONB, + the same three audit columns. (No
       altVendorSku — deprecated.) Do NOT add any "referenced only" constraint (keep the
       full-vs-lazy catalog decision open for Phase 11 per CONTEXT Claude's Discretion).

    3. `pax8_subscriptions`: id UUID PRIMARY KEY, pax8_company_id UUID (plain indexed
       column, NOT a hard FK — sync insert order across companies/subscriptions is not
       guaranteed; add a `-- soft ref` comment), product_id UUID (soft ref, matches
       pax8_products.id's UUID type so Phase 11's catalog join needs no cast), quantity
       INTEGER (seat count), billing_term TEXT, status TEXT, start_date TIMESTAMPTZ,
       raw_payload JSONB, + the three audit columns.

    4. `pax8_orders` (header — Invoice-shaped): id UUID PRIMARY KEY, pax8_company_id UUID
       (soft ref, indexed), order_date TIMESTAMPTZ, total NUMERIC(12,2), status TEXT,
       currency CHAR(3) NOT NULL DEFAULT 'USD', raw_payload JSONB, + the three audit columns.

    5. `pax8_order_items` (line — InvoiceItem-shaped): id UUID PRIMARY KEY, order_id UUID
       NOT NULL REFERENCES pax8_orders(id) ON DELETE CASCADE, product_id UUID (soft ref,
       matches pax8_products.id's UUID type — no cast needed for Phase 11/12 joins),
       quantity INTEGER, unit_price NUMERIC(12,2), line_total NUMERIC(12,2),
       currency CHAR(3) NOT NULL DEFAULT 'USD', raw_payload JSONB, + the three audit columns.

    6. `pax8_company_match_review` (copy device_link_review field-for-field): id UUID
       PRIMARY KEY DEFAULT gen_random_uuid(), pax8_company_id UUID NOT NULL REFERENCES
       pax8_companies(id) ON DELETE CASCADE, candidate_company_ids BIGINT[] NOT NULL,
       match_confidences TEXT[] NOT NULL, detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
       resolved_at TIMESTAMPTZ, resolved_by_user_id TEXT REFERENCES "user"(id) ON DELETE
       SET NULL, resolved_to_company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL,
       resolution_note TEXT.

    Indexes: one `CREATE INDEX IF NOT EXISTS idx_<table>_is_deleted ON <table>(is_deleted)`
    per primary entity table (companies, products, subscriptions, orders, order_items);
    plus indexes on pax8_subscriptions(pax8_company_id), pax8_orders(pax8_company_id),
    pax8_order_items(order_id). For the review table, mirror device_link_review's three
    indexes: `ix_pax8_company_match_review_unresolved ON (...)(detected_at DESC) WHERE
    resolved_at IS NULL`, `ix_pax8_company_match_review_pax8_company ON (pax8_company_id)`,
    and a partial-unique `uq_pax8_company_match_review_open ON (pax8_company_id) WHERE
    resolved_at IS NULL`. Add a `COMMENT ON TABLE pax8_company_match_review` describing it
    as the PAX8->Autotask company-match conflicts queue populated by Phase 12 (admin
    resolves; matcher does not auto-merge). Never edit any committed migration.
  </action>
  <verify>
    <automated>test $(grep -v '^--' migrations/091_pax8_tables.sql | grep -c "CREATE TABLE IF NOT EXISTS pax8_") -eq 6</automated>
  </verify>
  <acceptance_criteria>
    - The grep-filtered count of `CREATE TABLE IF NOT EXISTS pax8_` equals 6
    - `grep -q "REFERENCES pax8_orders(id) ON DELETE CASCADE" migrations/091_pax8_tables.sql` succeeds (order_items -> orders)
    - `grep -q "candidate_company_ids BIGINT\[\] NOT NULL"` and `grep -q "match_confidences TEXT\[\] NOT NULL"` both succeed
    - `grep -q "resolved_to_company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL"` succeeds
    - `grep -q 'resolved_by_user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL'` succeeds
    - `grep -c "NUMERIC(12,2)"` is at least 3 (total, unit_price, line_total) and `grep -c "CHAR(3)"` is at least 2
    - `grep -c "raw_payload JSONB"` is at least 4 (all primary entity tables)
    - No occurrence of `altVendorSku`
  </acceptance_criteria>
  <done>migrations/091_pax8_tables.sql exists with all six tables, correct FK types, audit columns, monetary+currency columns, and the review-queue indexes/comment.</done>
</task>

<task type="auto">
  <name>Task 2: Apply migration 091 to the dev DB and prove idempotency</name>
  <files>migrations/091_pax8_tables.sql</files>
  <read_first>
    - scripts/apply-migrations.sh (single-migration mode: `bash scripts/apply-migrations.sh 091_pax8_tables.sql` runs `docker exec -i pulse-postgres psql -U pulse_user -d pulse_autotask < migrations/091_pax8_tables.sql`)
    - CLAUDE.md ("Watch out for" / Database sections: Postgres applies migrations on first volume boot only — existing dev volume needs manual apply)
    - migrations/091_pax8_tables.sql (the file created in Task 1)
  </read_first>
  <action>
    Confirm the pulse-postgres container is running (`docker ps | grep pulse-postgres`).
    Apply the migration with `bash scripts/apply-migrations.sh 091_pax8_tables.sql`. Then
    list the created tables with `docker exec pulse-postgres psql -U pulse_user -d
    pulse_autotask -c "\dt pax8_*"`. Re-run `bash scripts/apply-migrations.sh
    091_pax8_tables.sql` a second time to prove idempotency (IF NOT EXISTS must produce no
    error and exit 0). If the pulse-postgres container is NOT running/reachable in this
    environment, do NOT fail the task: record in the SUMMARY the exact apply command above
    as a pending developer step, and treat Task 1's grep gate as the completion proof —
    the .sql file is the deliverable; live application is verification.
  </action>
  <verify>
    <automated>docker exec pulse-postgres psql -U pulse_user -d pulse_autotask -tAc "SELECT count(*) FROM information_schema.tables WHERE table_name LIKE 'pax8\_%'" 2>/dev/null || echo "SKIP: pulse-postgres unavailable — apply is a documented developer step"</automated>
  </verify>
  <acceptance_criteria>
    - When pulse-postgres is reachable: the table count for `pax8\_%` returns 6, and a second `bash scripts/apply-migrations.sh 091_pax8_tables.sql` exits 0 with no error (idempotent)
    - When pulse-postgres is NOT reachable: the SUMMARY records the exact apply command as a pending developer step, and Task 1's grep gate stands as the completion proof
    - The committed `.sql` file is unchanged by the apply step (application does not edit the migration)
  </acceptance_criteria>
  <done>Six pax8_* tables exist in the dev DB (or the apply command is documented as a pending developer step when the container is unreachable); re-apply is idempotent.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| migration DDL -> Postgres | Static, developer-authored DDL applied by an operator; no runtime user input reaches this SQL |

This plan installs no external packages and exposes no route — the supply-chain (`T-*-SC`) checkpoint is not triggered.

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-10-06 | Tampering (SQL injection) | migrations/091_pax8_tables.sql | accept | The migration is static DDL with no interpolated or dynamic values; no user/request data flows into it. Injection is not reachable |
| T-10-07 | Denial of Service | applying to the live dev DB | mitigate | All statements use IF NOT EXISTS; no DROP/ALTER of existing tables, no data mutation. Re-apply is a proven no-op (Task 2 idempotency check). Existing data is untouched |
| T-10-08 | Information Disclosure | raw_payload JSONB columns | accept | Columns are empty in this phase (schema only). When Phase 11+ populates them they hold PAX8 business data (subscriptions/costs), already inside the same trusted Postgres as Autotask data — no new exposure surface introduced here |

No HIGH-severity threat blocks this plan.
</threat_model>

<verification>
- `test $(grep -v '^--' migrations/091_pax8_tables.sql | grep -c "CREATE TABLE IF NOT EXISTS pax8_") -eq 6` passes
- Where docker is available: `\dt pax8_*` lists 6 tables; second apply exits 0 (idempotent)
- The migration touches no committed file other than the new `091_pax8_tables.sql`
</verification>

<success_criteria>
- PAX8 Phase-10 SC#4: a new numbered migration (`091_pax8_tables.sql`) creates the PAX8 companies, subscriptions, products, orders, order_items, and company-match/review tables using `IF NOT EXISTS`, ready for Phase 11+ to populate
- Orders/order_items columns are modeled with Invoice/InvoiceItem semantics (RESEARCH Pitfall 1) so `total`/`status`/`unit_price` are populatable by Phase 12; inline comment flags the `/invoices` sync source for Phase 12 confirmation
</success_criteria>

<output>
Create `.planning/phases/10-pax8-client-auth-foundation/10-02-SUMMARY.md` when done.
</output>
