---
phase: 14-pax8-ui-surface
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
  - app/api/pax8/company-matches/route.ts
  - lib/services/pax8-company-match-resolver.ts
  - lib/services/pax8-company-match-resolver.test.ts
  - app/api/pax8/company-matches/[id]/resolve/route.ts
autonomous: true
requirements: [PAX8-12, PAX8-14]
must_haves:
  truths:
    - "An authenticated user can GET /api/pax8/company-matches and receive the unresolved review queue with each PAX8 company's stored top-3 candidate Autotask companies and their names; this GET uses requireAuth() only, not requirePermission, per D-07"
    - "An admin (and only an admin) can POST /api/pax8/company-matches/[id]/resolve to link a flagged PAX8 company to any existing active Autotask company, gated by requirePermission('admin','access') per D-08"
    - "Resolving writes BOTH pax8_companies.match_method='manual' and pax8_company_match_review.resolved_* in one transaction per D-06, so the resolution survives the next sync's re-matching pass"
    - "The resolve action accepts a companyId outside candidate_company_ids (manual-search fallback / zero-candidate case per D-05) provided that company exists and is active"
  artifacts:
    - path: "app/api/pax8/company-matches/route.ts"
      provides: "GET unresolved review queue with bulk-fetched candidate names, requireAuth-gated"
      exports: ["GET"]
    - path: "lib/services/pax8-company-match-resolver.ts"
      provides: "resolvePax8CompanyMatch(tx, params) two-table transactional write + validation, unit-testable"
      exports: ["resolvePax8CompanyMatch", "ResolveResult"]
    - path: "lib/services/pax8-company-match-resolver.test.ts"
      provides: "vitest coverage of the resolve logic (both writes, guards, no candidate-membership restriction)"
    - path: "app/api/pax8/company-matches/[id]/resolve/route.ts"
      provides: "POST resolve, requirePermission('admin','access')-gated, zod-validated"
      exports: ["POST"]
  key_links:
    - from: "app/api/pax8/company-matches/[id]/resolve/route.ts"
      to: "lib/services/pax8-company-match-resolver.ts"
      via: "postgresClient.transaction(tx => resolvePax8CompanyMatch(tx, ...))"
      pattern: "resolvePax8CompanyMatch"
    - from: "lib/services/pax8-company-match-resolver.ts"
      to: "pax8_companies + pax8_company_match_review"
      via: "two UPDATE statements in the same tx"
      pattern: "match_method = 'manual'"
---

<objective>
Build the review-queue read route, the resolve mutation route, and — the load-bearing piece — an extracted `resolvePax8CompanyMatch()` service that performs the two-table transactional write and is unit-testable under the existing `lib/**/*.test.ts` vitest glob.

Purpose: PAX8-12 (admin resolves flagged matches) and PAX8-14 (surface flagged matches for resolution). This is the phase's only genuinely test-worthy logic: the resolution must set BOTH `pax8_companies.match_method='manual'` AND mark the review row resolved, or `pax8-company-matcher.ts`'s re-scoring guard will re-flag the company on the next sync. Extracting the write into `lib/services/` (Validation-strategy Wave 0 decision: EXTRACT for coverage) gives it real automated verification instead of manual-only.
Output: review list route, resolver service + test, resolve route.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/14-pax8-ui-surface/14-RESEARCH.md
@.planning/phases/14-pax8-ui-surface/14-PATTERNS.md
@.planning/phases/14-pax8-ui-surface/14-CONTEXT.md

<interfaces>
<!-- Executor uses these directly. -->

Auth helpers (lib/auth-utils.ts):
  requireAuth(): { session, error }          // review LIST route (D-07)
  requirePermission('admin','access'): { session, error }  // resolve route ONLY (D-08)

Postgres (lib/services/postgres-client.ts):
  import postgresClient from '@/lib/services/postgres-client';
  postgresClient.query<T>(sql, params): Promise<{ rows: T[]; rowCount: number }>;
  postgresClient.transaction<T>(cb: (tx: PoolClient) => Promise<T>): Promise<T>;
  // PoolClient has .query(sql, params): Promise<{ rows; rowCount }> — the tx handle.

