docs(10): record planning completion in STATE.md, add pattern map

This commit is contained in:
lorentz 2026-07-10 13:28:00 -04:00
parent 40e17aa4b6
commit 8b975be700
2 changed files with 431 additions and 6 deletions

View file

@ -2,14 +2,14 @@
gsd_state_version: 1.0
milestone: v2.0
milestone_name: PAX8 Integration
status: planning
status: executing
stopped_at: Phase 10 context gathered
last_updated: "2026-07-10T16:18:11.364Z"
last_activity: 2026-07-10 — v2.0 ROADMAP.md created, Phases 10-14 defined
last_updated: "2026-07-10T17:27:42.850Z"
last_activity: 2026-07-10 -- Phase 10 planning complete
progress:
total_phases: 5
completed_phases: 0
total_plans: 0
total_plans: 3
completed_plans: 0
percent: 0
---
@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-07-10)
Phase: 10 of 14 (PAX8 Client & Auth Foundation)
Plan: — (not yet planned)
Status: Roadmap approved, ready to plan Phase 10
Last activity: 2026-07-10 — v2.0 ROADMAP.md created, Phases 10-14 defined
Status: Ready to execute
Last activity: 2026-07-10 -- Phase 10 planning complete
Progress: [░░░░░░░░░░] 0%

View file

