diff --git a/.planning/STATE.md b/.planning/STATE.md
index 28402d9..578cae5 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -2,16 +2,16 @@
gsd_state_version: 1.0
milestone: v2.0
milestone_name: PAX8 Integration
-status: planning
+status: executing
stopped_at: Phase 14 UI-SPEC approved
-last_updated: "2026-07-11T17:25:07.470Z"
-last_activity: 2026-07-11
+last_updated: "2026-07-11T18:00:19.418Z"
+last_activity: 2026-07-11 -- Phase 14 planning complete
progress:
total_phases: 5
completed_phases: 4
- total_plans: 14
+ total_plans: 20
completed_plans: 14
- percent: 80
+ percent: 70
---
# Project State
@@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-07-10)
Phase: 14
Plan: Not started
-Status: Ready to plan
-Last activity: 2026-07-11
+Status: Ready to execute
+Last activity: 2026-07-11 -- Phase 14 planning complete
Progress: [░░░░░░░░░░] 0%
diff --git a/.planning/phases/14-pax8-ui-surface/14-PATTERNS.md b/.planning/phases/14-pax8-ui-surface/14-PATTERNS.md
new file mode 100644
index 0000000..5d4c20b
--- /dev/null
+++ b/.planning/phases/14-pax8-ui-surface/14-PATTERNS.md
@@ -0,0 +1,431 @@
+# Phase 14: /pax8 UI Surface - Pattern Map
+
+**Mapped:** 2026-07-11
+**Files analyzed:** 8 (1 page, 4 API routes, 1 extended component, 1 modified component, 1 nav edit)
+**Analogs found:** 8 / 8
+
+## File Classification
+
+| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
+|--------------------|------|-----------|-----------------|---------------|
+| `app/pax8/page.tsx` | component (page) | request-response (list + drill-down) | `app/engagement/page.tsx` (Tabs shell) + `app/admin/data-browser/companies/page.tsx` (DataTable/DetailModal composition) + `app/admin/device-link-conflicts/page.tsx` (review/resolve cards) | exact (composite of 3 precedents, no single one covers the whole page) |
+| `app/api/pax8/companies/route.ts` | route (API) | CRUD (read, paginated/sortable) | `app/api/admin/device-link-conflicts/route.ts` (query shape) + `app/admin/data-browser/companies/page.tsx`'s consumed `/api/data/companies` (pagination/sort param contract) | role-match |
+| `app/api/pax8/companies/[id]/route.ts` | route (API) | CRUD (read, single-entity aggregate) | `app/api/admin/device-link-conflicts/route.ts` (bulk-fetch-then-map pattern) | role-match |
+| `app/api/pax8/company-matches/route.ts` | route (API) | CRUD (read, list with filter) | `app/api/admin/device-link-conflicts/route.ts` | exact |
+| `app/api/pax8/company-matches/[id]/resolve/route.ts` | route (API) | CRUD (write, transactional) | `app/api/admin/device-link-conflicts/[id]/resolve/route.ts` | exact (with one required divergence — see Pattern 4 below) |
+| `components/admin/DetailModal.tsx` | component (modal, extended in place) | transform (render) | itself — additive extension only, see Pitfall/Pattern below | n/a (modification, not new file) |
+| `components/navigation/app-navigation.tsx` | component (nav config, edited) | n/a | itself — one array entry added | n/a (modification, not new file) |
+| `lib/services/pax8-company-match-resolver.ts` (optional, if extracting for testability) | service | CRUD (transactional write) | `lib/services/pax8-company-matcher.ts` (query/eligibility conventions) | role-match |
+
+## Pattern Assignments
+
+### `app/pax8/page.tsx` (component/page, request-response)
+
+**Analogs:** `app/engagement/page.tsx` (top-level Tabs page shell), `app/admin/data-browser/companies/page.tsx` (DataTable + DetailModal composition), `app/admin/device-link-conflicts/page.tsx` (review-card list + resolve action + Select filter + Skeleton/Alert states)
+
+**Page shell + Tabs pattern** (`app/engagement/page.tsx` lines 597-644):
+```tsx
+'use client';
+import { useEffect, useState, useCallback } from 'react';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+...
+return (
+
+
+
+
Employee Engagement
+
...
+
+
{/* actions */}
+
+
+
setActiveTab(v as 'overview' | 'by-employee')} className="space-y-6">
+
+ Overview
+ By Employee
+
+ ...
+
+
+);
+```
+Use this shell for `/pax8`'s two tabs: `"companies"` / `"needs-review"`. Note `app/admin/device-link-conflicts/page.tsx` instead uses `` (`components/navigation/page-header.tsx`) for its title block — either is acceptable; `PageHeader` gives breadcrumbs (`accent` prop) if the planner wants a heavier header, `engagement`'s inline `` is lighter. Given `/pax8` is a new top-level route (not nested under `/admin`), prefer the `PageHeader` pattern with `breadcrumbs={[{ label: 'PAX8' }]}` for consistency with how `device-link-conflicts` (its closest functional cousin) presents itself.
+
+**DataTable + DetailModal composition** (`app/admin/data-browser/companies/page.tsx` lines 38-78, 166-216):
+```tsx
+const [companies, setCompanies] = useState([]);
+const [totalCount, setTotalCount] = useState(0);
+const [page, setPage] = useState(1);
+const [selectedCompany, setSelectedCompany] = useState(null);
+const [modalOpen, setModalOpen] = useState(false);
+
+const fetchCompanies = async (currentPage: number, search?: string, sortBy?: string, sortOrder?: string) => {
+ setIsLoading(true);
+ try {
+ const params = new URLSearchParams({ page: currentPage.toString(), limit: pageSize.toString() });
+ if (search) params.append('search', search);
+ if (sortBy) params.append('sort', sortBy);
+ if (sortOrder) params.append('order', sortOrder);
+ const response = await fetch(`/api/data/companies?${params}`);
+ const result = await response.json();
+ setCompanies(result.data || []);
+ setTotalCount(result.pagination?.total || 0);
+ } catch (error) {
+ console.error('Failed to fetch companies:', error);
+ } finally {
+ setIsLoading(false);
+ }
+};
+
+const handleRowClick = (company: any) => {
+ setSelectedCompany(company);
+ setModalOpen(true);
+};
+
+// ... columns array with { key, label, sortable?, render? } ...
+
+ fetchCompanies(page, undefined, column, direction)}
+ onSearch={(query) => fetchCompanies(1, query)}
+ onRowClick={handleRowClick}
+ isLoading={isLoading}
+/>
+
+
+```
+For `/pax8`, `handleRowClick` should trigger a *second* fetch (`GET /api/pax8/companies/[id]`) to get subscriptions/cost-breakdown before opening the modal, since the list row won't carry the full drill-down payload (per research's Architecture Patterns 1-2 split). Fetch-then-open, not open-then-fetch-in-modal, keeps loading state visible on the row rather than inside the dialog.
+
+**Review-list + resolve-action + empty/error states** (`app/admin/device-link-conflicts/page.tsx` lines 80-129, 172-196, 198-282):
+```tsx
+async function resolve(reviewId: string, ciId: string): Promise {
+ setResolving(`${reviewId}:${ciId}`);
+ try {
+ const res = await fetch(`/api/admin/device-link-conflicts/${reviewId}/resolve`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ ciId }),
+ });
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
+ if (!res.ok) throw new Error(data.error ?? `Request failed: ${res.status}`);
+ toast.success(`Linked to CI ${ciId}`);
+ setItems((prev) => prev?.filter((r) => r.id !== reviewId) ?? null);
+ setTotal((t) => Math.max(0, t - 1));
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Resolve failed');
+ } finally {
+ setResolving(null);
+ }
+}
+```
+Error/loading/empty-state trio to copy verbatim (swap copy text):
+```tsx
+{error && (
+ Failed to load{error}
+)}
+{items === null && !error && (
+ ...
+)}
+{items !== null && items.length === 0 && !error && (
+ No conflictsNothing waiting for review on this filter.
+)}
+```
+D-05's manual-search fallback and D-09's empty-candidate-array empty state ("No suggested matches — search manually") are new UI not present in `device-link-conflicts` (which has no manual-picker fallback) — build a `Command`/`Popover` combobox fed by `/api/data/companies-list` (see below), rendered inside each review card alongside (or instead of, when `candidate_company_ids` is empty) the candidate buttons.
+
+---
+
+### `app/api/pax8/companies/route.ts` (route, CRUD/read)
+
+**Analog:** `app/api/admin/device-link-conflicts/route.ts` (query construction + auth placement), consumed-contract shape from `app/admin/data-browser/companies/page.tsx`'s `/api/data/companies` (page/limit/sort/order params + `{ data, pagination: { total } }` response envelope — verify actual response shape against that route before matching exactly, this phase's route can use either envelope shape but should be internally consistent)
+
+**Auth pattern** — D-07 requires only `requireAuth()`, NOT `requirePermission()` (this file's routes must NOT copy `device-link-conflicts`' `requirePermission('admin','access')` for the read routes):
+```typescript
+import { requireAuth } from '@/lib/auth-utils';
+export async function GET(request: NextRequest) {
+ const { error } = await requireAuth();
+ if (error) return error;
+ // ...
+}
+```
+
+**Paginated/parameterized query pattern** (`app/api/admin/device-link-conflicts/route.ts` lines 40-55):
+```typescript
+const url = request.nextUrl;
+const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '50', 10) || 50, 200);
+const offset = Math.max(parseInt(url.searchParams.get('offset') ?? '0', 10) || 0, 0);
+const params: unknown[] = [limit, offset];
+```
+
+**Company list join** (verified live against schema — RESEARCH.md Pattern 1, adapted from `scripts/verify-pax8-orders-matching.ts` lines 76-85):
+```sql
+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;
+```
+
+**Response transform (snake_case → camelCase)** — no existing file does this exact shape for pax8, but every route in the codebase manually maps in the handler, e.g. `app/api/admin/device-link-conflicts/route.ts` lines 103-130:
+```typescript
+const items = reviews.rows.map((r) => ({
+ id: r.id,
+ detectedAt: r.detected_at,
+ // ...
+}));
+return NextResponse.json({ items, total, limit, offset });
+```
+
+---
+
+### `app/api/pax8/companies/[id]/route.ts` (route, CRUD/read, aggregate)
+
+**Analog:** `app/api/admin/device-link-conflicts/route.ts`'s bulk-fetch-then-map pattern (lines 74-92) for the two-step "current subscriptions" + "latest order-item per subscription" join described in RESEARCH.md Pattern 2. No exact single-entity aggregate precedent exists elsewhere in the codebase for pax8 — this is a genuinely new query shape, follow the two-SQL-then-join-in-JS approach RESEARCH.md's Pattern 2 lays out rather than a single mega-query, to keep the per-subscription `DISTINCT ON` windowing (Pitfall 2) legible:
+```sql
+-- Step 1
+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 (per-subscription windowed latest, NOT a single MAX(start_period))
+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;
+```
+Auth: `requireAuth()` only (D-07) — same as the list route.
+
+---
+
+### `app/api/pax8/company-matches/route.ts` (route, CRUD/read)
+
+**Analog:** `app/api/admin/device-link-conflicts/route.ts` — near-exact structural match, entire file (34-133) is the template.
+
+**Auth** — D-07: view is `requireAuth()`, NOT `requirePermission('admin','access')` (this is the one place this phase's routes diverge from `device-link-conflicts`, which gates its GET admin-only — CONTEXT.md D-08 explicitly calls this out as deliberate).
+
+**Bulk-fetch-candidates query** (adapted, `app/api/admin/device-link-conflicts/route.ts` lines 56-92, RESEARCH.md Pattern 3):
+```typescript
+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]
+);
+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 divergence flagged in RESEARCH.md: `candidate_company_ids` is `BIGINT[]`, not `UUID[]` like `device_link_review.candidate_ci_ids` — no `::text[]` cast needed on the array column itself in the SELECT.
+
+---
+
+### `app/api/pax8/company-matches/[id]/resolve/route.ts` (route, CRUD/write, transactional)
+
+**Analog:** `app/api/admin/device-link-conflicts/[id]/resolve/route.ts` — full file is the template, with one required divergence (see below).
+
+**Auth pattern (D-08 — copy exactly, this is the one route that DOES match device-link-conflicts' gating):**
+```typescript
+const { session, error } = await requirePermission('admin', 'access');
+if (error) return error;
+```
+
+**Zod validation pattern** (lines 18-21, adapt field names):
+```typescript
+const ResolveBody = z.object({
+ companyId: z.number().int().positive(),
+ note: z.string().max(500).optional(),
+});
+```
+
+**Transaction + row-lock pattern** (lines 51-98) — copy the `FOR UPDATE` + already-resolved 409 guard verbatim:
+```typescript
+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 });
+ // DIVERGENCE: device-link-conflicts validates ciId is in candidate_ci_ids here
+ // and 400s otherwise. DO NOT copy that check — D-05 requires accepting any
+ // valid Autotask companyId, not just candidates (manual-search fallback).
+ // Instead: validate companyId exists in `companies` (and is_active) before writing.
+ ...
+});
+```
+
+**Required two-table write (the actual divergence from the analog — device-link-conflicts only writes one other table):**
+```typescript
+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]
+);
+```
+This is load-bearing: `lib/services/pax8-company-matcher.ts` lines 216-231 excludes companies from re-matching via `c.match_method IS DISTINCT FROM 'manual'` AND a `NOT EXISTS` against resolved review rows — both signals must be set or the resolution won't "stick" against the next sync (D-06's persistence requirement).
+
+**Anti-pattern to avoid** (explicit — matches `device-link-conflicts`' own candidate-membership check, which must NOT be copied here): don't restrict `companyId` to `candidate_company_ids` membership. Validate existence/active-state in `companies` instead.
+
+---
+
+### `components/admin/DetailModal.tsx` (component, extended in place — not a new file)
+
+**This is a modification, not a from-scratch pattern.** Read in full; confirmed structure:
+
+- `detectGroups(data)` (lines 256-260) sniffs field presence to pick a group set:
+```typescript
+function detectGroups(data: Record): FieldGroup[] {
+ if ('ticket_number' in data) return TICKET_GROUPS;
+ if ('company_name' in data) return COMPANY_GROUPS;
+ return [{ label: 'Fields', fields: Object.keys(data).map(k => ({ key: k, label: k })) }];
+}
+```
+A PAX8 company row has `name`, not `company_name` — it will silently fall into the flat unstyled fallback branch today. Required additive change: add a `kind?: 'ticket' | 'company' | 'pax8_company'` prop to `DetailModalProps` (line 266-272) so the new page passes `kind="pax8_company"` explicitly instead of relying on field-name sniffing, and branch `detectGroups`/the header block on that prop when present (fall back to existing sniffing when absent, so `TICKET_GROUPS`/`COMPANY_GROUPS`/every other current caller is untouched).
+
+- Header block also branches on `'ticket_number' in data` (lines 348-375) — same pattern, needs a `kind`-aware branch alongside, not instead of, the existing ternary.
+
+- Every `FieldType` in `resolveLabel()` (lines 136-254) renders a single scalar — **no array-rendering path exists**. The subscriptions/cost-breakdown table (D-02/D-03) needs a new, unconditionally-rendered section in the Formatted tab (parallel to the existing "Description block for tickets" section at lines 543-551, which is exactly this shape — an unconditional extra block keyed on field presence):
+```tsx
+{'description' in data && data.description && (
+
+
Description
+
{data.description}
+
+)}
+```
+Copy this block's shape (conditional wrapper + `` label + bordered container) for a new `{data.subscriptions && Array.isArray(data.subscriptions) && (...)}` section rendering a compact table (product name / qty / billing term / line total) plus a summed total, styled with the same `rounded-lg border` / `text-xs uppercase tracking-wider` conventions used throughout this file — do not introduce a different visual language.
+
+**Do not touch:** `TICKET_GROUPS`, `COMPANY_GROUPS`, or their existing detection branches (lines 49-132, 256-260's first two conditions) — this must be a pure addition per RESEARCH.md Pitfall 1.
+
+---
+
+### `components/navigation/app-navigation.tsx` (nav config, edited)
+
+**Pattern** (lines 50-195: `navigationItems` array; lines 211-217: role-based filter):
+```typescript
+{
+ title: 'PAX8',
+ href: '/pax8',
+ icon: /* pick a lucide icon not already used at top level, e.g. ShoppingCart or CreditCard */,
+ description: 'PAX8 companies, subscriptions, and cost breakdown',
+},
+```
+Add as a **top-level, non-nested** entry (like `Dashboard`/`Configuration Items`) — not inside the `Admin` children array — because the `visibleItems` filter (lines 212-217) only hides items titled `'Engagement'` or `'Admin'` from non-super-admins; every other top-level item (including a new `PAX8` one) is visible to all authenticated users by default, matching D-07's view-access requirement without any filter-logic change. `MobileNav` (`components/navigation/mobile-nav.tsx`) consumes the same `navigationItems` array — confirm no separate edit is needed there (per RESEARCH.md, one array edit covers both surfaces).
+
+---
+
+## Shared Patterns
+
+### Authentication / Authorization split (D-07 / D-08)
+**Source:** `lib/auth-utils.ts` lines 31-45 (`requireAuth`), 51-74 (`requirePermission`)
+**Apply to:**
+- `requireAuth()` only → `app/api/pax8/companies/route.ts`, `app/api/pax8/companies/[id]/route.ts`, `app/api/pax8/company-matches/route.ts` (view routes, D-07)
+- `requirePermission('admin', 'access')` → `app/api/pax8/company-matches/[id]/resolve/route.ts` (mutation, D-08)
+```typescript
+const { session, error } = await requireAuth(); // view routes
+if (error) return error;
+
+const { session, error } = await requirePermission('admin', 'access'); // resolve route
+if (error) return error;
+```
+Every new route MUST call one of these explicitly at the top of the handler — do not rely on `middleware.ts` (it only checks session-cookie presence, confirmed by reading `middleware.ts`; `/api/pax8/sync/route.ts` at lines 1-30 is the existing counter-example with **no** auth check at all — CONTEXT.md/RESEARCH.md both explicitly flag this as a gap not to repeat).
+
+### Error handling / response shape
+**Source:** every route above; convention is uniform across the codebase.
+```typescript
+try {
+ // ...
+} catch (err) {
+ console.error('Failed to ...:', err);
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Failed to ...' },
+ { status: 500 }
+ );
+}
+```
+404/409/400 are returned inline (not via catch) for expected conditions (`Review not found`, `Already resolved`, validation failure) — see the resolve route analog.
+
+### Toasts + resolve-action UX
+**Source:** `app/admin/device-link-conflicts/page.tsx` lines 111-129
+**Apply to:** the resolve button/handler in the "Needs Review" tab
+```typescript
+toast.success(`Linked to CI ${ciId}`); // adapt message
+toast.error(err instanceof Error ? err.message : 'Resolve failed');
+```
+
+### Manual-search fallback data source (D-05)
+**Source:** `app/api/data/companies-list/route.ts` (full file, 15 lines) — returns `[{id, company_name}, ...]` for all active, non-deleted companies, no pagination, no auth check today (fine to leave as-is per RESEARCH.md — only ~242 rows, fetched once and filtered client-side; consider whether this phase should add a `requireAuth()` to it too, since D-08's spirit is "every new route" — this route isn't new, but flagging for planner judgment).
+```typescript
+const result = await postgresClient.query(
+ `SELECT id, company_name FROM companies WHERE is_active = true AND is_deleted = false ORDER BY company_name ASC`
+);
+return NextResponse.json(result.rows);
+```
+Fetch once on mount/tab-open in the page, filter client-side with a shadcn `Command`/`Popover` combobox for D-05's manual-picker fallback.
+
+### Postgres transaction pattern
+**Source:** `app/api/admin/device-link-conflicts/[id]/resolve/route.ts` lines 51-98
+**Apply to:** `app/api/pax8/company-matches/[id]/resolve/route.ts`
+```typescript
+return postgresClient.transaction(async (tx) => {
+ const row = await tx.query(`... FOR UPDATE`, [id]);
+ // guards (not found / already resolved) return early inside the transaction callback
+ await tx.query(`UPDATE ...`, [...]);
+ await tx.query(`UPDATE ...`, [...]);
+ return NextResponse.json({ ok: true });
+});
+```
+
+## No Analog Found
+
+| File | Role | Data Flow | Reason |
+|------|------|-----------|--------|
+| Per-company windowed cost-breakdown query (`DISTINCT ON (subscription_id) ... ORDER BY subscription_id, start_period DESC`) | query pattern within `app/api/pax8/companies/[id]/route.ts` | transform/aggregate | No existing route in the codebase performs a per-subscription-windowed latest-row join; this is genuinely new SQL. RESEARCH.md Pattern 2 + Pitfall 2 is the authoritative source — follow that, not a codebase analog. |
+| Array-rendering section inside `DetailModal`'s Formatted tab | component (modal section) | transform (render) | No existing `FieldGroup`/`FieldType` renders a list of rows; closest precedent is the unconditional "Description block for tickets" (single scalar, not a table) — extend from that shape, budgeted as its own task per RESEARCH.md Pitfall 1. |
+
+## Metadata
+
+**Analog search scope:** `app/admin/device-link-conflicts/**`, `app/api/admin/device-link-conflicts/**`, `components/admin/DataTable.tsx`, `components/admin/DetailModal.tsx`, `components/navigation/app-navigation.tsx`, `app/admin/data-browser/companies/page.tsx`, `app/api/data/companies-list/route.ts`, `app/engagement/page.tsx`, `lib/auth-utils.ts`, `lib/services/pax8-company-matcher.ts`, `middleware.ts`
+**Files scanned:** 11 read in full (all cited above); `migrations/091,093_pax8_*.sql` schema referenced via RESEARCH.md's already-verified live-DB queries rather than re-read (no new information would be gained from re-reading migration files RESEARCH.md already fully quotes)
+**Pattern extraction date:** 2026-07-11