diff --git a/.planning/STATE.md b/.planning/STATE.md index bd34482..0e712ad 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -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% diff --git a/.planning/phases/10-pax8-client-auth-foundation/10-PATTERNS.md b/.planning/phases/10-pax8-client-auth-foundation/10-PATTERNS.md new file mode 100644 index 0000000..2f57532 --- /dev/null +++ b/.planning/phases/10-pax8-client-auth-foundation/10-PATTERNS.md @@ -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 { + 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(path: string, retryCount = 0): Promise { + 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()`, 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 { + range?: string; + totalCount?: number; + data: T[]; +} +``` +For PAX8, define the equivalent pagination envelope generic matching RESEARCH.md's confirmed shape: +```typescript +export interface Pax8PageEnvelope { + 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 + `isConfigured()` + 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__is_deleted ON
(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