---
phase: 12-orders-invoices-company-matching
plan: 03
type: execute
wave: 2
depends_on: ["12-01"]
files_modified:
  - lib/services/pax8-company-matcher.ts
  - lib/services/pax8-company-matcher.test.ts
autonomous: true
requirements: [PAX8-10, PAX8-11]

must_haves:
  truths:
    - "A PAX8 company with a single candidate scoring >= 0.90 and no near-tie is auto-linked (columns written on pax8_companies) (D-01)"
    - "A PAX8 company with the best score < 0.90, or a second candidate within 0.05 of the top, is flagged with the top-3 candidates (never auto-linked) (D-02 / D-04)"
    - "A PAX8 company with zero candidates above the 0.3 floor is flagged with an empty candidate_company_ids array (D-03)"
    - "A human-resolved match is never overwritten by re-running the matcher (D-05 / SC#4)"
    - "PAX8 company names are passed as bound parameters into similarity(), never string-interpolated"
  artifacts:
    - path: "lib/services/pax8-company-matcher.ts"
      provides: "matchPax8Companies() + candidate/decision/apply/conflict helpers"
      exports: ["matchPax8Companies"]
      min_lines: 120
    - path: "lib/services/pax8-company-matcher.test.ts"
      provides: "auto-link / review / empty-candidate / idempotency unit tests"
      contains: "matchPax8Companies"
  key_links:
    - from: "lib/services/pax8-company-matcher.ts"
      to: "companies"
      via: "similarity($1, company_name) with is_active filter"
      pattern: "similarity\\(\\$1, company_name\\)"
    - from: "lib/services/pax8-company-matcher.ts"
      to: "pax8_companies"
      via: "UPDATE auto-match columns guarded against resolved rows"
      pattern: "UPDATE pax8_companies"
    - from: "lib/services/pax8-company-matcher.ts"
      to: "pax8_company_match_review"
      via: "upsert on the open-review partial unique index"
      pattern: "INSERT INTO pax8_company_match_review"
---

<objective>
Build the fuzzy company matcher (PAX8-10, PAX8-11) as a close structural port of
lib/services/device-link-reconciler.ts, scored with Postgres `pg_trgm`
similarity() at a conservative, tunable 0.90 auto-link floor with a 0.05
tie-margin — the thresholds research validated live against this project's real
118 pax8_companies vs 242 active companies (every genuine match scored 1.00; the
highest non-match was 0.70).

Match policy (locked decisions):
- D-01: auto-link only at similarity >= 0.90 (AUTO_LINK_THRESHOLD, initial &
  tunable) with a single candidate and no near-tie.
- D-02: never silently tie-break — a second candidate within 0.05 (TIE_MARGIN)
  of the top score forces review even at score 1.0.
- D-03: zero candidates above a 0.3 floor still creates a review row with an
  empty candidate_company_ids array — never silently dropped.
- D-04: ambiguous review rows carry the top 3 candidates.
- D-05 / SC#4: only unresolved rows are (re)scored every sync; a human-resolved
  match (resolved review row, or match_method='manual') is never overwritten.

Purpose: The hardest problem of the phase, isolated in its own service + test
plan (matching logic + SQL is context-heavy). Runs from Pax8SyncService in Plan 04.
Output: lib/services/pax8-company-matcher.ts and its unit tests.
</objective>

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

<context>
@.planning/phases/12-orders-invoices-company-matching/12-CONTEXT.md
@.planning/phases/12-orders-invoices-company-matching/12-RESEARCH.md
@.planning/phases/12-orders-invoices-company-matching/12-PATTERNS.md

<interfaces>
Precedent to port (lib/services/device-link-reconciler.ts):
- findBySerial/findByMac/findByHostnameInCompany — parameterized candidate
  queries filtered on is_deleted=false (adapt to similarity() + is_active=true).
- pickBestCandidate — ambiguity guard (adapt to numeric TIE_MARGIN).
- applyLink — UPDATE ... WHERE ... AND configuration_item_id IS NULL idempotency
  guard (adapt to a resolved-row guard).
- recordConflict — INSERT ... ON CONFLICT (subject) WHERE resolved_at IS NULL
  DO UPDATE (device_link_review). pax8_company_match_review has the identical
  partial-unique-index shape.
- reconcileUnlinkedDevices(opts?: { limit?; dryRun? }) — top-level scan loop +
  ReconcileResult rollup.