@ -0,0 +1,425 @@
# Phase 10: PAX8 Client & Auth Foundation - Pattern Map
**Mapped:** 2026-07-10
**Files analyzed:** 4 (2 optional test files also flagged, see below)
**Analogs found:** 4 / 4
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|--------------------|------|-----------|-----------------|----------------|
| `lib/services/pax8-client.ts` | service (integration client) | request-response (OAuth2 client-credentials + REST) | `lib/services/msgraph-client.ts` | exact |
| `lib/services/pax8-factory.ts` | service (factory/singleton) | request-response (config check + lazy init) | `lib/services/msgraph-factory.ts` (secondary: `lib/services/appgate-factory.ts`) | exact |
| `lib/types/pax8.ts` | model (types barrel) | transform (API shape → typed interfaces) | `lib/types/appgate.ts` | exact |
| `migrations/091_pax8_tables.sql` | migration | CRUD (schema DDL) | `migrations/089_appgate_tables.sql` (table style) + `migrations/080_device_xref_company_id.sql` (review-queue shape) | exact |
| `lib/services/pax8-client.test.ts` (optional, RESEARCH-recommended) | test | request-response (mocked `fetch`) | `lib/services/llm/call.test.ts` (mocking pattern only — no existing `*-client.test.ts` precedent in this codebase) | role-match (no exact precedent exists) |
| `lib/services/pax8-factory.test.ts` (optional, RESEARCH-recommended) | test | request-response (env-var config check) | none — no `*-factory.test.ts` exists anywhere in the codebase | no analog |
## Pattern Assignments
### `lib/services/pax8-client.ts` (service, request-response)
**Analog:** `lib/services/msgraph-client.ts` (OAuth2 client-credentials shape), with two required deviations from RESEARCH.md's Pitfall 2/3 — PAX8's token body is JSON (not form-encoded) and requires an `audience` field.
**Imports/class shape pattern** (`lib/services/msgraph-client.ts` lines 55-68):
```typescript
export interface MsGraphClientConfig {
tenantId: string;
clientId: string;
clientSecret: string;
}
export class MsGraphClient {
private config: MsGraphClientConfig;
private accessToken: string | null = null;
private tokenExpiry: number = 0;
constructor(config: MsGraphClientConfig) {
this.config = config;
}
```
For PAX8: `Pax8ClientConfig { clientId: string; clientSecret: string }` (no tenant concept). Type import convention should follow `appgate-client.ts` line 24-33 (`import type { ... } from '@/lib/types/appgate'`) — i.e. `pax8-client.ts` should `import type { Pax8Company, Pax8Subscription, ... } from '@/lib/types/pax8'` rather than declaring response interfaces inline, since a dedicated `lib/types/pax8.ts` file is already planned.
**Token exchange + expiry-aware cache pattern** (`lib/services/msgraph-client.ts` lines 70-98):
```typescript
private async getToken(): Promise<string> {
if (this.accessToken && Date.now() < this.tokenExpiry - 60000) {
return this.accessToken;
}
const url = `https://login.microsoftonline.com/${this.config.tenantId}/oauth2/v2.0/token`;
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
scope: 'https://graph.microsoft.com/.default',
});
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Graph token request failed: ${res.status} ${text}`);
}
const data = await res.json();
this.accessToken = data.access_token;
this.tokenExpiry = Date.now() + data.expires_in * 1000;
return this.accessToken!;
}
```
**Copy the structure (cache check, expiry math, error-on-!res.ok) but change the body encoding and add `audience`** per RESEARCH.md Pattern 1 / Pitfall 3:
```typescript
// PAX8 deviation — JSON body + audience field, NOT URLSearchParams
const res = await fetch('https://api.pax8.com/v1/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
grant_type: 'client_credentials',
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
audience: 'https://api.pax8.com', // NOT api://provisioning — see Pitfall 2
}),
});
```
**Authenticated fetch + retry-on-429 pattern** (`lib/services/msgraph-client.ts` lines 100-122):
```typescript
private async fetchJson<T>(path: string, retryCount = 0): Promise<T> {
const token = await this.getToken();
const res = await fetch(`https://graph.microsoft.com/v1.0${path}`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
if (res.status === 429 && retryCount < 4) {
const retryAfter = Math.max(30, parseInt(res.headers.get('Retry-After') || '30', 10));
await new Promise(r => setTimeout(r, retryAfter * 1000));
return this.fetchJson(path, retryCount + 1);
}
if (!res.ok) {
const text = await res.text();
throw new Error(`Graph API error ${res.status} for ${path}: ${text}`);
}
return res.json();
}
```
Copy this shape verbatim for `pax8-client.ts`'s internal `fetchJson<T>()`, swapping the base URL to `https://api.pax8.com/v1` — the 429/`Retry-After` handling is directly reusable and matches RESEARCH.md's Pitfall 4 note (PAX8 rate limit is 1000/min account-wide; not exercised by this phase's single auth-proof call, but the retry scaffold costs nothing to include now).
**Paginated list-endpoint pattern** — no `msgraph-client.ts` equivalent uses PAX8's exact `{ content, page }` envelope (Graph uses `@odata.nextLink`), so this piece has no direct in-repo analog. Use RESEARCH.md's Pattern 3 code example directly:
```typescript
async listCompanies(page = 0, size = 200): Promise<{ content: Pax8Company[]; totalPages: number }> {
const token = await this.getToken();
const res = await fetch(`https://api.pax8.com/v1/companies?page=${page}&size=${size}`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
if (!res.ok) {
const text = await res.text();
throw new Error(`PAX8 API error ${res.status} for /companies: ${text}`);
}
const data = await res.json();
return { content: data.content, totalPages: data.page.totalPages };
}
```
This phase only needs one auth-proof call (`listCompanies`) — do not build a generic pagination-looping utility (RESEARCH.md's "Don't Hand-Roll" table explicitly rejects this).
**Error handling pattern:** Every method in `msgraph-client.ts` follows `if (!res.ok) { const text = await res.text(); throw new Error(...) }` — no custom error classes, no try/catch wrapper inside the client itself (callers catch). Apply identically in `pax8-client.ts`. Never interpolate `config.clientSecret` into any thrown message or `console.log` (RESEARCH.md Security Domain — Information Disclosure row).
---
### `lib/services/pax8-factory.ts` (service, request-response)
**Analog:** `lib/services/msgraph-factory.ts` (primary — same 3-var config shape) and `lib/services/appgate-factory.ts` (secondary — most recently added integration, same JSDoc-header + reset-seam convention).
**Full pattern** (`lib/services/msgraph-factory.ts` lines 1-43, adapt var names/count):
```typescript
import { MsGraphClient, MsGraphClientConfig } from './msgraph-client';
let msGraphClientInstance: MsGraphClient | null = null;
export function isMsgraphConfigured(): boolean {
return !!(
process.env.MSGRAPH_CLIENT_ID &&
process.env.MSGRAPH_CLIENT_SECRET &&
process.env.MSGRAPH_TENANT_ID
);
}
export function getMsgraphClient(): MsGraphClient {
if (!msGraphClientInstance) {
const config: MsGraphClientConfig = {
tenantId: process.env.MSGRAPH_TENANT_ID || '',
clientId: process.env.MSGRAPH_CLIENT_ID || '',
clientSecret: process.env.MSGRAPH_CLIENT_SECRET || '',
};
if (!config.tenantId || !config.clientId || !config.clientSecret) {
throw new Error(
'Microsoft Graph credentials missing. Set MSGRAPH_CLIENT_ID, MSGRAPH_CLIENT_SECRET, and MSGRAPH_TENANT_ID.'
);
}
msGraphClientInstance = new MsGraphClient(config);
console.log('[MSGRAPH] Client initialized');
}
return msGraphClientInstance;
}
export function resetMsgraphClient(): void {
msGraphClientInstance = null;
}
```
PAX8 only has 2 env vars (`PAX8_CLIENT_ID`, `PAX8_CLIENT_SECRET` — no tenant), so `appgate-factory.ts`'s tighter `Boolean(a && b)` early-return style (lines 19-42, reproduced below) is the better literal template since it's closer to PAX8's 2-var shape and uses the `_reset` naming convention RESEARCH.md's own code example (Pattern 2) already committed to:
```typescript
import { AppgateClient } from './appgate-client';
let _client: AppgateClient | null = null;
export function isAppgateConfigured(): boolean {
return Boolean(
process.env.APPGATE_URL &&
process.env.APPGATE_USERNAME &&
process.env.APPGATE_PASSWORD &&
process.env.APPGATE_DEVICE_ID,
);
}
export function getAppgateClient(): AppgateClient {
if (_client) return _client;
if (!isAppgateConfigured()) {
throw new Error('AppGate is not configured — set APPGATE_URL, APPGATE_USERNAME, APPGATE_PASSWORD, APPGATE_DEVICE_ID');
}
_client = new AppgateClient({ /* ... */ });
return _client;
}
// Test seam — reset the cached client (e.g. after rotating credentials).
export function _resetAppgateClient(): void {
_client = null;
}
```
**Recommendation:** follow RESEARCH.md's own Pattern 2 code example verbatim (it already merges both templates correctly) — `isPax8Configured()`, `getPax8Client()` throwing `'PAX8 is not configured — set PAX8_CLIENT_ID and PAX8_CLIENT_SECRET'`, and `_resetPax8Client()`.
---
### `lib/types/pax8.ts` (model, transform)
**Analog:** `lib/types/appgate.ts` — same "types barrel per external integration" role, most recently added, uses `[key: string]: unknown` escape hatch for partially-typed vendor payloads.
**File header + section-comment convention** (`lib/types/appgate.ts` lines 1-10):
```typescript
/**
* Type definitions for the AppGate SDP Controller REST API (v22.5) and the
* shapes Pulse persists. Source spec lives at
* `https://wawnvaagp01.wulfconsulting.com:8443/api_specs.html`.
*
* Only the slices Pulse consumes are typed — the API exposes ~150 endpoints,
* most of which are administrative writes Pulse never makes.
*/
// ─── API response shapes ──────────────────────────────────────────────────
```
Adapt to: `PAX8 REST API (v1)` header, cite `https://devx.pax8.com`, keep the `// ─── API response shapes ───` / `// ─── Sync orchestration ───` section-divider convention (lines 10, 115 of `appgate.ts`).
**Envelope + partial-typing pattern** (`lib/types/appgate.ts` lines 49-70, 109-113):
```typescript
export interface AppgateAppliance {
id: string;
name: string;
// ...fully-typed known fields...
[key: string]: unknown; // escape hatch for fields not yet modeled
}
export interface AppgateResultList<T> {
range?: string;
totalCount?: number;
data: T[];
}
```
For PAX8, define the equivalent pagination envelope generic matching RESEARCH.md's confirmed shape:
```typescript
export interface Pax8PageEnvelope<T> {
content: T[];
page: { size: number; totalElements: number; totalPages: number; number: number };
}
```
Type the four entities (`Pax8Company`, `Pax8Subscription`, `Pax8Product`, and the Invoice/InvoiceItem-sourced `Pax8Order`/`Pax8OrderItem` — see RESEARCH.md Pitfall 1 on field-source naming) with known fields typed and `[key: string]: unknown` for the long tail, matching `AppgateAppliance`'s style. Do NOT type every documented PAX8 field exhaustively — `appgate.ts` deliberately types "only the slices Pulse consumes."
---
### `migrations/091_pax8_tables.sql` (migration, CRUD)
**Analogs:** `migrations/089_appgate_tables.sql` (table/index/comment style for the 4 primary entity tables) + `migrations/080_device_xref_company_id.sql` (`device_link_review` — explicit shape reference for `pax8_company_match_review` per CONTEXT.md's Claude's Discretion section).
**Header comment convention** (`migrations/089_appgate_tables.sql` lines 1-11):
```sql
-- AppGate SDP integration — Postgres schema.
--
-- Mirrors the slices of Appgate SDP Controller REST API v22.5 that Pulse
-- surfaces for the manager-on-the-go view:
--
-- • Active sessions (current snapshot) -> appgate_active_sessions
-- • On-boarded devices (slowly changing list) -> appgate_devices
-- ...
```
Adapt to a similar bullet list mapping PAX8 entities → table names, and flag inline (per RESEARCH.md Pitfall 1) that `pax8_orders`/`pax8_order_items` columns are sourced from PAX8's Invoice/InvoiceItem objects, not the bare Order/LineItem objects.
**Primary entity table shape — audit columns + soft-delete + raw JSONB** (`migrations/089_appgate_tables.sql` lines 56-77, closest to a "typed columns + raw payload" table):
```sql
CREATE TABLE IF NOT EXISTS appgate_appliances (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
hostname TEXT,
tags JSONB,
roles JSONB, -- {controller, gateway, logServer, ...} subset flags
raw JSONB, -- full payload — versioned schema changes
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_appgate_appliances_is_deleted ON appgate_appliances(is_deleted);
```
Use this as the template for `pax8_companies`, `pax8_subscriptions`, `pax8_products` (rename `raw``raw_payload` per D-03's explicit naming) — all get `synced_at`, `is_deleted`, `deleted_at` per CLAUDE.md's stated audit-column convention. Add `idx_*_is_deleted` indexes matching this pattern.
**Monetary + currency columns pattern** (`migrations/051_create_qbo_tables.sql` lines 14-33 — closest existing precedent for NUMERIC + currency on a financial entity, matching D-04 exactly):
```sql
CREATE TABLE IF NOT EXISTS qbo_invoices (
id TEXT PRIMARY KEY, -- QBO Id
...
total_amt NUMERIC(12,2),
balance NUMERIC(12,2),
status TEXT, -- Open, Paid, Voided, etc.
currency_code TEXT DEFAULT 'USD',
line_items JSONB,
...
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
D-04 locks `NUMERIC(12,2)` + `currency CHAR(3) DEFAULT 'USD'` (CHAR(3), not TEXT — a slightly stricter type than `qbo_invoices.currency_code TEXT` uses, but the same intent). Apply this to `pax8_orders.total`/`pax8_order_items.unit_price`/`line_total`, sourcing the actual field names from PAX8's Invoice/InvoiceItem schema per RESEARCH.md Pitfall 1 (`amountDue`, `price`, `subTotal`, invoice-level `status`), not the bare Order/LineItem fields which don't carry these.
**Header/line-item FK relationship pattern** — no existing Pulse migration models a strict header+lines pattern as cleanly as PAX8's own docs describe; RESEARCH.md's migration skeleton (Code Examples section, lines 478-515 of RESEARCH.md) is the most concrete starting point:
```sql
CREATE TABLE IF NOT EXISTS pax8_companies (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
external_id TEXT,
website TEXT,
status TEXT,
raw_payload JSONB,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ
);
```
`pax8_order_items` should FK to `pax8_orders(id)` `ON DELETE CASCADE`, mirroring how `migrations/051_create_qbo_tables.sql`'s later tables (payments/credit memos, not shown above) and `device_link_review`'s `ON DELETE CASCADE` (below) both use cascade deletes for child rows.
**Review-queue table shape** (`migrations/080_device_xref_company_id.sql` lines 62-86 — copy near-verbatim per CONTEXT.md's explicit instruction):
```sql
CREATE TABLE IF NOT EXISTS device_link_review (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
device_external_id BIGINT NOT NULL REFERENCES device_external_ids(id) ON DELETE CASCADE,
candidate_ci_ids BIGINT[] NOT NULL,
match_confidences TEXT[] NOT NULL,
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
resolved_by_user_id TEXT NULL REFERENCES "user"(id) ON DELETE SET NULL,
resolved_to_ci_id BIGINT NULL REFERENCES configuration_items(id) ON DELETE SET NULL,
resolution_note TEXT
);
CREATE INDEX IF NOT EXISTS ix_device_link_review_unresolved
ON device_link_review(detected_at DESC) WHERE resolved_at IS NULL;
CREATE INDEX IF NOT EXISTS ix_device_link_review_xref
ON device_link_review(device_external_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_device_link_review_open_per_xref
ON device_link_review(device_external_id) WHERE resolved_at IS NULL;
COMMENT ON TABLE device_link_review IS
'Reconciler conflicts queue: one row per unlinked device_external_ids row that matches 2+ configuration_items. Admin picks the right CI; reconciler does not auto-merge.';
```
Map field-for-field per CONTEXT.md: `device_external_id``pax8_company_id` (FK to `pax8_companies(id) ON DELETE CASCADE`), `candidate_ci_ids BIGINT[]``candidate_company_ids BIGINT[]` (FK target: Autotask `companies.id`), `resolved_to_ci_id``resolved_to_company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL`. Keep `match_confidences TEXT[]` exactly as-is (RESEARCH.md Open Question 3 explicitly recommends matching this type rather than a numeric score, to keep the door open for either representation). Keep the three-index pattern (unresolved-queue index, xref lookup index, partial-unique "one open review per source row" index) and the `COMMENT ON TABLE` documentation habit — RESEARCH.md's own skeleton reproduces this table almost verbatim (lines 501-511 of RESEARCH.md), confirming this is the intended template.
---
## Shared Patterns
### OAuth2 Client-Credentials Token Cache
**Source:** `lib/services/msgraph-client.ts` lines 70-98
**Apply to:** `lib/services/pax8-client.ts` (with JSON-body + `audience` deviation per Pitfall 2/3 above)
```typescript
if (this.accessToken && Date.now() < this.tokenExpiry - 60000) {
return this.accessToken;
}
// ...POST to token endpoint...
this.accessToken = data.access_token;
this.tokenExpiry = Date.now() + data.expires_in * 1000;
```
### Factory Singleton + `is<Name>Configured()` + Throw-If-Missing + Reset Seam
**Source:** `lib/services/msgraph-factory.ts` (full file) / `lib/services/appgate-factory.ts` (full file)
**Apply to:** `lib/services/pax8-factory.ts`
```typescript
export function isPax8Configured(): boolean {
return Boolean(process.env.PAX8_CLIENT_ID && process.env.PAX8_CLIENT_SECRET);
}
export function getPax8Client(): Pax8Client {
if (_client) return _client;
if (!isPax8Configured()) {
throw new Error('PAX8 is not configured — set PAX8_CLIENT_ID and PAX8_CLIENT_SECRET');
}
_client = new Pax8Client({ clientId: process.env.PAX8_CLIENT_ID!, clientSecret: process.env.PAX8_CLIENT_SECRET! });
return _client;
}
export function _resetPax8Client(): void { _client = null; }
```
### Error Handling — Never Interpolate Secrets
**Source:** `lib/services/msgraph-client.ts` (every method), `lib/services/appgate-factory.ts` line 31 (error names missing env vars, never values)
**Apply to:** All PAX8 files
```typescript
if (!res.ok) {
const text = await res.text();
throw new Error(`PAX8 API error ${res.status} for ${path}: ${text}`);
}
```
Never log/throw `config.clientSecret` itself — only name which env var is missing.
### Migration Audit Columns + Soft Delete
**Source:** `migrations/089_appgate_tables.sql` (all tables), `migrations/088_qbo_invoices_soft_delete.sql`, CLAUDE.md's stated convention
**Apply to:** All four `pax8_*` primary entity tables in `091_pax8_tables.sql`
```sql
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ
```
Plus a matching `CREATE INDEX IF NOT EXISTS idx_<table>_is_deleted ON <table>(is_deleted);` per table.
### Raw-Payload Safety Net (D-03)
**Source:** `migrations/089_appgate_tables.sql` line 69 (`raw JSONB — full payload`), CONTEXT.md D-03 citing `itglue-search.ts` precedent
**Apply to:** `pax8_orders`, `pax8_order_items` (locked by D-03); recommended default for `pax8_companies`/`pax8_subscriptions`/`pax8_products` per Claude's Discretion section
```sql
raw_payload JSONB, -- full API payload — safety net for fields not yet modeled
```
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `lib/services/pax8-client.test.ts` | test | request-response (mocked `fetch`) | No `*-client.test.ts` exists for any integration client in this codebase (confirmed by RESEARCH.md's grep of the whole tree). `lib/services/llm/call.test.ts` is the closest available *mocking-style* reference (vi.fn() fake + call-count/body assertions) but tests a different layer (Anthropic SDK wrapper, not raw `fetch`). Planner should write this test from RESEARCH.md's Validation Architecture section rather than an in-repo analog. |
| `lib/services/pax8-factory.test.ts` | test | request-response (env-var check) | No `*-factory.test.ts` exists anywhere in the codebase. No analog to copy from; use plain vitest `describe`/`it` with `process.env` mutation + `_resetPax8Client()` between tests. |
## Metadata
**Analog search scope:** `lib/services/` (integration clients + factories), `lib/types/`, `migrations/`
**Files scanned:** `lib/services/msgraph-client.ts`, `lib/services/msgraph-factory.ts`, `lib/services/veeam-factory.ts`, `lib/services/appgate-client.ts`, `lib/services/appgate-factory.ts`, `lib/types/appgate.ts`, `migrations/080_device_xref_company_id.sql`, `migrations/089_appgate_tables.sql`, `migrations/088_qbo_invoices_soft_delete.sql`, `migrations/051_create_qbo_tables.sql`, `lib/services/llm/call.test.ts`
**Pattern extraction date:** 2026-07-10