diff --git a/.planning/phases/14-pax8-ui-surface/14-RESEARCH.md b/.planning/phases/14-pax8-ui-surface/14-RESEARCH.md
new file mode 100644
index 0000000..695f2d7
--- /dev/null
+++ b/.planning/phases/14-pax8-ui-surface/14-RESEARCH.md
@@ -0,0 +1,485 @@
+# Phase 14: /pax8 UI Surface - Research
+
+**Researched:** 2026-07-11
+**Domain:** Next.js App Router page + API routes over existing Postgres schema (no new external integration, no new library) — read-heavy list/detail UI plus a small admin-gated resolve mutation.
+**Confidence:** HIGH (all claims below verified directly against the live codebase and a live Postgres query against this project's actual `pax8_*` tables — no external library research was needed for this phase)
+
+## Summary
+
+Phase 14 is a pure internal-consistency phase: every table, service, and UI primitive it needs already exists in this repo. There is no new library to evaluate and no external API to integrate — the work is (1) two or three new `app/api/pax8/*` route handlers doing parameterized `postgresClient` queries, (2) a new `app/pax8/page.tsx` composed from `DataTable` + `DetailModal` + shadcn `Tabs`, and (3) one new nav entry. `device-link-conflicts` (page + its two API routes) is a near-exact structural precedent for the review/resolve half of this phase and should be mirrored closely.
+
+The one real risk in this phase is **not** schema or auth — it's `DetailModal.tsx`. Its "Formatted" tab is not a generic renderer: it hardcodes two field-group tables (`TICKET_GROUPS`, `COMPANY_GROUPS`) selected by sniffing specific keys in the data object (`'ticket_number' in data`, `'company_name' in data`), and every field type it knows how to render is a single scalar (date, bool, badge, phone, url, id). It has **no mechanism to render an array of subscription/cost-breakdown rows** — the exact content D-02/D-03 need front and center. CONTEXT.md's D-02 says "used as-is," but as verified below, rendering the per-company cost breakdown will require an additive extension to `DetailModal.tsx`, not just passing it data. This is flagged as Pitfall 1 and should be budgeted as a task, not discovered mid-implementation.
+
+Live data was queried directly against the project's Postgres container to ground every join and count below (118 `pax8_companies`, 80 auto-matched, 38 open review rows [16 no-candidate / 22 ambiguous], 445 `pax8_subscriptions`, ~28.8K `pax8_order_items` with a company set). That data surfaced a second load-bearing pitfall: PAX8's "New Commerce Experience" subscriptions bill on **per-subscription anniversary cycles**, not a shared calendar month — so "the latest order_items period" is not one date, it's one date *per subscription*. Any cost-breakdown query that takes a single global `MAX(start_period)` will silently drop every subscription whose anniversary isn't the most recent one. See Pitfall 2.
+
+**Primary recommendation:** Build `/pax8` as a client page with two `Tabs` panels ("Companies", "Needs Review") per D-04; back the Companies tab with `DataTable` querying `pax8_companies LEFT JOIN companies ON companies.id = pax8_companies.autotask_company_id`; back the drill-down with an extended `DetailModal` (new field-group branch + a new array-rendering path) fed by a per-company query that joins `pax8_subscriptions` (current state: qty/billing-term/status) to each subscription's *own* latest `pax8_order_items` row (windowed per `subscription_id`, not a single global cutoff) for the actual billed amount; back the Needs Review tab with a list+resolve pair that mirrors `device-link-conflicts` exactly, except the resolve transaction must write to **both** `pax8_companies` (autotask_company_id/match_confidence/match_method='manual'/matched_at) **and** `pax8_company_match_review` (resolved_at/resolved_by_user_id/resolved_to_company_id/resolution_note) — confirmed necessary by reading `pax8-company-matcher.ts`'s own re-scoring-eligibility guard (see Architecture Patterns).
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Company list + subscription summary | API / Backend (`app/api/pax8/companies`) | Frontend Server (page shell) | Aggregation/joins belong server-side; page just renders `DataTable` rows from the API response |
+| Per-company cost breakdown | API / Backend (`app/api/pax8/companies/[id]/cost-breakdown` or embedded in company row detail fetch) | Browser (DetailModal render) | The windowed "latest period per subscription" join (Pitfall 2) is non-trivial SQL — must not be reimplemented client-side |
+| Needs Review queue (list) | API / Backend (`app/api/pax8/company-matches`) | — | Mirrors `device-link-conflicts` route exactly: parameterized query, admin-gated |
+| Resolve action (mutation) | API / Backend (`app/api/pax8/company-matches/[id]/resolve`) | Database (transaction) | Must be a single transaction touching two tables (`pax8_companies` + `pax8_company_match_review`) — see Architecture Patterns |
+| Manual company search fallback (D-05) | Browser (client-side filter) | API / Backend (`/api/data/companies-list`, already exists) | Only ~242 active companies — fetch once, filter client-side; no new search endpoint needed |
+| Nav entry | Browser (client component) | — | `components/navigation/app-navigation.tsx`'s `navigationItems` array; both desktop dropdown-menu and mobile Sheet consume the same array, so one edit covers both |
+| View/resolve permission split | API / Backend (route-level guard) | — | `requireAuth()` (view) vs `requirePermission('admin','access')` (resolve) per D-07/D-08 — enforced in the route handler, not middleware |
+
+## Project Constraints (from CLAUDE.md)
+
+- No ORM — use `postgresClient.query()` with parameterized SQL, manual snake_case → camelCase transform in the route handler.
+- No server actions — API routes called via `fetch()` from a `'use client'` page.
+- No SWR/react-query/new state libraries — `useState`/`useEffect`/`fetch`, matching every other Pulse page.
+- No Zod in API routes unless it "matters" — the resolve mutation is a good candidate for a small Zod schema (mirrors `device-link-conflicts/[id]/resolve/route.ts`'s existing `ResolveBody = z.object({...})` precedent exactly); the read-only list routes don't need it.
+- Files kebab-case, components PascalCase-exported from kebab-case files, icons from `lucide-react`, toasts from `sonner`.
+- New SQL is a new numbered migration, `IF NOT EXISTS` guarded — **this phase should need zero new migrations**; all tables/columns it reads already exist (091, 092, 093, 094, 095, 096). Confirm during planning that no new column is actually required before adding one.
+- Every new API route must have an explicit `requireAuth()`/`requirePermission()` call (explicit CONTEXT.md directive, echoing 13-REVIEW.md CR-02 — the adjacent `/api/pax8/sync` route currently has **no** auth check at all; do not copy that route as a pattern).
+- `/pax8` is not in `middleware.ts`'s `publicRoutes` list (confirmed) — it requires a session cookie by default; no middleware change needed, but do **not** add it to `publicRoutes`.
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+- **D-01:** Main company list uses `components/admin/DataTable.tsx`, not a bespoke card grid.
+- **D-02:** Per-company cost breakdown is a drill-down via `components/admin/DetailModal.tsx` (formatted/raw tab pattern), not inline in the table row. Formatted tab: subscriptions (product name, quantity, billing term) + cost summary. Raw tab: underlying JSON.
+- **D-03:** Cost breakdown is grouped **by subscription/product**, not a single total. Cost data must be joined from `pax8_order_items.pax8_company_id` (and `start_period`/`end_period`), never from `pax8_orders` (its `pax8_company_id` is always NULL).
+- **D-04:** The flagged/ambiguous review queue is a **section/tab within `/pax8`** (e.g., "Companies" / "Needs Review" tabs), not a separate top-level route. Reuse `device-link-conflicts`'s card-list-with-resolve-action UI pattern, embedded as a tab.
+- **D-05:** Resolution UI offers **both**: pick from top-3 stored candidates (`pax8_company_match_review.candidate_company_ids` + `match_confidences`) as primary path, plus a manual company search/picker as fallback (required for the zero-candidate case, useful even when candidates exist but none are correct).
+- **D-06:** Resolving writes `resolved_at`, `resolved_by_user_id`, `resolved_to_company_id`, optional `resolution_note` — mirrors `device_link_review`'s resolve shape.
+- **D-07:** Viewing `/pax8` requires only `requireAuth()` — any authenticated user.
+- **D-08:** The resolve action requires `requirePermission('admin', 'access')` — matching `/api/admin/device-link-conflicts/[id]/resolve`'s pattern exactly. Every new route this phase adds must have an explicit auth call.
+- **D-09:** A review row with an empty `candidate_company_ids` array shows the same review-section UI with an explicit empty state ("No suggested matches — search manually"), handled by the D-05 manual-search fallback, no special-cased flow.
+
+### Claude's Discretion
+
+- Exact tab/section labels, table column set, and DetailModal field layout.
+- Whether "Needs Review" tab shows a count badge, and sort/filter options on the main company list.
+
+### Deferred Ideas (OUT OF SCOPE)
+
+None — discussion stayed within Phase 14's scope. PAX8 write access, seat-adjustment actions, and any general natural-language assistant over this data are already explicitly out of scope per PROJECT.md, not deferred from this discussion.
+
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| PAX8-12 | An admin can view flagged/ambiguous company matches and manually resolve them to the correct Autotask company | Architecture Patterns → "Needs Review tab + resolve transaction"; Code Examples → resolve route; verified schema for `pax8_company_match_review` and `pax8_companies` match columns |
+| PAX8-13 | A new `/pax8` page lists PAX8 companies with their subscriptions and a cost breakdown | Architecture Patterns → company list query; Pitfall 1 (DetailModal extension) and Pitfall 2 (per-subscription latest period) directly govern correct implementation |
+| PAX8-14 | The `/pax8` page surfaces flagged/ambiguous company matches (PAX8-11) for manual resolution | Same as PAX8-12 — this is the UI-surfacing half of the same review queue |
+
+
+## Standard Stack
+
+No new packages. Everything needed is already in `package.json` and already imported by the precedents cited below.
+
+### Core (reused, not new)
+| Library | Version (installed) | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| `@tanstack/react-table` (via `DataTable.tsx`) | 8.21.3 | Company list table | Existing wrapper, used by every other tabular list in Pulse |
+| `pg` (via `postgresClient`) | 8.11.0 | All Postgres access | No-ORM convention |
+| shadcn `Tabs`/`Dialog`/`Card`/`Badge`/`Select` | n/a (local components) | Page/section/modal chrome | Already vendored in `components/ui/` |
+| `sonner` | 2.0.7 | Resolve success/error toasts | Matches `device-link-conflicts` page exactly |
+| `lucide-react` | 0.562.0 | Icons (nav entry, review-tab alert icon, etc.) | Project convention |
+| `zod` | 4.3.5 | Resolve-body validation | Matches `device-link-conflicts/[id]/resolve` exactly |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| `DataTable` for the company list | Bespoke card grid | Rejected by CONTEXT.md D-01 — no sorting/pagination/search for free, inconsistent with rest of app |
+| Extending `DetailModal` | A bespoke `Dialog` built from scratch, matching the visual style only | Extending is more consistent with the rest of the codebase (one shared component, one place to fix bugs) but requires touching a file used by every ticket/company detail view in the app — must be additive-only (new branch, no changes to existing `TICKET_GROUPS`/`COMPANY_GROUPS` behavior). See Pitfall 1 for the concrete tradeoff analysis. |
+| Client-side company search combobox (shadcn `Command`) | Server-side `ILIKE` search endpoint | Only ~242 active companies (verified: `companies-list` route has no pagination) — fetching once and filtering client-side is simpler and avoids a new endpoint; a `Command`+`Popover` combobox (shadcn primitives already vendored) is the natural UI for this |
+
+**Installation:** none required.
+
+## Package Legitimacy Audit
+
+Not applicable — this phase introduces zero new npm packages. All libraries used are already installed and in active use elsewhere in the repo (confirmed via `package.json` and direct imports in `DataTable.tsx`, `DetailModal.tsx`, `device-link-conflicts/page.tsx`).
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+Browser (manager, any authenticated user)
+ │
+ │ GET /pax8 (page load, Next.js page component)
+ ▼
+app/pax8/page.tsx ('use client')
+ │
+ ├─ Tab "Companies" ──────────────────────────────────────────┐
+ │ useEffect → fetch('/api/pax8/companies?...') │
+ │ └─ requireAuth() only (D-07) │
+ │ SELECT pax8_companies LEFT JOIN companies │
+ │ ON companies.id = pax8_companies.autotask_company_id
+ │ → DataTable renders rows │
+ │ → onRowClick → fetch per-company cost detail │
+ │ GET /api/pax8/companies/[id] (or embed in row) │
+ │ pax8_subscriptions JOIN pax8_products │
+ │ + LATERAL latest pax8_order_items per │
+ │ subscription_id (Pitfall 2) │
+ │ → DetailModal (EXTENDED, see Pitfall 1) renders │
+ │ Formatted tab: subscriptions table + cost sum │
+ │ Raw tab: raw_payload JSON │
+ │ │
+ ├─ Tab "Needs Review" ────────────────────────────────────────┘
+ │ useEffect → fetch('/api/pax8/company-matches')
+ │ └─ requireAuth() only to VIEW (D-07) — resolve is gated
+ │ SELECT pax8_company_match_review
+ │ WHERE resolved_at IS NULL
+ │ JOIN pax8_companies (name)
+ │ + bulk-fetch candidate `companies` names
+ │ → card list, top-3 candidates + manual search (D-05)
+ │
+ │ User picks a candidate OR searches manually → clicks "Link"
+ ▼
+POST /api/pax8/company-matches/[id]/resolve
+ └─ requirePermission('admin','access') (D-08)
+ BEGIN
+ UPDATE pax8_companies
+ SET autotask_company_id=$X, match_method='manual',
+ matched_at=NOW(), match_confidence=NULL
+ WHERE id = $pax8CompanyId
+ UPDATE pax8_company_match_review
+ SET resolved_at=NOW(), resolved_by_user_id=$user,
+ resolved_to_company_id=$X, resolution_note=$note
+ WHERE id = $reviewId AND resolved_at IS NULL
+ COMMIT
+ → both writes make the resolution visible in the Companies tab
+ (via pax8_companies.autotask_company_id) AND permanent against
+ future re-matching (via pax8-company-matcher.ts's eligibility
+ guard, which excludes match_method='manual' AND rows with a
+ resolved review — see below)
+```
+
+### Recommended Project Structure
+```
+app/
+├── pax8/
+│ └── page.tsx # NEW — Companies / Needs Review tabs
+├── api/
+│ └── pax8/
+│ ├── sync/route.ts # existing — do not modify (Phase 13 scope)
+│ ├── companies/
+│ │ ├── route.ts # NEW — GET list (paginated, sortable, searchable)
+│ │ └── [id]/route.ts # NEW — GET one company's subscriptions + cost breakdown
+│ └── company-matches/
+│ ├── route.ts # NEW — GET unresolved review queue
+│ └── [id]/resolve/route.ts # NEW — POST resolve (admin-gated)
+components/
+├── admin/
+│ ├── DataTable.tsx # reused as-is
+│ └── DetailModal.tsx # EXTENDED (additive) — see Pitfall 1
+├── navigation/
+│ └── app-navigation.tsx # EDITED — add one `navigationItems` entry
+```
+
+### Pattern 1: Company list query (D-01, D-03's join note)
+
+`pax8_companies.autotask_company_id` (set by both the auto-matcher and — after this phase — the manual resolve action) is the **live source of truth** for "which Autotask company is this PAX8 company linked to." `pax8_company_match_review.resolved_to_company_id` is an audit trail of the resolution *event*, not a live join target — confirmed by reading `pax8-company-matcher.ts`'s re-scoring query, which checks `pax8_companies.match_method` and a `NOT EXISTS` against resolved review rows, never reads `resolved_to_company_id` to decide what to display. `[VERIFIED: codebase — lib/services/pax8-company-matcher.ts lines 216-231]`
+
+```sql
+-- Source: adapted from scripts/verify-pax8-orders-matching.ts's own join pattern
+-- (lines 76-85), which already joins pax8_companies -> companies via
+-- autotask_company_id in this exact shape and is live-verified against
+-- real data (118 companies, 80 matched).
+SELECT
+ pc.id, pc.name, pc.status, pc.city, pc.state_or_province, pc.country,
+ pc.autotask_company_id, pc.match_confidence, pc.match_method,
+ c.company_name AS matched_company_name,
+ (SELECT count(*) FROM pax8_subscriptions s
+ WHERE s.pax8_company_id = pc.id AND s.is_deleted = false
+ AND s.status = 'Active') AS active_subscription_count
+FROM pax8_companies pc
+LEFT JOIN companies c ON c.id = pc.autotask_company_id
+WHERE pc.is_deleted = false
+ORDER BY pc.name
+LIMIT $1 OFFSET $2;
+```
+
+### Pattern 2: Per-company cost breakdown — subscriptions + latest-period actuals (D-02, D-03)
+
+**Two data sources are needed, not one.** `pax8_subscriptions` gives the stable "what exists" view (quantity, billing term, status — matches D-02's formatted-tab spec directly). `pax8_order_items` gives the actual billed amount, which differs from `subscriptions.price × quantity` due to proration/discounts (live-verified: one sampled line had `unit_price=155.65, quantity=1` but `line_total=109.00` — a ~30% discount not visible on the subscription record itself).
+
+```sql
+-- Step 1: current subscriptions (the "what am I paying for" list)
+SELECT s.id AS subscription_id, s.product_id, p.name AS product_name, p.sku,
+ s.quantity, s.billing_term, s.status, s.price, s.partner_cost, s.currency
+FROM pax8_subscriptions s
+LEFT JOIN pax8_products p ON p.id = s.product_id
+WHERE s.pax8_company_id = $1 AND s.is_deleted = false
+ORDER BY p.name NULLS LAST;
+
+-- Step 2: latest actually-billed line per subscription (see Pitfall 2 for
+-- why this MUST be windowed per subscription_id, not a single MAX(start_period)
+-- across the whole company).
+SELECT DISTINCT ON (subscription_id)
+ subscription_id, product_id, sku, description, item_type,
+ start_period, end_period, quantity, unit_price, line_total,
+ partner_cost, partner_cost_total
+FROM pax8_order_items
+WHERE pax8_company_id = $1 AND is_deleted = false
+ AND subscription_id IS NOT NULL
+ORDER BY subscription_id, start_period DESC;
+```
+Join Step 1 and Step 2 in application code (or a single query with a `LATERAL` join) keyed on `subscription_id`; fall back to `subscriptions.price * quantity` when no order-item match exists (verified: 3 of 436 distinct `subscription_id`s referenced by order items have no matching row in `pax8_subscriptions` — likely tombstoned/cancelled subscriptions still present in historical invoice data; these should still appear in the cost breakdown using the order-item data alone, with no `billing_term`/`status` from step 1).
+
+`[VERIFIED: live DB query]` — sample counts and the discount example above were pulled directly from this project's Postgres container on 2026-07-11 (118 companies / 445 subscriptions / 28,826 order-items-with-company / 20,174 `item_type='subscription'` rows, all with `subscription_id` populated).
+
+### Pattern 3: Needs Review list (D-04, D-05)
+
+Directly mirrors `app/api/admin/device-link-conflicts/route.ts`'s shape — same bulk-fetch-candidates-in-one-query optimization applies (candidate ids are `bigint[]` referencing `companies`, exactly like `device_link_review.candidate_ci_ids` references `configuration_items`):
+
+```typescript
+// Source: pattern lifted from app/api/admin/device-link-conflicts/route.ts
+const reviews = await postgresClient.query(
+ `SELECT r.id::text, r.detected_at::text, r.candidate_company_ids,
+ r.match_confidences,
+ pc.id AS pax8_company_id, pc.name AS pax8_company_name
+ FROM pax8_company_match_review r
+ JOIN pax8_companies pc ON pc.id = r.pax8_company_id
+ WHERE r.resolved_at IS NULL
+ ORDER BY r.detected_at DESC
+ LIMIT $1 OFFSET $2`,
+ [limit, offset]
+);
+// Bulk-fetch all candidate company names in one query (same optimization
+// device-link-conflicts uses for configuration_items):
+const allCandidateIds = new Set();
+for (const r of reviews.rows) for (const id of r.candidate_company_ids ?? []) allCandidateIds.add(Number(id));
+const companyNames = await postgresClient.query(
+ `SELECT id, company_name FROM companies WHERE id = ANY($1::bigint[])`,
+ [Array.from(allCandidateIds)]
+);
+```
+
+Note the type difference from `device-link-conflicts`: `candidate_company_ids` is `BIGINT[]` (Autotask company IDs, integers) not `UUID[]` — no `::text[]` cast needed on the array itself, but each row's `id` in the `companies` lookup is `bigint` matching Postgres's native JS number handling (values fit safely in JS number range for this dataset — confirmed IDs are small integers, not snowflake-scale).
+
+### Pattern 4: Resolve action — two-table transaction (D-06, D-08)
+
+This is the one place this phase's implementation must **diverge** from the `device-link-conflicts` resolve route it's told to mirror. `device-link-conflicts/[id]/resolve` only touches one other table (`device_external_ids`) because that table has no separate "confidence tier" the matcher checks before re-scoring. PAX8's matcher does: `pax8-company-matcher.ts`'s `matchPax8Companies()` selects its eligible-for-rescoring set with:
+
+```sql
+-- Source: lib/services/pax8-company-matcher.ts lines 216-231 (verbatim)
+WHERE c.is_deleted = false
+ AND c.match_method IS DISTINCT FROM 'manual'
+ AND NOT EXISTS (
+ SELECT 1 FROM pax8_company_match_review r2
+ WHERE r2.pax8_company_id = c.id AND r2.resolved_at IS NOT NULL
+ )
+```
+
+For a manual resolution to actually "stick" against future syncs (SC#4 / D-06's "persist and be respected"), the resolve transaction must set **both** signals this query checks — `pax8_companies.match_method = 'manual'` (not just marking the review row resolved). Confirmed by direct inspection of the matcher's own SQL, not inferred:
+
+```typescript
+// app/api/pax8/company-matches/[id]/resolve/route.ts — NEW
+export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const { session, error } = await requirePermission('admin', 'access'); // D-08
+ if (error) return error;
+ const { id } = await params;
+ // ... validate body (companyId: number, note?: string) with zod, mirroring
+ // device-link-conflicts/[id]/resolve's ResolveBody shape ...
+ return postgresClient.transaction(async (tx) => {
+ const reviewRes = await tx.query(
+ `SELECT pax8_company_id, candidate_company_ids, resolved_at
+ FROM pax8_company_match_review WHERE id = $1 FOR UPDATE`,
+ [id]
+ );
+ if (reviewRes.rowCount === 0) return NextResponse.json({ error: 'Review not found' }, { status: 404 });
+ if (reviewRes.rows[0].resolved_at) return NextResponse.json({ error: 'Already resolved' }, { status: 409 });
+ // NOTE: unlike device-link-conflicts, do NOT restrict companyId to the
+ // candidate list — D-05 explicitly requires a manual-search fallback for
+ // cases with no correct candidate, so any active Autotask company id is
+ // a valid resolution target here (validate it exists + is_active, but
+ // don't require candidate-list membership).
+
+ await tx.query(
+ `UPDATE pax8_companies
+ SET autotask_company_id = $2, match_confidence = NULL,
+ match_method = 'manual', matched_at = NOW()
+ WHERE id = $1`,
+ [reviewRes.rows[0].pax8_company_id, companyId]
+ );
+ await tx.query(
+ `UPDATE pax8_company_match_review
+ SET resolved_at = NOW(), resolved_by_user_id = $2,
+ resolved_to_company_id = $3, resolution_note = $4
+ WHERE id = $1`,
+ [id, session?.user?.id ?? null, companyId, note ?? null]
+ );
+ return NextResponse.json({ ok: true });
+ });
+}
+```
+
+This is the concrete, code-verified answer to CONTEXT.md's open research question ("does resolving also update `pax8_companies.autotask_company_id`?") — **yes, it must**, or the resolution will appear to work in the Needs Review tab (row disappears) but silently fail to show up in the Companies tab's `autotask_company_id` join, and worse, `matchPax8Companies()` would immediately re-flag the company on the next sync since `match_method` was never set to `'manual'`.
+
+### Anti-Patterns to Avoid
+- **Restricting the resolve action's target company to `candidate_company_ids`** (like `device-link-conflicts` does for `ciId`): D-05 explicitly requires a manual-search fallback for when none of the top-3 candidates are correct, and D-09's zero-candidate case has an empty candidate array by design — a candidate-membership check would make both of those cases impossible to resolve.
+- **Reading `pax8_company_match_review.resolved_to_company_id` as the display-time join target**: it's an audit column, not a live pointer. Always join through `pax8_companies.autotask_company_id`.
+- **A single `MAX(start_period)` cutoff for "current" cost data**: see Pitfall 2 — this silently drops subscriptions on an earlier anniversary cycle.
+- **Passing raw PAX8 field names straight through to `DetailModal`'s formatted tab expecting automatic formatting**: `detectGroups()` doesn't know about PAX8 shapes at all today; without extension everything falls into the generic `{ label: 'Fields', fields: Object.keys(data) }` fallback — a flat, unstyled key/value dump with no subscriptions table.
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Paginated/sortable/searchable table | Custom table + manual pagination state | `components/admin/DataTable.tsx` (D-01) | Already handles manual sort/pagination dispatch, CSV export, loading/empty states |
+| Formatted/raw detail drill-down shell | New Dialog component from scratch | `components/admin/DetailModal.tsx`, extended (D-02) | Keeps visual consistency (header style, tab chrome, copy-to-clipboard) with every other detail view in the app — see Pitfall 1 for the specific extension needed |
+| Company name matching for the manual-search fallback | New fuzzy-search endpoint | Existing `/api/data/companies-list` (already returns `{id, company_name}` for all active companies) + client-side filter | Only ~242 rows; no pagination exists on that endpoint today because it's designed to be fetched once |
+| Auth/permission checks | Custom role-check logic in the route | `requireAuth()` / `requirePermission('admin','access')` from `lib/auth-utils.ts` (D-07/D-08) | Exact signatures already used by `device-link-conflicts` routes; `admin`/`access` resource-action pair confirmed to exist in `lib/permissions.ts`'s `statement` object |
+| Nav item styling/active-state logic | New nav component | Add one object to `navigationItems` in `components/navigation/app-navigation.tsx` | Both desktop `NavigationMenu` and mobile `Sheet` (`MobileNav`) consume the same array — one edit, two surfaces |
+
+**Key insight:** every "don't hand-roll" item here is an existing in-repo component, not an external package — this phase's leverage comes entirely from following precedent, not researching new tools.
+
+## Common Pitfalls
+
+### Pitfall 1: DetailModal.tsx cannot render the cost breakdown without an additive extension
+**What goes wrong:** CONTEXT.md's D-02 says "used as-is" for the drill-down. Reading the actual component (`components/admin/DetailModal.tsx`) shows `detectGroups(data)` only recognizes two shapes — `'ticket_number' in data` → `TICKET_GROUPS`, `'company_name' in data` → `COMPANY_GROUPS` — and falls back to a flat, un-grouped key/value dump for anything else. Every `FieldType` branch in `resolveLabel()` renders a single scalar (date/bool/badge/phone/url/id); none render an array. A PAX8 company object has a `name` field (not `company_name`), and even if aliased, the subscriptions/cost-breakdown array has no representation path at all.
+**Why it happens:** `DetailModal` was built ticket/company-specific and extended in place; it was never designed as a generic field-group renderer with a public extension point.
+**How to avoid:** Budget a task to extend `DetailModal.tsx` additively: (1) add an explicit optional `kind` prop (e.g. `kind?: 'ticket' | 'company' | 'pax8_company'`) so `detectGroups` no longer has to guess from ambiguous field-name sniffing — pass `kind="pax8_company"` from the new page; (2) add a `PAX8_COMPANY_GROUPS` field-group set for identity/contact/system fields (reusing existing `FieldType`s: `url` for website, `bool` for `is_deleted`); (3) add a dedicated non-`FieldGroup` section rendered unconditionally in the formatted tab when `data.subscriptions` (or similar) is an array — a compact table of product/qty/billing-term/cost rows plus a summed total, styled consistently with the existing rounded-border card sections. Do not touch `TICKET_GROUPS`/`COMPANY_GROUPS` or their existing detection branches — this must be a pure addition.
+**Warning signs:** If a plan task says "pass company + subscriptions to DetailModal" without a corresponding "extend DetailModal.tsx" task, the formatted tab will render as an unstyled field dump with no subscriptions data visible at all.
+
+### Pitfall 2: NCE subscriptions bill on per-subscription anniversary cycles — no single "current period" cutoff exists
+**What goes wrong:** A naive cost-breakdown query using `WHERE pax8_company_id = $1 ORDER BY start_period DESC LIMIT N` (or `MAX(start_period)` as a single cutoff) will return only the one or two subscriptions whose billing anniversary happens to be most recent, silently omitting every other active subscription on this company.
+**Why it happens:** Live-verified: a single sampled company's most-recent order-items spanned `start_period` values of `2026-07-01`, `2026-06-28`, `2026-06-22`, `2026-06-15`, and `2026-06-08` — five different subscriptions, five different anniversary dates, in the same "current" snapshot. This is Microsoft NCE's per-license anniversary billing model, not a data quality bug.
+**How to avoid:** Window the "latest" lookup **per `subscription_id`** (`DISTINCT ON (subscription_id) ... ORDER BY subscription_id, start_period DESC`, or an equivalent `ROW_NUMBER() OVER (PARTITION BY subscription_id ...)`), never a single company-wide cutoff. See Pattern 2's SQL.
+**Warning signs:** A cost breakdown that shows far fewer line items than the company's active-subscription count in `pax8_subscriptions`.
+
+### Pitfall 3: `unit_price × quantity` does not equal the actual amount billed
+**What goes wrong:** Computing a cost summary as `SUM(unit_price * quantity)` overstates real spend.
+**Why it happens:** PAX8 applies discounts/proration at the line level; `line_total` (and `amountDue` in the raw API shape) is the actual charged amount, `unit_price`/`price` is closer to list price. Live-verified example: `unit_price=155.65, quantity=1, line_total=109.00` on a real synced row.
+**How to avoid:** Use `line_total` (and `partner_cost_total` for the reseller-cost figure, if shown) directly as the per-line amount; never recompute from `unit_price * quantity`.
+**Warning signs:** Cost summary totals that look suspiciously round or higher than what the company's actual PAX8 invoice shows.
+
+### Pitfall 4: Some referenced products/subscriptions have blank names or no matching row
+**What goes wrong:** A cost-breakdown row renders with an empty product name, or a `LEFT JOIN` to `pax8_subscriptions` returns no billing-term/status because the subscription was later tombstoned.
+**Why it happens:** Live-verified: 3 of 436 distinct `subscription_id` values referenced by `pax8_order_items` have no matching `pax8_subscriptions` row (likely historical/cancelled subscriptions whose parent row was tombstoned or never synced). Separately, some order-item `description`/`sku` combinations exist with entirely blank product names in the raw payload (e.g. ad-hoc "Rate Plan Adjustment" / "Change of Channel" line items that aren't real product SKUs).
+**How to avoid:** Build a fallback label chain in the query or render layer: `COALESCE(product.name, order_item.description, order_item.sku, 'Unknown item')`. Don't `INNER JOIN` `pax8_products`/`pax8_subscriptions` when building the cost breakdown — use `LEFT JOIN` throughout, exactly as the existing sync service and matcher already do everywhere else in this schema.
+**Warning signs:** Blank cells in the subscriptions table, or rows silently disappearing from the cost breakdown vs. what a raw count of `pax8_order_items` for that company would suggest.
+
+### Pitfall 5: `pax8_order_items` is large (28.8K rows with a company set, live-verified) — always filter by `pax8_company_id` and `is_deleted = false`, never scan the whole table
+**What goes wrong:** An unfiltered or company-unscoped query against `pax8_order_items` for the cost-breakdown drill-down will scan far more data than needed (one company's history can be 5,000+ rows — the busiest sampled company had 5,856).
+**Why it happens:** The table accumulates full historical invoice line items since first sync (some as far back as 2019), not just current-state data.
+**How to avoid:** Every cost-breakdown query must be scoped by `pax8_company_id = $1` (indexed: `idx_pax8_order_items_company`) and `is_deleted = false`; never load the table for the main company-list route, only for the per-company drill-down.
+**Warning signs:** Slow drill-down modal opens; `postgresClient`'s built-in slow-query warning (`> 1000ms`) firing in logs.
+
+## Code Examples
+
+See Architecture Patterns 1-4 above for verified, live-data-grounded query and route-handler examples (company list join, per-company cost breakdown windowed join, review-queue bulk-fetch, and the two-table resolve transaction). All are adapted directly from existing in-repo code (`device-link-conflicts` routes, `pax8-company-matcher.ts`, `verify-pax8-orders-matching.ts`) rather than external sources.
+
+## State of the Art
+
+Not applicable in the usual sense — nothing in this phase is a library/framework whose "state of the art" shifts over time. The one internal precedent worth noting:
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|---------------|--------|
+| `pax8_orders.pax8_company_id` assumed usable for per-company cost joins (Phase 10's original schema comment) | `pax8_order_items.pax8_company_id` is the only usable per-company cost join key | Migration 093 (Phase 12), confirmed still true live | Any query joining through `pax8_orders` for company-scoped cost data will return nothing — this is documented in the migration's own header comment and re-confirmed by this research's live query |
+
+**Deprecated/outdated:** none — this schema is 8 migrations old (091→096) as of this research, all still current.
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | The exact API route paths (`app/api/pax8/companies`, `app/api/pax8/company-matches`, `.../[id]/resolve`) are a naming recommendation, not verified against any existing convention document — CONTEXT.md itself calls these "left to the planner." | Recommended Project Structure | Low — purely a naming choice, doesn't affect correctness; planner can rename freely without invalidating any other finding in this document |
+| A2 | The recommended `DetailModal` extension approach (add a `kind` prop + a dedicated array-rendering section) is this researcher's design recommendation, not something confirmed by reading a similar precedent elsewhere in the codebase — no other phase has extended `DetailModal.tsx` for a non-ticket/non-company shape. | Pitfall 1 | Medium — if the planner instead builds a fully separate bespoke modal component, that's also valid; the risk is only in underestimating the DetailModal-reuse work if this extension path is chosen |
+| A3 | `match_confidence` on `pax8_companies` should be set to `NULL` (not e.g. `1.000`) for a manually-resolved match, since there's no numeric trigram score for a human decision. | Pattern 4 (resolve transaction) | Low — cosmetic; if the planner prefers `1.000` for display purposes ("100% confidence"), that's a one-line change with no schema impact (`match_confidence` is nullable) |
+
+## Open Questions
+
+1. **Exact column set / labels for the Companies-tab table and the cost-breakdown formatted view**
+ - What we know: DataTable/DetailModal mechanics, available columns on every relevant table (verified via `\d` against the live schema), and D-02's minimum spec (product name, quantity, billing term, cost summary).
+ - What's unclear: whether the main list should show a subscription *count* column (query pattern shown in Pattern 1), a matched/unmatched status badge, or both — CONTEXT.md explicitly leaves this to the planner ("Claude's Discretion").
+ - Recommendation: include at minimum `name`, matched-Autotask-company (or "Unmatched"/badge), active subscription count, and city/country as a `DataTable` sort target — matches the level of detail `app/admin/data-browser/companies/page.tsx` uses for its own company list.
+
+2. **Whether the cost-breakdown summary should also surface `partner_cost_total` (reseller cost) alongside `line_total` (customer-facing cost)**
+ - What we know: both columns exist and are populated on `pax8_order_items`; in the sampled data, most rows had `partner_cost_total ≈ line_total` (no visible margin), but D-03/PAX8-13 only asks for "a cost breakdown," not a margin analysis.
+ - What's unclear: whether managers need to see partner cost at all, given margin/reselling isn't in this milestone's stated scope (`REQUIREMENTS.md` frames this purely as "see PAX8 subscription costs... without manually cross-referencing PAX8's own portal").
+ - Recommendation: show only the customer-facing `line_total`/`price` in the primary breakdown; if there's appetite for a "raw" partner-cost column it belongs in the Raw tab (which already dumps `raw_payload`), not the Formatted tab.
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| PostgreSQL | All queries in this phase | ✓ | 16 (pulse-postgres container, healthy) | — |
+| Docker Compose stack (pulse-app, pulse-postgres, pulse-redis) | Local verification during planning/implementation | ✓ | running (pulse-app up ~1h, pulse-postgres up ~2h at research time) | — |
+| PAX8 API credentials | N/A — this phase reads only already-synced Postgres data, never calls PAX8 directly | not needed | — | — |
+
+No missing dependencies — this phase has no new external dependency at all.
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | vitest 4.1.5 |
+| Config file | `vitest.config.ts` — `test.include: ['lib/**/*.test.ts']` (confirmed: page components and `app/api/**` route handlers are **structurally excluded** from `npm test` today) |
+| Quick run command | `npx tsc --noEmit --pretty` (the only automated gate that actually covers `app/pax8/**` and `app/api/pax8/**`) |
+| Full suite command | `npm test` (covers `lib/services/analyzer/**`, `lib/services/rmm/**`, `lib/services/b2/**` only — will not exercise any file this phase adds unless logic is extracted into `lib/services/`) |
+
+### Phase Requirements → Test Map
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| PAX8-13 | Company list renders with subscription/cost data | manual (page has no test coverage under current vitest glob) | `npx tsc --noEmit --pretty` (type-check only) | ❌ Wave 0 (no test file planned; matches `device-link-conflicts` precedent, which also has no test file) |
+| PAX8-12 / PAX8-14 | Resolve action writes both `pax8_companies` and `pax8_company_match_review` correctly and is admin-gated | unit (if cost-breakdown/resolve SQL is extracted into a `lib/services/pax8-*` helper) OR manual otherwise | `npx vitest run lib/services/pax8-company-matches.test.ts` (if extracted) | ❌ Wave 0 — recommend extracting the resolve transaction's write logic into a small `lib/services/pax8-company-match-resolver.ts` function specifically so it becomes unit-testable under the existing `lib/**/*.test.ts` glob, following `pax8-company-matcher.test.ts`'s existing mock-postgresClient convention |
+
+### Sampling Rate
+- **Per task commit:** `npx tsc --noEmit --pretty`
+- **Per wave merge:** `npm test` (won't cover new files unless logic is extracted per above, but must still pass — regressions elsewhere would still be caught)
+- **Phase gate:** Full suite green + manual click-through of both tabs (list, drill-down, resolve-with-candidate, resolve-via-manual-search, empty-state for zero-candidate row) before `/gsd:verify-work`
+
+### Wave 0 Gaps
+- [ ] Decide whether to extract resolve/cost-breakdown SQL into `lib/services/` for unit-test coverage, or accept manual-only verification (matches `device-link-conflicts`'s existing precedent — it also has zero automated tests)
+- [ ] If extracted: `lib/services/pax8-company-match-resolver.test.ts` — mock `postgresClient.query`, following `pax8-company-matcher.test.ts`'s existing convention (`vi.mock('@/lib/services/postgres-client', ...)`)
+- [ ] No new test framework/config needed — vitest is already configured project-wide
+
+*(If the planner accepts manual-only verification for this phase, matching `device-link-conflicts`'s existing precedent: state that explicitly in the plan rather than silently having zero coverage.)*
+
+## Security Domain
+
+### Applicable ASVS Categories
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | yes | Better Auth session cookie, checked via `requireAuth()`/`requirePermission()` — no new auth surface |
+| V3 Session Management | no | Unchanged — Better Auth handles this, out of scope for this phase |
+| V4 Access Control | yes | `requireAuth()` for view routes (D-07), `requirePermission('admin','access')` for the resolve mutation (D-08) — verified exact signatures in `lib/auth-utils.ts` |
+| V5 Input Validation | yes | Zod schema on the resolve POST body (companyId numeric, optional note capped in length), mirroring `device-link-conflicts/[id]/resolve`'s `ResolveBody` exactly |
+| V6 Cryptography | no | No new secrets/crypto in this phase |
+
+### Known Threat Patterns for this stack
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| SQL injection via company name / search query params | Tampering | Parameterized `postgresClient.query()` calls throughout (never string-interpolate user input) — matches every existing route in the codebase, including `pax8-company-matcher.ts`'s own `similarity($1, company_name)` discipline |
+| Privilege escalation via missing auth check on a new route (the exact gap CR-02 flagged on `/api/pax8/sync`) | Elevation of Privilege | Every new route in this phase must open with an explicit `requireAuth()` or `requirePermission()` call — no route may rely on middleware alone (middleware only checks session-cookie *presence*, never role) |
+| Resolve action linking a PAX8 company to an arbitrary/non-existent or inactive Autotask company id | Tampering | Validate the submitted `companyId` exists and (recommended) `is_active = true` in `companies` before the UPDATE — device-link-conflicts validates candidate-list membership instead, but D-05 requires accepting IDs outside the candidate list, so existence/active-state validation is the correct substitute check here |
+
+## Sources
+
+### Primary (HIGH confidence — direct codebase/DB verification, no external research needed)
+- `migrations/091_pax8_tables.sql`, `092_pax8_subscription_costs.sql`, `093_pax8_orders_company_matching.sql`, `094_pax8_order_items_quantity_numeric.sql`, `095_pax8_order_items_partner_cost_numeric.sql`, `096_pax8_daily_schedule.sql` — full schema history read directly
+- Live `docker exec pulse-postgres psql` queries against the actual running database (schema `\d` dumps + row counts + sample cost/period data) — run 2026-07-11
+- `lib/services/pax8-company-matcher.ts`, `lib/services/pax8-sync-service.ts`, `lib/types/pax8.ts` — read in full
+- `app/admin/device-link-conflicts/page.tsx`, `app/api/admin/device-link-conflicts/route.ts`, `app/api/admin/device-link-conflicts/[id]/resolve/route.ts` — read in full, the direct structural precedent
+- `components/admin/DataTable.tsx`, `components/admin/DetailModal.tsx`, `lib/auth-utils.ts`, `lib/permissions.ts`, `middleware.ts`, `components/navigation/app-navigation.tsx`, `components/navigation/mobile-nav.tsx` — read in full
+- `app/admin/data-browser/companies/page.tsx` — DataTable+DetailModal composition precedent
+- `app/api/data/companies-list/route.ts` — manual-search fallback data source
+- `.planning/phases/12-orders-invoices-company-matching/12-PATTERNS.md` — corroborates the resolve-transaction two-table requirement independently
+
+### Secondary / Tertiary
+None — this phase required no external library, framework, or web-search research; every claim traces to a file read or a live query in this session.
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — zero new packages, every component already in active use
+- Architecture: HIGH — every query pattern verified against live data; the resolve-transaction design is derived directly from reading the matcher's own eligibility SQL, not inferred
+- Pitfalls: HIGH — all five pitfalls are backed by a live query result from this project's actual database, not general PAX8/NCE domain knowledge
+
+**Research date:** 2026-07-11
+**Valid until:** No natural expiry — this is an internal-schema/internal-component phase with no external dependency to go stale. Re-verify only if a future migration changes `pax8_*` schema before Phase 14 is implemented.