docs(14): record planning completion and pattern map

STATE.md now reflects Phase 14 as ready to execute (6 plans). Adds
14-PATTERNS.md (analog files + code excerpts) produced during planning,
consumed by execute-phase.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHRgZqkzBHBbAbc3KHneuR
This commit is contained in:
lorentz 2026-07-11 14:00:38 -04:00
parent 4a2230c457
commit d2dfc341da
2 changed files with 438 additions and 7 deletions

View file

@ -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%

View file

@ -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 (
<div className="container mx-auto px-6 py-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Employee Engagement</h1>
<p className="text-muted-foreground text-sm mt-1">...</p>
</div>
<div className="flex items-center gap-3">{/* actions */}</div>
</div>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'overview' | 'by-employee')} className="space-y-6">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="by-employee">By Employee</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">...</TabsContent>
</Tabs>
</div>
);
```
Use this shell for `/pax8`'s two tabs: `"companies"` / `"needs-review"`. Note `app/admin/device-link-conflicts/page.tsx` instead uses `<PageHeader>` (`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 `<h1>` 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<any>(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? } ...
<DataTable
columns={columns}
data={companies}
totalCount={totalCount}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onSort={(column, direction) => fetchCompanies(page, undefined, column, direction)}
onSearch={(query) => fetchCompanies(1, query)}
onRowClick={handleRowClick}
isLoading={isLoading}
/>
<DetailModal
open={modalOpen}
onOpenChange={setModalOpen}
title={`Company: ${selectedCompany?.name ?? selectedCompany?.id}`}
data={selectedCompany}
/>
```
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<void> {
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 && (
<Alert variant="destructive"><AlertTitle>Failed to load</AlertTitle><AlertDescription>{error}</AlertDescription></Alert>
)}
{items === null && !error && (
<div className="space-y-2"><Skeleton className="h-24 w-full" />...</div>
)}
{items !== null && items.length === 0 && !error && (
<Alert><CheckCircle2 className="size-4" /><AlertTitle>No conflicts</AlertTitle><AlertDescription>Nothing waiting for review on this filter.</AlertDescription></Alert>
)}
```
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<number>();
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<string, any>): 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 && (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">Description</h3>
<div className="rounded-lg border p-4 text-sm whitespace-pre-wrap ...">{data.description}</div>
</div>
)}
```
Copy this block's shape (conditional wrapper + `<h3>` 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