Schema (migration 091/093, verified):
  pax8_company_match_review(
    id UUID PK, pax8_company_id UUID NOT NULL REFERENCES pax8_companies(id),
    candidate_company_ids BIGINT[] NOT NULL,   -- Autotask company ids (integers), NOT uuid[]
    match_confidences TEXT[] NOT NULL,
    detected_at TIMESTAMPTZ, resolved_at TIMESTAMPTZ,
    resolved_by_user_id TEXT REFERENCES "user"(id),
    resolved_to_company_id BIGINT REFERENCES companies(id),
    resolution_note TEXT)
  pax8_companies( ... autotask_company_id BIGINT, match_confidence NUMERIC(4,3),
                  match_method TEXT, matched_at TIMESTAMPTZ )
  companies(id BIGINT, company_name VARCHAR, is_active BOOL, is_deleted BOOL)

Matcher re-scoring eligibility guard (lib/services/pax8-company-matcher.ts lines ~216-231) — the reason BOTH writes are required:
  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)

Review-queue query (RESEARCH.md Pattern 3):
  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;
  Then bulk-fetch candidate names:
  SELECT id, company_name FROM companies WHERE id = ANY($1::bigint[]);

Resolver contract to CREATE (lib/services/pax8-company-match-resolver.ts):
  export type ResolveResult =
    | { ok: true; resolvedToCompanyId: number }
    | { ok: false; code: 'not_found' | 'already_resolved' | 'company_not_found'; message: string };
  export async function resolvePax8CompanyMatch(
    tx: { query: <T = any>(sql: string, params?: unknown[]) => Promise<{ rows: T[]; rowCount: number }> },
    params: { reviewId: string; companyId: number; note: string | null; userId: string | null }
  ): Promise<ResolveResult>;