Schema (after Plan 01's migration 093):
- pax8_companies: id UUID, name TEXT, is_deleted BOOLEAN, autotask_company_id
  BIGINT, match_confidence NUMERIC(4,3), match_method TEXT, matched_at TIMESTAMPTZ.
- companies (Autotask): id BIGINT, company_name VARCHAR(255), is_active BOOLEAN.
- pax8_company_match_review (migration 091): id UUID, pax8_company_id UUID NOT
  NULL, candidate_company_ids BIGINT[] NOT NULL, match_confidences TEXT[] NOT
  NULL, detected_at, resolved_at TIMESTAMPTZ, resolved_by_user_id TEXT,
  resolved_to_company_id BIGINT, resolution_note TEXT. Partial unique index
  uq_pax8_company_match_review_open on (pax8_company_id) WHERE resolved_at IS NULL.

postgresClient: default export from '@/lib/services/postgres-client';
postgresClient.query<T>(sql, params) — parameterized only, never interpolate.
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create lib/services/pax8-company-matcher.ts</name>
  <files>lib/services/pax8-company-matcher.ts</files>
  <read_first>
    - lib/services/device-link-reconciler.ts (full file — the structural precedent: findBy*, applyLink, recordConflict, pickBestCandidate, reconcileUnlinkedDevices)
    - migrations/091_pax8_tables.sql (pax8_company_match_review shape + partial unique index)
    - .planning/phases/12-orders-invoices-company-matching/12-PATTERNS.md (pax8-company-matcher.ts section — the exact adapted findCandidates/decide/applyLink/recordConflict snippets AND the "IMPORTANT" note on the resolved_at re-scoring gate)
    - .planning/phases/12-orders-invoices-company-matching/12-RESEARCH.md (Pattern 2; Pitfall 4 case-insensitivity; Pitfall 5 is_active filter; Security Domain SQL-injection mitigation)
    - lib/types/pax8.ts (Pax8EntitySyncResult shape the matcher result maps into)
  </read_first>
  <action>
    Create lib/services/pax8-company-matcher.ts. File header comment: states this
    is the PAX8 company↔Autotask fuzzy matcher, ported from
    device-link-reconciler.ts, and that unlike the reconciler it runs from
    Pax8SyncService.fullSync() (Plan 04) — NOT a standalone cron (no scheduler
    wiring this phase). Import postgresClient default from
    '@/lib/services/postgres-client'.

    Module constants (export them so they are documented/tunable and testable):
    AUTO_LINK_THRESHOLD = 0.90 (D-01, conservative initial/tunable value),
    TIE_MARGIN = 0.05 (D-02), CANDIDATE_FLOOR = 0.3 (keeps the candidate list
    small). Add a comment citing the live evidence: genuine matches clustered at
    1.00, highest observed non-match 0.70, so 0.90 sits in the empty gap.

    Types: CompanyCandidate { autotask_company_id: number; score: number }. Export
    interface Pax8CompanyMatchResult { scanned: number; autoLinked: number;
    flaggedAmbiguous: number; flaggedNoCandidate: number; durationMs: number }.

    findCandidates(pax8Name: string): parameterized query — SELECT id::text,
    similarity($1, company_name)::text AS score FROM companies WHERE is_active =
    true AND similarity($1, company_name) > CANDIDATE_FLOOR ORDER BY score DESC
    LIMIT 5. Bind pax8Name as $1 (NEVER interpolate — Security Domain). Return
    rows mapped to CompanyCandidate (Number(id), Number(score)). Rely on
    pg_trgm's built-in case-insensitivity (Pitfall 4) — do not add LOWER(); TRIM
    the input name for cleaner review display only.

    decide(candidates): returns { kind: 'auto'; match } | { kind: 'review'; top3 }.
    Zero candidates → review with empty top3 (D-03). Else let [best, second] =
    candidates; tie = second exists AND best.score - second.score < TIE_MARGIN.
    If best.score >= AUTO_LINK_THRESHOLD AND NOT tie → auto. Otherwise review with
    candidates.slice(0, 3) (D-02/D-04).

    applyLink(pax8CompanyId, autotaskCompanyId, score): UPDATE pax8_companies SET
    autotask_company_id=$2, match_confidence=$3, match_method='pg_trgm',
    matched_at=NOW() WHERE id=$1 AND match_method IS DISTINCT FROM 'manual' AND
    NOT EXISTS (SELECT 1 FROM pax8_company_match_review r WHERE
    r.pax8_company_id = pax8_companies.id AND r.resolved_at IS NOT NULL). This is
    the SC#4 / D-05 idempotency guard: never overwrite a manually-resolved match.
    Then close any open (never-human-touched) review row for that company: DELETE
    FROM pax8_company_match_review WHERE pax8_company_id=$1 AND resolved_at IS NULL
    (a now-confident match supersedes an open flag; human-resolved rows have
    resolved_at set and are untouched). Pass score as a NUMERIC bound param.

    recordConflict(pax8CompanyId, top3): first, if this company previously held a
    pg_trgm auto-match that is now ambiguous/below-threshold, clear the stale
    columns conservatively — UPDATE pax8_companies SET autotask_company_id=NULL,
    match_confidence=NULL, match_method=NULL, matched_at=NULL WHERE id=$1 AND
    match_method='pg_trgm' (never clears 'manual'). Then upsert the review row:
    INSERT INTO pax8_company_match_review (pax8_company_id, candidate_company_ids,
    match_confidences) VALUES ($1, $2::bigint[], $3::text[]) ON CONFLICT
    (pax8_company_id) WHERE resolved_at IS NULL DO UPDATE SET candidate_company_ids
    = EXCLUDED.candidate_company_ids, match_confidences = EXCLUDED.match_confidences,
    detected_at = NOW(). candidate_company_ids = top3 ids (empty [] when no
    candidates, D-03); match_confidences = top3 scores formatted with toFixed(3)
    as TEXT[] (consistent precision with match_confidence NUMERIC(4,3)).

    matchPax8Companies(opts?: { limit?: number; dryRun?: boolean }):
    Promise<Pax8CompanyMatchResult>. Select the re-scoring-eligible set (D-05):
    SELECT id::text, name FROM pax8_companies WHERE is_deleted = false AND
    match_method IS DISTINCT FROM 'manual' AND NOT EXISTS (SELECT 1 FROM
    pax8_company_match_review r WHERE r.pax8_company_id = pax8_companies.id AND
    r.resolved_at IS NOT NULL) ORDER BY name LIMIT $1 (default limit 1000). Loop:
    scanned++; candidates = findCandidates(name.trim()); d = decide(candidates);
    if kind auto → (unless dryRun) applyLink + autoLinked++; if kind review with
    empty top3 → (unless dryRun) recordConflict(id, []) + flaggedNoCandidate++;
    else review → (unless dryRun) recordConflict(id, top3) + flaggedAmbiguous++.
    Wrap in try/catch per the CLAUDE.md convention; return the result with
    durationMs. Console.log a one-line summary at the end.

    Use error handling shape: catch (err) { const msg = err instanceof Error ?
    err.message : String(err); console.error('[Pax8Match] failed:', msg); ... }.
  </action>
  <acceptance_criteria>
    - Exports `matchPax8Companies`, `AUTO_LINK_THRESHOLD` (= 0.90), `TIE_MARGIN` (= 0.05), and interface `Pax8CompanyMatchResult`
    - The candidate query passes the PAX8 name as a bound `$1` param and filters `is_active = true`; `grep -c "company_name +" lib/services/pax8-company-matcher.ts` returns 0 (no string concatenation into SQL)
    - The applyLink UPDATE contains both `match_method IS DISTINCT FROM 'manual'` and a `resolved_at IS NOT NULL` NOT EXISTS guard
    - The recordConflict upsert targets `pax8_company_match_review` with `ON CONFLICT (pax8_company_id) WHERE resolved_at IS NULL`
    - The eligibility SELECT excludes rows where a review row has `resolved_at IS NOT NULL` and where `match_method` = 'manual'
    - `npx tsc --noEmit --pretty` passes
  </acceptance_criteria>
  <verify>
    <automated>npx tsc --noEmit --pretty 2>&1 | tail -5; grep -c "similarity(\$1, company_name)" lib/services/pax8-company-matcher.ts</automated>
  </verify>
  <done>Matcher implements the D-01..D-05 policy with parameterized queries, the resolved-row idempotency guard, and the top-3/empty-array review semantics; type-check passes.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Create lib/services/pax8-company-matcher.test.ts</name>
  <files>lib/services/pax8-company-matcher.test.ts</files>
  <behavior>
    - auto-link: eligible company, one candidate scoring 0.95 → applyLink UPDATE against pax8_companies is issued; no INSERT into pax8_company_match_review
    - review (below threshold): best candidate 0.80 → an INSERT/UPSERT into pax8_company_match_review with that candidate; no auto-link UPDATE writing autotask_company_id
    - review (near-tie): candidates 0.95 and 0.92 (margin < 0.05) → review row with top candidates, no auto-link even though top >= 0.90 (D-02)
    - empty candidates: zero rows above the floor → review row with an empty candidate_company_ids array (D-03)
    - idempotent (D-05/SC#4): the applyLink UPDATE SQL includes the `resolved_at IS NOT NULL` guard AND `match_method IS DISTINCT FROM 'manual'`; the eligibility SELECT excludes manually-resolved rows
    - dryRun: no write queries (no UPDATE/INSERT/DELETE) are issued when dryRun is true
  </behavior>
  <read_first>
    - lib/services/pax8-client.test.ts (vi.fn / vi.stubGlobal mocking discipline to mirror, applied to postgresClient.query instead of fetch)
    - lib/services/pax8-company-matcher.ts (Task 1 — the query shapes to assert against)
    - .planning/phases/12-orders-invoices-company-matching/12-RESEARCH.md (Validation Architecture > Phase Requirements → Test Map; Wave 0 Gaps recommends mocking postgresClient.query)
  </read_first>
  <action>
    Create the matcher test with vitest. Mock the postgres client module:
    vi.mock('@/lib/services/postgres-client', () => ({ default: { query: vi.fn() } })).
    Import the mocked query and, per test, program its return sequence so the
    first call (eligibility SELECT) yields the pax8_companies rows under test and
    subsequent findCandidates calls yield the candidate score rows. Assert
    behavior by inspecting the SQL string + params of the mock's calls: for
    auto-link assert a call whose SQL matches /UPDATE pax8_companies/ ran; for
    review assert a call whose SQL matches /INSERT INTO pax8_company_match_review/
    ran (and, for the empty case, that its bound candidate id array is empty). For
    the idempotency test, assert the applyLink SQL string contains both
    'resolved_at IS NOT NULL' and "IS DISTINCT FROM 'manual'". For dryRun, assert
    no call SQL matches /UPDATE|INSERT|DELETE/ after the read-only eligibility and
    candidate SELECTs.

    Follow the RESEARCH test-name map so the -t filters resolve: name the cases so
    'auto-link', 'review', 'empty candidates', and 'idempotent' each appear.
    similarity() runs in Postgres, so these tests never touch a real DB — the mock
    returns canned score rows (keeps the suite fast and consistent with the rest of
    lib/services/*.test.ts, all pure-mock, environment: 'node').
  </action>
  <acceptance_criteria>
    - Test titles include the substrings 'auto-link', 'review', 'empty candidates', and 'idempotent'
    - The auto-link test asserts a mock query call whose SQL matches `/UPDATE pax8_companies/`
    - The empty-candidates test asserts the review upsert's bound `candidate_company_ids` param is an empty array
    - The idempotent test asserts the applyLink SQL contains `resolved_at IS NOT NULL` and `IS DISTINCT FROM 'manual'`
    - `npx vitest run lib/services/pax8-company-matcher.test.ts` passes
  </acceptance_criteria>
  <verify>
    <automated>npx vitest run lib/services/pax8-company-matcher.test.ts</automated>
  </verify>
  <done>All five decision branches (auto, below-threshold review, near-tie review, empty-candidate review, idempotency guard) plus dryRun are proven with mocked postgresClient.query.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| PAX8 company name → SQL | External string fed into a similarity() comparison — must be a bound parameter |
| matcher writes → billing-sensitive match state | A wrong auto-link misattributes cost data between companies |
| re-sync → human-resolved rows | Automated re-scoring must never overwrite a manual decision |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-12-01 | Tampering | findCandidates similarity() query | mitigate | PAX8 name bound as `$1`, never string-interpolated (parameterized query per postgresClient convention); asserted by the no-concatenation acceptance check |
| T-12-02 | Tampering / Repudiation | decide() / applyLink() | mitigate | Conservative 0.90 threshold + 0.05 tie-margin + top-3 review; unit tests cover below-threshold, near-tie, and empty-candidate branches so wrong-company cost attribution is caught in CI |
| T-12-04 | Tampering | applyLink() re-scoring | mitigate | UPDATE guarded with `match_method IS DISTINCT FROM 'manual'` + `resolved_at IS NOT NULL` NOT EXISTS; eligibility SELECT excludes human-resolved rows; idempotency unit test asserts the guard |
| T-12-06 | Denial of Service | matchPax8Companies loop | accept | Bounded by LIMIT (default 1000) and CANDIDATE_FLOOR keeping candidate lists to ≤5; dataset is ~120 pax8 companies — no scaling concern |
| T-12-SC | Tampering | package installs | accept | No new packages; pg_trgm is a Postgres contrib extension enabled in Plan 01 |
</threat_model>

<verification>
- `npx vitest run lib/services/pax8-company-matcher.test.ts` green.
- `npx tsc --noEmit --pretty` passes.
- No string concatenation of company names into SQL (grep gate).
</verification>

<success_criteria>
The matcher auto-links only on conservative, unambiguous, high-confidence matches; flags everything else (including zero-candidate and near-tie cases); never overwrites a human decision; all parameterized. Unit tests prove each branch.
</success_criteria>

<output>
Create `.planning/phases/12-orders-invoices-company-matching/12-03-SUMMARY.md` when done.
</output>