Zod resolve body (mirror device-link-conflicts/[id]/resolve/route.ts ResolveBody):
  const ResolveBody = z.object({ companyId: z.number().int().positive(), note: z.string().max(500).optional() });
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: GET /api/pax8/company-matches — unresolved review queue</name>
  <read_first>
    - app/api/admin/device-link-conflicts/route.ts (full file — near-exact template: limit/offset, bulk-fetch candidates in one query, snake_case→camelCase map, response envelope)
    - lib/auth-utils.ts (requireAuth — D-07: this GET is NOT admin-gated, unlike the device-link-conflicts GET it copies)
    - .planning/phases/14-pax8-ui-surface/14-RESEARCH.md (Pattern 3, note on BIGINT[] vs UUID[])
  </read_first>
  <files>app/api/pax8/company-matches/route.ts</files>
  <action>
    Create `GET(request: NextRequest)` gated by `requireAuth()` (D-07 — deliberately NOT `requirePermission`; viewing the queue is manager-visible, only the resolve mutation is admin-gated). Parse `limit` (default 50, max 200) and `offset` (default 0) as in device-link-conflicts. Run the review-queue query from the interfaces block. Collect all candidate ids into a `Set<number>` (coerce each with `Number`), bulk-fetch their names with `SELECT id, company_name FROM companies WHERE id = ANY($1::bigint[])`, build a `Map<number,string>`. Also fetch total: `SELECT COUNT(*) FROM pax8_company_match_review WHERE resolved_at IS NULL`. Transform each review row to `{ id, detectedAt, pax8CompanyId, pax8CompanyName, candidates: [...] }` where `candidates` zips `candidate_company_ids[i]` with `match_confidences[i]` into `{ companyId: Number(id), companyName: nameMap.get(Number(id)) ?? null, confidence: match_confidences[i] ?? null }`. Return `NextResponse.json({ items, total, limit, offset })`. Note the BIGINT[] type: `candidate_company_ids` needs no `::text[]` cast on the array column itself (unlike device-link-conflicts' UUID[]). try/catch → 500 + console.error.
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - `GET` handler's first statement is `requireAuth()` (grep: `requireAuth` present, `requirePermission` absent in this file)
    - Query filters `WHERE r.resolved_at IS NULL` and joins pax8_companies for the flagged company name
    - Candidate names are bulk-fetched in a single `= ANY($1::bigint[])` query (not one query per candidate)
    - Each item exposes `candidates[]` with `companyId`, `companyName`, `confidence`
    - `npx tsc --noEmit --pretty` passes
  </acceptance_criteria>
  <done>GET /api/pax8/company-matches returns the 38 known open review rows (16 no-candidate, 22 ambiguous) with candidate names resolved, for any authenticated user.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: resolvePax8CompanyMatch service + unit test (RED→GREEN)</name>
  <read_first>
    - lib/services/pax8-company-matcher.test.ts (lines 1-40 — the vi.mock('@/lib/services/postgres-client') convention and calls() helper to replicate)
    - app/api/admin/device-link-conflicts/[id]/resolve/route.ts (the FOR UPDATE lock + already-resolved 409 guard + two-table write shape to adapt — but this resolver DIVERGES: no candidate-membership check, and writes pax8_companies too)
    - lib/services/pax8-company-matcher.ts (lines 216-231 — the guard that makes BOTH writes mandatory)
    - .planning/phases/14-pax8-ui-surface/14-RESEARCH.md (Pattern 4, Anti-Patterns, Security Domain row on companyId existence validation)
  </read_first>
  <files>lib/services/pax8-company-match-resolver.ts, lib/services/pax8-company-match-resolver.test.ts</files>
  <behavior>
    - Given a mock tx: SELECT review FOR UPDATE returns an unresolved row → resolver issues an UPDATE to pax8_companies setting autotask_company_id=$companyId, match_confidence=NULL, match_method='manual', matched_at=NOW(), AND an UPDATE to pax8_company_match_review setting resolved_at=NOW(), resolved_by_user_id, resolved_to_company_id, resolution_note; returns { ok: true, resolvedToCompanyId }
    - Given the review row is missing (rowCount 0) → returns { ok: false, code: 'not_found' } and issues NO update statements
    - Given the review row already has resolved_at set → returns { ok: false, code: 'already_resolved' } and issues NO update statements
    - Given the target companyId does NOT exist / is not active in companies (existence-check query returns 0 rows) → returns { ok: false, code: 'company_not_found' } and issues NO write to pax8_companies
    - Given a companyId that is NOT in candidate_company_ids but exists+is_active → still resolves successfully (candidate-membership is NOT enforced — D-05 manual-search / D-09 zero-candidate)
  </behavior>
  <action>
    Create `lib/services/pax8-company-match-resolver.ts` exporting the `ResolveResult` type and `resolvePax8CompanyMatch(tx, params)` per the interfaces contract. Sequence inside the function using the passed `tx.query`: (1) `SELECT pax8_company_id, resolved_at FROM pax8_company_match_review WHERE id = $1 FOR UPDATE` — rowCount 0 → return `not_found`; `resolved_at` truthy → return `already_resolved`. (2) Validate the target: `SELECT 1 FROM companies WHERE id = $1 AND is_active = true AND is_deleted = false` — 0 rows → return `company_not_found` (this is the substitute for device-link-conflicts' candidate-membership check; D-05 requires accepting non-candidate ids). Do NOT check `candidate_company_ids` membership. (3) `UPDATE pax8_companies SET autotask_company_id = $2, match_confidence = NULL, match_method = 'manual', matched_at = NOW() WHERE id = $1` keyed on the review's `pax8_company_id`. (4) `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`. Return `{ ok: true, resolvedToCompanyId: companyId }`. Write the test FIRST (RED): create `lib/services/pax8-company-match-resolver.test.ts` following pax8-company-matcher.test.ts's mocking discipline — but since the resolver takes `tx` as a parameter, the test passes a hand-rolled mock tx `{ query: vi.fn() }` scripted to return the sequenced results, and asserts on the SQL strings + bound params of each `query` call and on the returned ResolveResult for each behavior case above. Run the test, confirm it fails, implement, confirm it passes.
  </action>
  <verify>
    <automated>npx vitest run lib/services/pax8-company-match-resolver.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - `lib/services/pax8-company-match-resolver.ts` exports `resolvePax8CompanyMatch` and `ResolveResult`
    - The success path issues exactly the two UPDATEs with `match_method = 'manual'` and the review resolve columns (grep: both `match_method = 'manual'` and `resolved_at = NOW()` present)
    - No candidate-membership check exists (grep: no reference to `candidate_company_ids` in the resolver source)
    - Company existence/active validation query is present before the pax8_companies UPDATE
    - `npx vitest run lib/services/pax8-company-match-resolver.test.ts` passes with all five behavior cases green
  </acceptance_criteria>
  <done>The resolver writes both tables atomically, guards not-found/already-resolved/company-not-found, allows non-candidate ids, and is covered by a passing vitest suite.</done>
</task>

<task type="auto">
  <name>Task 3: POST /api/pax8/company-matches/[id]/resolve — admin-gated route</name>
  <read_first>
    - app/api/admin/device-link-conflicts/[id]/resolve/route.ts (full file — requirePermission gate, uuid id validation, zod body parse, transaction wrapper to mirror)
    - lib/services/pax8-company-match-resolver.ts (the resolver created in Task 2 — this route wraps it)
    - lib/auth-utils.ts (requirePermission signature; session.user.id for resolved_by_user_id)
  </read_first>
  <files>app/api/pax8/company-matches/[id]/resolve/route.ts</files>
  <action>
    Create `POST(request, { params }: { params: Promise<{ id: string }> })`. First statement: `const { session, error } = await requirePermission('admin', 'access'); if (error) return error;` (D-08 — copy device-link-conflicts' gate exactly; this is the one route that IS admin-gated). `const { id } = await params;` validate UUID shape (`/^[0-9a-f-]{36}$/i`) → 400 if invalid. Parse JSON body (400 on parse failure), validate with the `ResolveBody` zod schema from interfaces (`companyId` positive int, optional `note` max 500) → 400 with `parsed.error.flatten()` on failure. Call `postgresClient.transaction(tx => resolvePax8CompanyMatch(tx, { reviewId: id, companyId: parsed.data.companyId, note: parsed.data.note ?? null, userId: session?.user?.id ?? null }))`. Map the returned `ResolveResult` to HTTP: `ok` → `NextResponse.json({ ok: true, resolvedToCompanyId })` 200; `not_found` → 404; `already_resolved` → 409; `company_not_found` → 400 — each with the result's message in `{ error }`. Wrap in try/catch → 500 + `console.error('Failed to resolve PAX8 company match:', err)`.
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - First statement of `POST` invokes `requirePermission('admin', 'access')` (grep confirms; NOT `requireAuth` alone)
    - Body is validated with a zod schema (`companyId` int positive, `note` optional max 500) before any DB work
    - Route calls `resolvePax8CompanyMatch` inside `postgresClient.transaction(...)` (grep: both identifiers present)
    - Result codes map to statuses: ok→200, not_found→404, already_resolved→409, company_not_found→400
    - `npx tsc --noEmit --pretty` passes
  </acceptance_criteria>
  <done>POST resolve is admin-only, zod-validated, and delegates the two-table write to the tested resolver inside a transaction.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| browser → GET /api/pax8/company-matches | Any authenticated user reads the flagged-match queue (read side of the asymmetric split) |
| browser → POST .../[id]/resolve | Privilege boundary: mutating a match is admin-only (write side of the asymmetric split) |
| resolve route → Postgres | Two-table transactional write repointing pax8_companies.autotask_company_id (a BIGINT FK-by-convention to companies) |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-14-01 | Elevation of Privilege | POST /api/pax8/company-matches/[id]/resolve | mitigate | Handler opens with `requirePermission('admin','access')` (D-08). This is the deliberate asymmetry vs the read routes' `requireAuth()` — the GET queue is manager-visible, only the mutation crosses the admin boundary. Do not rely on middleware (it checks cookie presence only). Do NOT repeat the `/api/pax8/sync` no-auth gap (13-REVIEW CR-02) |
| T-14-02 | Tampering | resolve target companyId (D-05 accepts ids outside candidate_company_ids) | mitigate | Because the manual-search fallback intentionally allows any Autotask company id, the resolver validates `SELECT 1 FROM companies WHERE id = $1 AND is_active = true AND is_deleted = false` before writing — substituting existence/active-state validation for device-link-conflicts' candidate-membership check, preventing an invalid/dangling autotask_company_id reference |
| T-14-04 | Tampering | resolve `[id]` path param + JSON body | mitigate | UUID-shape check on `id`; zod schema on body (companyId positive int, note ≤500 chars); all values bound as `$N` parameters |
| T-14-03 | Information Disclosure | GET /api/pax8/company-matches | mitigate | `requireAuth()` gate; queue not exposed to unauthenticated callers |
| T-14-SC | Tampering | npm/pip/cargo installs | accept | Zero new packages (RESEARCH.md audit N/A); `zod` already installed and in active use |
</threat_model>

<verification>
- `npx vitest run lib/services/pax8-company-match-resolver.test.ts` green (all five behavior cases)
- `npx tsc --noEmit --pretty` green with all three files present
- Manual (Plan 06): resolve a real flagged company as admin; confirm both tables updated and a re-sync leaves it untouched; confirm a non-admin gets 403
</verification>

<success_criteria>
- Review list route gated by requireAuth, resolve route gated by requirePermission('admin','access')
- Resolver writes both pax8_companies (match_method='manual') and the review row, atomically, with company-existence validation and no candidate-membership restriction
- Resolver unit test passes
- Type check green
</success_criteria>

<output>
Create `.planning/phases/14-pax8-ui-surface/14-02-SUMMARY.md` when done
</output>
