[object Object]

← back to Rentv 2026

pr-intelligence: CA seed (178 curated + FDIC registry), unit+integration tests (28 passing), merge NULL-array fix, FDIC API host update

d0589dad1428c9d66740089defb9171c50f319d9 · 2026-07-30 11:35:45 -0700 · Steve Abrams

Files touched

Diff

commit d0589dad1428c9d66740089defb9171c50f319d9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 30 11:35:45 2026 -0700

    pr-intelligence: CA seed (178 curated + FDIC registry), unit+integration tests (28 passing), merge NULL-array fix, FDIC API host update
---
 package.json                     |   2 +-
 src/pr/adapters/registry.js      |   3 +-
 src/pr/seed/ca-seed.js           | 260 +++++++++++++++++++++++++++++++++++++++
 src/pr/services/organizations.js |  11 +-
 test/pr/integration.test.js      | 233 +++++++++++++++++++++++++++++++++++
 test/pr/unit.test.js             | 152 +++++++++++++++++++++++
 6 files changed, 654 insertions(+), 7 deletions(-)

diff --git a/package.json b/package.json
index 977adb6d..ee9273bd 100644
--- a/package.json
+++ b/package.json
@@ -1 +1 @@
-{"name":"rentv","version":"0.10.0","private":true,"main":"server.js","scripts":{"start":"node server.js","pull":"node scripts/pull-news.mjs","pr:migrate":"node -e \"require('./src/pr/db').runMigrations({log:console.log}).then(r=>console.log(JSON.stringify(r))).catch(e=>{console.error(e.message);process.exit(1)})\"","pr:worker":"node src/pr/worker.js","pr:seed:ca":"node src/pr/seed/ca-seed.js","test":"node --test test/pr/"},"dependencies":{"express":"^4.21.2","pg":"^8.22.0"}}
\ No newline at end of file
+{"name":"rentv","version":"0.10.0","private":true,"main":"server.js","scripts":{"start":"node server.js","pull":"node scripts/pull-news.mjs","pr:migrate":"node -e \"require('./src/pr/db').runMigrations({log:console.log}).then(r=>console.log(JSON.stringify(r))).catch(e=>{console.error(e.message);process.exit(1)})\"","pr:worker":"node src/pr/worker.js","pr:seed:ca":"node src/pr/seed/ca-seed.js","test":"node --test test/pr/*.test.js"},"dependencies":{"express":"^4.21.2","pg":"^8.22.0"}}
\ No newline at end of file
diff --git a/src/pr/adapters/registry.js b/src/pr/adapters/registry.js
index 1b4784b2..b193bdea 100644
--- a/src/pr/adapters/registry.js
+++ b/src/pr/adapters/registry.js
@@ -22,7 +22,8 @@ const adapter = register({
    */
   async fdicBanks(state, { cursor } = {}) {
     const offset = cursor && cursor.offset ? cursor.offset : 0;
-    const url = `https://banks.data.fdic.gov/api/institutions?filters=${encodeURIComponent(`STALP:${state} AND ACTIVE:1`)}&fields=NAME,CITY,COUNTY,STALP,WEBADDR,CERT,BKCLASS&limit=100&offset=${offset}&format=json`;
+    // NOTE 2026-07-30: banks.data.fdic.gov now 301s to api.fdic.gov/banks — use the new host.
+    const url = `https://api.fdic.gov/banks/institutions?filters=${encodeURIComponent(`STALP:${state} AND ACTIVE:1`)}&fields=NAME,CITY,COUNTY,STALP,WEBADDR,CERT,BKCLASS&limit=100&offset=${offset}&format=json`;
     const errors = []; let items = []; let total = 0;
     try {
       const j = await throttled('fdic', async () => {
diff --git a/src/pr/seed/ca-seed.js b/src/pr/seed/ca-seed.js
new file mode 100644
index 00000000..9e9d1626
--- /dev/null
+++ b/src/pr/seed/ca-seed.js
@@ -0,0 +1,260 @@
+'use strict';
+// California Phase-1 seed — HONEST BY CONSTRUCTION.
+//
+// Two ingestion paths, both through the real pipeline:
+//   1. discover-organizations job → FDIC registry adapter loads every ACTIVE
+//      California bank (official government data, primary source, live API).
+//   2. The curated candidate list below: real, well-known California CRE
+//      organizations (brokerages, owners/REITs, title/escrow, lenders, law, AEC,
+//      EDCs, associations, proptech, PR/marketing agencies, publications).
+//      Each enters as lifecycle_status='discovered' with a low-confidence seed
+//      source, then a verify-organization job fetches the LIVE website to
+//      confirm it, find press/newsroom/contact pages, and extract public team
+//      names+titles. Anything whose site can't be verified STAYS unverified.
+//
+// Run: npm run pr:seed:ca      (idempotent — exact matches dedupe, jobs dedupe)
+const db = require('../db');
+const organizations = require('../services/organizations');
+const jobs = require('../jobs');
+
+// [name, domain, type, metro, county]  — metro keys from lib/geo.js
+const C = [
+  // ── Brokerage & advisory ──
+  ['CBRE', 'cbre.com', 'brokerage', 'la', 'Los Angeles'],
+  ['JLL', 'jll.com', 'brokerage', 'la', 'Los Angeles'],
+  ['Cushman & Wakefield', 'cushmanwakefield.com', 'brokerage', 'la', 'Los Angeles'],
+  ['Colliers', 'colliers.com', 'brokerage', 'la', 'Los Angeles'],
+  ['Newmark', 'nmrk.com', 'brokerage', 'la', 'Los Angeles'],
+  ['Marcus & Millichap', 'marcusmillichap.com', 'brokerage', 'la', 'Los Angeles'],
+  ['Kidder Mathews', 'kidder.com', 'brokerage', 'la', 'Los Angeles'],
+  ['Lee & Associates', 'lee-associates.com', 'brokerage', 'la', 'Los Angeles'],
+  ['NAI Capital Commercial', 'naicapital.com', 'brokerage', 'la', 'Los Angeles'],
+  ['DAUM Commercial Real Estate Services', 'daumcommercial.com', 'brokerage', 'la', 'Los Angeles'],
+  ['Voit Real Estate Services', 'voitco.com', 'brokerage', 'oc', 'Orange'],
+  ['Avison Young', 'avisonyoung.com', 'brokerage', 'la', 'Los Angeles'],
+  ['Savills North America', 'savills.us', 'brokerage', 'la', 'Los Angeles'],
+  ['Matthews Real Estate Investment Services', 'matthews.com', 'brokerage', 'la', 'Los Angeles'],
+  ['Hanley Investment Group', 'hanleyinvestment.com', 'brokerage', 'oc', 'Orange'],
+  ['Faris Lee Investments', 'farislee.com', 'brokerage', 'oc', 'Orange'],
+  ['Progressive Real Estate Partners', 'progressiverep.com', 'brokerage', 'inland-empire', 'San Bernardino'],
+  ['SVN International', 'svn.com', 'brokerage', 'la', 'Los Angeles'],
+  ['TRI Commercial Real Estate Services', 'tricommercial.com', 'brokerage', 'bay-area', 'San Francisco'],
+  ['Hughes Marino', 'hughesmarino.com', 'brokerage', 'san-diego', 'San Diego'],
+  ['Pearson Realty', 'pearsonrealty.com', 'brokerage', 'central-valley', 'Fresno'],
+  // ── Owners / operators / investors / developers / sponsors ──
+  ['Irvine Company', 'irvinecompany.com', 'developer_owner', 'oc', 'Orange'],
+  ['Related California', 'relatedcalifornia.com', 'developer_owner', 'la', 'Los Angeles'],
+  ['Lowe', 'lowe-re.com', 'developer_owner', 'la', 'Los Angeles'],
+  ['Lincoln Property Company', 'lpc.com', 'developer_owner', 'la', 'Los Angeles'],
+  ['Hines', 'hines.com', 'developer_owner', 'la', 'Los Angeles'],
+  ['Tishman Speyer', 'tishmanspeyer.com', 'developer_owner', 'bay-area', 'San Francisco'],
+  ['Brookfield Properties', 'brookfieldproperties.com', 'developer_owner', 'la', 'Los Angeles'],
+  ['Majestic Realty Co.', 'majesticrealty.com', 'developer_owner', 'la', 'Los Angeles'],
+  ['Watson Land Company', 'watsonlandcompany.com', 'developer_owner', 'la', 'Los Angeles'],
+  ['Sares Regis Group', 'saresregis.com', 'developer_owner', 'oc', 'Orange'],
+  ['Shea Properties', 'sheaproperties.com', 'developer_owner', 'oc', 'Orange'],
+  ['The Sobrato Organization', 'sobrato.com', 'developer_owner', 'bay-area', 'Santa Clara'],
+  ['Jay Paul Company', 'jaypaul.com', 'developer_owner', 'bay-area', 'San Francisco'],
+  ['LBA Realty', 'lbarealty.com', 'developer_owner', 'oc', 'Orange'],
+  ['CIM Group', 'cimgroup.com', 'developer_owner', 'la', 'Los Angeles'],
+  ['Kearny Real Estate Company', 'kearny.com', 'developer_owner', 'la', 'Los Angeles'],
+  ['Panattoni Development Company', 'panattoni.com', 'developer_owner', 'oc', 'Orange'],
+  ['Overton Moore Properties', 'omproperties.com', 'developer_owner', 'la', 'Los Angeles'],
+  ['Kennedy Wilson', 'kennedywilson.com', 'developer_owner', 'la', 'Los Angeles'],
+  ['Buzz Oates', 'buzzoates.com', 'developer_owner', 'sacramento', 'Sacramento'],
+  ['Investec Real Estate Companies', 'investecre.com', 'developer_owner', 'central-coast', 'Santa Barbara'],
+  ['Ares Management Real Estate', 'aresmgmt.com', 'developer_owner', 'la', 'Los Angeles'],
+  ['Oaktree Capital Management', 'oaktreecapital.com', 'developer_owner', 'la', 'Los Angeles'],
+  ['Canyon Partners Real Estate', 'canyonpartners.com', 'developer_owner', 'la', 'Los Angeles'],
+  // ── REITs ──
+  ['Kilroy Realty Corporation', 'kilroyrealty.com', 'reit', 'la', 'Los Angeles'],
+  ['Douglas Emmett', 'douglasemmett.com', 'reit', 'la', 'Los Angeles'],
+  ['Hudson Pacific Properties', 'hudsonpacificproperties.com', 'reit', 'la', 'Los Angeles'],
+  ['Rexford Industrial Realty', 'rexfordindustrial.com', 'reit', 'la', 'Los Angeles'],
+  ['Prologis', 'prologis.com', 'reit', 'bay-area', 'San Francisco'],
+  ['Essex Property Trust', 'essexpropertytrust.com', 'reit', 'bay-area', 'San Mateo'],
+  ['Alexandria Real Estate Equities', 'are.com', 'reit', 'la', 'Los Angeles'],
+  ['Terreno Realty Corporation', 'terreno.com', 'reit', 'bay-area', 'San Francisco'],
+  ['BioMed Realty', 'biomedrealty.com', 'reit', 'san-diego', 'San Diego'],
+  ['Realty Income Corporation', 'realtyincome.com', 'reit', 'san-diego', 'San Diego'],
+  ['American Assets Trust', 'americanassetstrust.com', 'reit', 'san-diego', 'San Diego'],
+  ['BXP', 'bxp.com', 'reit', 'bay-area', 'San Francisco'],
+  // ── Asset / property / facility management ──
+  ['RiverRock Real Estate Group', 'riverrockreg.com', 'asset_property_mgmt', 'oc', 'Orange'],
+  ['Transwestern', 'transwestern.com', 'asset_property_mgmt', 'la', 'Los Angeles'],
+  ['Greystar', 'greystar.com', 'asset_property_mgmt', 'la', 'Los Angeles'],
+  ['Avanath Capital Management', 'avanath.com', 'asset_property_mgmt', 'oc', 'Orange'],
+  ['ABM Industries', 'abm.com', 'facility_mgmt', 'bay-area', 'San Francisco'],
+  // ── Debt & equity / mortgage banking / lenders ──
+  ['Gantry', 'gantryinc.com', 'debt_equity_broker', 'bay-area', 'San Francisco'],
+  ['Northmarq', 'northmarq.com', 'debt_equity_broker', 'la', 'Los Angeles'],
+  ['Berkadia', 'berkadia.com', 'commercial_mortgage_bank', 'la', 'Los Angeles'],
+  ['Walker & Dunlop', 'walkerdunlop.com', 'commercial_mortgage_bank', 'la', 'Los Angeles'],
+  ['PSRS', 'psrs.com', 'commercial_mortgage_bank', 'la', 'Los Angeles'],
+  ['Mesa West Capital', 'mesawestcapital.com', 'private_lender', 'la', 'Los Angeles'],
+  ['ACORE Capital', 'acorecapital.com', 'private_lender', 'bay-area', 'Marin'],
+  ['Thorofare Capital', 'thorofarecapital.com', 'private_lender', 'la', 'Los Angeles'],
+  ['PCCP', 'pccpllc.com', 'private_lender', 'la', 'Los Angeles'],
+  ['Parkview Financial', 'parkviewfinancial.com', 'construction_lender', 'la', 'Los Angeles'],
+  ['Anchor Loans', 'anchorloans.com', 'bridge_lender', 'central-coast', 'Ventura'],
+  ['Bolour Associates', 'bolour.com', 'bridge_lender', 'la', 'Los Angeles'],
+  ['Pacific Life', 'pacificlife.com', 'life_co_lender', 'oc', 'Orange'],
+  // ── Credit unions (CRE-active) ──
+  ['Golden 1 Credit Union', 'golden1.com', 'credit_union', 'sacramento', 'Sacramento'],
+  ['SchoolsFirst Federal Credit Union', 'schoolsfirstfcu.org', 'credit_union', 'oc', 'Orange'],
+  ['Redwood Credit Union', 'redwoodcu.org', 'credit_union', 'bay-area', 'Marin'],
+  ['Patelco Credit Union', 'patelco.org', 'credit_union', 'bay-area', 'Contra Costa'],
+  ['San Diego County Credit Union', 'sdccu.com', 'credit_union', 'san-diego', 'San Diego'],
+  // ── Title / escrow / 1031 ──
+  ['First American Title Insurance Company', 'firstam.com', 'title_insurer', 'oc', 'Orange'],
+  ['Chicago Title', 'chicagotitle.com', 'title_insurer', 'la', 'Los Angeles'],
+  ['Old Republic Title', 'oldrepublictitle.com', 'title_insurer', 'bay-area', 'San Francisco'],
+  ['Stewart Title', 'stewart.com', 'title_insurer', 'la', 'Los Angeles'],
+  ['Lawyers Title', 'lawyerstitle.com', 'title_agency', 'la', 'Los Angeles'],
+  ['Ticor Title', 'ticortitle.com', 'title_agency', 'la', 'Los Angeles'],
+  ['WFG National Title Insurance Company', 'wfgtitle.com', 'title_insurer', 'la', 'Los Angeles'],
+  ['Glen Oaks Escrow', 'glenoaksescrow.com', 'escrow_company', 'la', 'Los Angeles'],
+  ['Granite Escrow & Settlement Services', 'graniteescrow.com', 'escrow_company', 'oc', 'Orange'],
+  ['Escrow of the West', 'escrowofthewest.com', 'escrow_company', 'la', 'Los Angeles'],
+  ['California Escrow Association', 'caescrow.org', 'association', 'sacramento', 'Sacramento'],
+  ['California Land Title Association', 'clta.org', 'association', 'sacramento', 'Sacramento'],
+  ['IPX1031', 'ipx1031.com', 'exchange_1031', 'bay-area', 'San Francisco'],
+  ['First American Exchange Company', 'firstexchange.com', 'exchange_1031', 'oc', 'Orange'],
+  ['Asset Preservation, Inc.', 'apiexchange.com', 'exchange_1031', 'sacramento', 'Placer'],
+  ['Exeter 1031 Exchange Services', 'exeterco.com', 'exchange_1031', 'san-diego', 'San Diego'],
+  // ── Law / accounting / valuation / consulting / appraisal / insurance ──
+  ['Allen Matkins', 'allenmatkins.com', 'law_firm', 'la', 'Los Angeles'],
+  ['Cox, Castle & Nicholson', 'coxcastle.com', 'law_firm', 'la', 'Los Angeles'],
+  ['Miller Starr Regalia', 'msrlegal.com', 'law_firm', 'bay-area', 'Contra Costa'],
+  ['Rutan & Tucker', 'rutan.com', 'law_firm', 'oc', 'Orange'],
+  ['Buchalter', 'buchalter.com', 'law_firm', 'la', 'Los Angeles'],
+  ['Sheppard Mullin', 'sheppardmullin.com', 'law_firm', 'la', 'Los Angeles'],
+  ['Armanino', 'armanino.com', 'accounting_consulting', 'bay-area', 'Contra Costa'],
+  ['GHJ', 'ghjadvisors.com', 'accounting_consulting', 'la', 'Los Angeles'],
+  ['HCVT', 'hcvt.com', 'accounting_consulting', 'la', 'Los Angeles'],
+  ['Moss Adams', 'mossadams.com', 'accounting_consulting', 'la', 'Los Angeles'],
+  ['Integra Realty Resources', 'irr.com', 'appraiser', 'la', 'Los Angeles'],
+  ['Alliant Insurance Services', 'alliant.com', 'insurance_risk', 'oc', 'Orange'],
+  ['Woodruff Sawyer', 'woodruffsawyer.com', 'insurance_risk', 'bay-area', 'San Francisco'],
+  ['Heffernan Insurance Brokers', 'heffins.com', 'insurance_risk', 'bay-area', 'Contra Costa'],
+  ['Lockton', 'lockton.com', 'insurance_risk', 'la', 'Los Angeles'],
+  // ── Architecture / engineering / construction ──
+  ['Gensler', 'gensler.com', 'architecture_firm', 'bay-area', 'San Francisco'],
+  ['Ware Malcomb', 'waremalcomb.com', 'architecture_firm', 'oc', 'Orange'],
+  ['LPA Design Studios', 'lpadesignstudios.com', 'architecture_firm', 'oc', 'Orange'],
+  ['AC Martin', 'acmartin.com', 'architecture_firm', 'la', 'Los Angeles'],
+  ['CO Architects', 'coarchitects.com', 'architecture_firm', 'la', 'Los Angeles'],
+  ['Carrier Johnson + CULTURE', 'carrierjohnson.com', 'architecture_firm', 'san-diego', 'San Diego'],
+  ['HPA Architecture', 'hparchs.com', 'architecture_firm', 'oc', 'Orange'],
+  ['KPFF Consulting Engineers', 'kpff.com', 'engineering_firm', 'la', 'Los Angeles'],
+  ['Englekirk', 'englekirk.com', 'engineering_firm', 'la', 'Los Angeles'],
+  ['Fuscoe Engineering', 'fuscoe.com', 'engineering_firm', 'oc', 'Orange'],
+  ['Psomas', 'psomas.com', 'engineering_firm', 'la', 'Los Angeles'],
+  ['Kimley-Horn', 'kimley-horn.com', 'engineering_firm', 'oc', 'Orange'],
+  ['Swinerton', 'swinerton.com', 'general_contractor', 'bay-area', 'Contra Costa'],
+  ['Webcor', 'webcor.com', 'general_contractor', 'bay-area', 'San Francisco'],
+  ['Hathaway Dinwiddie Construction', 'hdcco.com', 'general_contractor', 'bay-area', 'San Francisco'],
+  ['DPR Construction', 'dpr.com', 'general_contractor', 'bay-area', 'San Mateo'],
+  ['Level 10 Construction', 'level10gc.com', 'general_contractor', 'bay-area', 'Santa Clara'],
+  ['Snyder Langston', 'snyderlangston.com', 'general_contractor', 'oc', 'Orange'],
+  ['C.W. Driver Companies', 'cwdriver.com', 'general_contractor', 'la', 'Los Angeles'],
+  ['MATT Construction', 'mattconstruction.com', 'general_contractor', 'la', 'Los Angeles'],
+  ['Oltmans Construction Co.', 'oltmans.com', 'general_contractor', 'la', 'Los Angeles'],
+  ['Cumming Group', 'cumming-group.com', 'construction_mgmt', 'la', 'Los Angeles'],
+  // ── Economic development / municipal ──
+  ['Los Angeles County Economic Development Corporation', 'laedc.org', 'economic_development', 'la', 'Los Angeles'],
+  ['San Diego Regional Economic Development Corporation', 'sandiegobusiness.org', 'economic_development', 'san-diego', 'San Diego'],
+  ['Greater Sacramento Economic Council', 'greatersacramento.com', 'economic_development', 'sacramento', 'Sacramento'],
+  ['Orange County Business Council', 'ocbc.org', 'economic_development', 'oc', 'Orange'],
+  ['Bay Area Council', 'bayareacouncil.org', 'economic_development', 'bay-area', 'San Francisco'],
+  ['Fresno County Economic Development Corporation', 'fresnoedc.com', 'economic_development', 'central-valley', 'Fresno'],
+  ['Kern Economic Development Corporation', 'kedc.com', 'economic_development', 'central-valley', 'Kern'],
+  ['San Joaquin Partnership', 'sjpnet.org', 'economic_development', 'central-valley', 'San Joaquin'],
+  ['Opportunity Stanislaus', 'opportunitystanislaus.com', 'economic_development', 'central-valley', 'Stanislaus'],
+  ['Inland Empire Economic Partnership', 'ieep.com', 'economic_development', 'inland-empire', 'San Bernardino'],
+  ['Los Angeles City Planning', 'planning.lacity.gov', 'municipal_re', 'la', 'Los Angeles'],
+  ['San Francisco Planning Department', 'sfplanning.org', 'municipal_re', 'bay-area', 'San Francisco'],
+  // ── Associations & chapters ──
+  ['AIR CRE', 'aircre.com', 'association', 'la', 'Los Angeles'],
+  ['NAIOP SoCal', 'naiopsocal.org', 'association', 'la', 'Los Angeles'],
+  ['ULI Los Angeles', 'la.uli.org', 'association', 'la', 'Los Angeles'],
+  ['ULI San Francisco', 'sf.uli.org', 'association', 'bay-area', 'San Francisco'],
+  ['CREW-LA', 'crewla.org', 'association', 'la', 'Los Angeles'],
+  ['BOMA Greater Los Angeles', 'bomagla.org', 'association', 'la', 'Los Angeles'],
+  ['BOMA San Francisco', 'bomasf.org', 'association', 'bay-area', 'San Francisco'],
+  ['California Business Properties Association', 'cbpa.com', 'association', 'sacramento', 'Sacramento'],
+  // ── Proptech / CRE data & software ──
+  ['Procore Technologies', 'procore.com', 'proptech', 'central-coast', 'Santa Barbara'],
+  ['Yardi', 'yardi.com', 'proptech', 'central-coast', 'Santa Barbara'],
+  ['AppFolio', 'appfolio.com', 'proptech', 'central-coast', 'Santa Barbara'],
+  ['Crexi', 'crexi.com', 'proptech', 'la', 'Los Angeles'],
+  ['Measurabl', 'measurabl.com', 'proptech', 'san-diego', 'San Diego'],
+  ['Juniper Square', 'junipersquare.com', 'proptech', 'bay-area', 'San Francisco'],
+  // ── PR / marketing / public affairs serving CRE ──
+  ['The Hoyt Organization', 'hoytorg.com', 'pr_agency', 'la', 'Los Angeles'],
+  ['Brower Group', 'browergroup.com', 'pr_agency', 'oc', 'Orange'],
+  ['Idea Hall', 'ideahall.com', 'pr_agency', 'oc', 'Orange'],
+  ['Murphy O’Brien', 'murphyobrien.com', 'pr_agency', 'la', 'Los Angeles'],
+  ['Antenna Group', 'antennagroup.com', 'pr_agency', 'bay-area', 'San Francisco'],
+  ['Davies Public Affairs', 'daviespublicaffairs.com', 'public_affairs', 'central-coast', 'Santa Barbara'],
+  ['Cerrell Associates', 'cerrell.com', 'public_affairs', 'la', 'Los Angeles'],
+  ['Marathon Communications', 'marathoncommunications.com', 'public_affairs', 'la', 'Los Angeles'],
+  ['Singer Associates', 'singersf.com', 'public_affairs', 'bay-area', 'San Francisco'],
+  ['Lighthouse Public Affairs', 'lighthousesf.com', 'public_affairs', 'bay-area', 'San Francisco'],
+  ['RSM Design', 'rsmdesign.com', 'marketing_agency', 'oc', 'Orange'],
+  ['Selbert Perkins Design', 'selbertperkins.com', 'marketing_agency', 'la', 'Los Angeles'],
+  // ── Trade publications / events ──
+  ['Connect CRE', 'connectcre.com', 'trade_publication', 'la', 'Los Angeles'],
+  ['The Registry', 'theregistrysf.com', 'trade_publication', 'bay-area', 'San Francisco'],
+  ['Los Angeles Business Journal', 'labusinessjournal.com', 'trade_publication', 'la', 'Los Angeles'],
+  ['San Diego Business Journal', 'sdbj.com', 'trade_publication', 'san-diego', 'San Diego'],
+  ['The Real Deal', 'therealdeal.com', 'trade_publication', 'la', 'Los Angeles'],
+  ['Bisnow', 'bisnow.com', 'trade_publication', 'la', 'Los Angeles'],
+  ['GlobeSt', 'globest.com', 'trade_publication', 'la', 'Los Angeles'],
+];
+
+async function main() {
+  const h = await db.health();
+  if (!h.ok) { console.error('DB unavailable: ' + h.reason); process.exit(1); }
+  await db.runMigrations({});
+  let created = 0, matched = 0, verifyJobs = 0;
+  for (const [name, domain, type, metro, county] of C) {
+    const r = await organizations.create({
+      display_name: name,
+      website_url: 'https://www.' + domain,
+      organization_type: type,
+      state_presence: ['CA'], metros: [metro], counties: [county],
+      lifecycle_status: 'discovered',
+    }, {
+      evidence: {
+        source: {
+          source_type: 'manual',
+          source_name: 'CA Phase-1 curated seed list',
+          url: 'https://www.' + domain,
+          short_excerpt: 'Curated candidate entry — real organization pending live-website verification by the pipeline. Not treated as verified until the verify-organization job confirms against the live site.',
+          is_primary_source: false,
+          license_or_usage_note: 'Candidate list only; all facts must be re-verified against primary sources before outreach.',
+          adapter: 'seed',
+        },
+        fields: { organization: 45 },
+      },
+      actor: 'system:ca-seed',
+    });
+    if (r.created) created++; else matched++;
+    // queue live verification for every seeded org (idempotent via dedupe_key)
+    const j = await jobs.enqueue('verify-organization', { organization_id: r.org.id }, { priority: 5, actor: 'system:ca-seed' });
+    if (!j.deduped) verifyJobs++;
+  }
+  // Official-registry discovery (FDIC CA banks) + duplicate sweep + scores + coverage report.
+  await jobs.enqueue('discover-organizations', { state: 'CA', category: 'bank' }, { priority: 3, actor: 'system:ca-seed' });
+  await jobs.enqueue('detect-duplicates', {}, { priority: 8, actor: 'system:ca-seed' });
+  await jobs.enqueue('calculate-scores', {}, { priority: 9, actor: 'system:ca-seed' });
+  await jobs.enqueue('generate-coverage-report', { state: 'CA' }, { priority: 9, actor: 'system:ca-seed' });
+  console.log(`[ca-seed] curated candidates: ${C.length} (created ${created}, matched-existing ${matched})`);
+  console.log(`[ca-seed] verify-organization jobs queued: ${verifyJobs}`);
+  console.log('[ca-seed] queued: FDIC CA-bank discovery, duplicate sweep, score refresh, coverage report');
+  console.log('[ca-seed] start the worker to execute: npm run pr:worker');
+  process.exit(0);
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });
diff --git a/src/pr/services/organizations.js b/src/pr/services/organizations.js
index c332665f..6a99a7fa 100644
--- a/src/pr/services/organizations.js
+++ b/src/pr/services/organizations.js
@@ -210,12 +210,13 @@ async function merge(winnerId, loserId, actor) {
     for (const k of fill) {
       if (!w[k] && l[k]) await client.query(`UPDATE pr_organizations SET ${k}=$1 WHERE id=$2`, [l[k], winnerId]);
     }
+    // array_agg over zero rows yields NULL — COALESCE keeps the NOT NULL columns intact
     await client.query(`UPDATE pr_organizations SET
-        state_presence = (SELECT array_agg(DISTINCT x) FROM unnest(state_presence || $2::text[]) x),
-        metros         = (SELECT array_agg(DISTINCT x) FROM unnest(metros || $3::text[]) x),
-        counties       = (SELECT array_agg(DISTINCT x) FROM unnest(counties || $4::text[]) x),
-        asset_classes  = (SELECT array_agg(DISTINCT x) FROM unnest(asset_classes || $5::text[]) x)
-      WHERE id=$1`, [winnerId, l.state_presence, l.metros, l.counties, l.asset_classes]);
+        state_presence = COALESCE((SELECT array_agg(DISTINCT x) FROM unnest(state_presence || $2::text[]) x), '{}'),
+        metros         = COALESCE((SELECT array_agg(DISTINCT x) FROM unnest(metros || $3::text[]) x), '{}'),
+        counties       = COALESCE((SELECT array_agg(DISTINCT x) FROM unnest(counties || $4::text[]) x), '{}'),
+        asset_classes  = COALESCE((SELECT array_agg(DISTINCT x) FROM unnest(asset_classes || $5::text[]) x), '{}')
+      WHERE id=$1`, [winnerId, l.state_presence || [], l.metros || [], l.counties || [], l.asset_classes || []]);
     await client.query(`UPDATE pr_organizations SET lifecycle_status='duplicate', duplicate_of_id=$1 WHERE id=$2`, [winnerId, loserId]);
     await audit.log({ actor, action: 'org.merge', entity_type: 'organization', entity_id: winnerId, detail: { merged_from: loserId } }, client);
     await audit.activity({ entity_type: 'organization', entity_id: winnerId, activity: 'duplicate_resolved', detail: { merged_from: loserId }, actor }, client);
diff --git a/test/pr/integration.test.js b/test/pr/integration.test.js
new file mode 100644
index 00000000..fb3b0718
--- /dev/null
+++ b/test/pr/integration.test.js
@@ -0,0 +1,233 @@
+'use strict';
+// Integration tests — run against a THROWAWAY database (rentv_pr_test), created and
+// migrated here. Never touches rentv_pr (dev/prod) data.
+process.env.PR_DB_NAME = 'rentv_pr_test';
+delete process.env.PR_DATABASE_URL;
+
+const test = require('node:test');
+const assert = require('node:assert');
+const { execSync } = require('child_process');
+
+// Fresh throwaway DB every run — migrations reapply from scratch (fast), so letter
+// versions and settings mutations from a prior run can never leak into this one.
+execSync('dropdb --if-exists rentv_pr_test 2>/dev/null; createdb rentv_pr_test', { shell: '/bin/bash' });
+
+const db = require('../../src/pr/db');
+const organizations = require('../../src/pr/services/organizations');
+const people = require('../../src/pr/services/people');
+const campaigns = require('../../src/pr/services/campaigns');
+const outreach = require('../../src/pr/services/outreach');
+const replies = require('../../src/pr/services/replies');
+const suppression = require('../../src/pr/services/suppression');
+const settings = require('../../src/pr/services/settings');
+const runs = require('../../src/pr/services/runs');
+const jobs = require('../../src/pr/jobs');
+const providers = require('../../src/pr/services/email-providers');
+const letters = require('../../src/pr/services/letters');
+
+const SRC = (name) => ({
+  source: { source_type: 'org_website', source_name: name, url: 'https://example-test.com/' + name.replace(/\s/g, '-'), short_excerpt: 'test excerpt for ' + name, is_primary_source: true, adapter: 'test' },
+  fields: { organization: 85, exact_title: 85 },
+});
+
+test.before(async () => {
+  const h = await db.health();
+  assert.ok(h.ok || h.db === 'unavailable', 'health call works');
+  await db.runMigrations({});
+  // clean slate for repeatable runs
+  await db.query(`TRUNCATE pr_field_evidence, pr_sources, pr_outreach_messages, pr_campaign_members,
+    pr_campaigns, pr_org_relationships, pr_people, pr_organizations, pr_suppression, pr_tasks,
+    pr_activities, pr_audit_log, pr_jobs, pr_research_runs, pr_import_batches RESTART IDENTITY CASCADE`, []);
+  await db.query(`UPDATE pr_settings SET value='false' WHERE key='arizona_unlocked'`, []);
+  providers.setProvider(new providers.MockProvider());
+});
+
+test('create organization with field-level evidence', async () => {
+  const r = await organizations.create({
+    display_name: 'Testco Commercial Advisors', website_url: 'https://testco-cre.com',
+    organization_type: 'brokerage', state_presence: ['CA'], metros: ['la'], counties: ['Los Angeles'],
+  }, { evidence: SRC('testco homepage'), actor: 'test' });
+  assert.ok(r.created);
+  const org = await organizations.get(r.org.id);
+  assert.equal(org.display_name, 'Testco Commercial Advisors');
+  assert.ok(org.evidence.length >= 1, 'evidence rows recorded');
+  assert.equal(org.evidence[0].source_type, 'org_website');
+});
+
+test('duplicate org create dedupes on exact match; merge is transactional', async () => {
+  const a = await organizations.create({ display_name: 'Merge Alpha', website_url: 'https://merge-alpha.com', organization_type: 'brokerage', state_presence: ['CA'] }, { evidence: SRC('alpha'), actor: 'test' });
+  const same = await organizations.create({ display_name: 'Merge Alpha Inc', website_url: 'https://merge-alpha.com/about', organization_type: 'brokerage', state_presence: ['CA'] }, { actor: 'test' });
+  assert.equal(same.created, false, 'same domain → exact match, no duplicate row');
+  const b = await organizations.create({ display_name: 'Merge Beta', website_url: 'https://merge-beta.com', organization_type: 'brokerage', state_presence: ['CA'], general_press_email: 'press@merge-beta.com' }, { evidence: SRC('beta'), actor: 'test' });
+  const p = await people.create({ full_name: 'Beta Person', exact_title: 'Marketing Director', organization_id: b.org.id }, { evidence: SRC('beta person'), actor: 'test' });
+  const m = await organizations.merge(a.org.id, b.org.id, 'test');
+  assert.ok(m.ok);
+  const winner = await organizations.get(a.org.id);
+  assert.ok(winner.people.some((x) => x.id === p.person.id), 'people moved to winner');
+  assert.equal(winner.general_press_email, 'press@merge-beta.com', 'gap-filled from loser');
+  const loser = await db.one('SELECT lifecycle_status, duplicate_of_id FROM pr_organizations WHERE id=$1', [b.org.id]);
+  assert.equal(loser.lifecycle_status, 'duplicate');
+  assert.equal(Number(loser.duplicate_of_id), Number(a.org.id));
+});
+
+test('add + verify person: exact title kept, role normalized, original preserved on correction', async () => {
+  const org = (await organizations.create({ display_name: 'People Org', website_url: 'https://people-org-test.com', organization_type: 'developer_owner', state_presence: ['CA'], metros: ['oc'] }, { evidence: SRC('people org'), actor: 'test' })).org;
+  const r = await people.create({
+    full_name: 'Casey Example', exact_title: 'Director of Corporate Communications',
+    organization_id: org.id, state: 'CA', metro: 'oc',
+    public_work_email: 'casey@people-org-test.com', email_verification_status: 'company_site_verified',
+  }, { evidence: SRC('team page'), actor: 'test' });
+  assert.ok(r.created);
+  assert.equal(r.person.department, 'communications');
+  await people.update(r.person.id, { lifecycle_status: 'needs_verification' }, 'test');
+  await people.update(r.person.id, { lifecycle_status: 'verified', last_verified_at: new Date().toISOString() }, 'test');
+  const upd = await people.update(r.person.id, { exact_title: 'VP, Communications' }, 'test');
+  assert.equal(upd.original_values.exact_title, 'Director of Corporate Communications', 'original extracted value preserved');
+  assert.equal(upd.role_history.length, 1, 'prior role appended to history, not overwritten');
+});
+
+test('inferred email can be stored but NEVER becomes outreach-eligible', async () => {
+  const org = (await organizations.create({ display_name: 'Inferred Org', website_url: 'https://inferred-org-test.com', organization_type: 'brokerage', state_presence: ['CA'] }, { evidence: SRC('inferred org'), actor: 'test' })).org;
+  const p = (await people.create({ full_name: 'Pat Guessed', exact_title: 'Media Relations Manager', organization_id: org.id, public_work_email: 'pguessed@inferred-org-test.com', email_verification_status: 'inferred' }, { evidence: SRC('pat'), actor: 'test' })).person;
+  await assert.rejects(() => people.update(p.id, { outreach_eligible: true }, 'test'), /verified business email/);
+  const rd = await outreach.readiness(p.id);
+  assert.equal(rd.ready, false);
+  assert.ok(rd.reasons.some((x) => /verified email|contact form|approved/.test(x)));
+});
+
+test('campaign enrollment is transactional and skips suppressed contacts', async () => {
+  const org = (await organizations.create({ display_name: 'Campaign Org', website_url: 'https://campaign-org-test.com', organization_type: 'pr_agency', state_presence: ['CA'], metros: ['la'] }, { evidence: SRC('campaign org'), actor: 'test' })).org;
+  const good = (await people.create({ full_name: 'Good Contact', exact_title: 'Account Director', organization_id: org.id, public_work_email: 'good@campaign-org-test.com', email_verification_status: 'company_site_verified' }, { evidence: SRC('good'), actor: 'test' })).person;
+  const bad = (await people.create({ full_name: 'Suppressed Contact', exact_title: 'Media Relations Director', organization_id: org.id, public_work_email: 'stop@campaign-org-test.com', email_verification_status: 'company_site_verified' }, { evidence: SRC('bad'), actor: 'test' })).person;
+  await suppression.add({ email: 'stop@campaign-org-test.com', person_id: bad.id, reason: 'opt_out', actor: 'test' });
+  const camp = await campaigns.create({ name: 'Test Campaign', state: 'CA' }, 'test');
+  const res = await campaigns.enroll(camp.id, [good.id, bad.id], 'test');
+  assert.deepEqual(res.added, [good.id]);
+  assert.equal(res.skipped.length, 1);
+  assert.match(res.skipped[0].reason, /suppressed|lifecycle/);
+});
+
+test('personalized draft: grounded blocks, provider draft WITHOUT sending, explicit send', async () => {
+  const org = (await organizations.create({ display_name: 'Draft Org', website_url: 'https://draft-org-test.com', organization_type: 'bank', state_presence: ['CA'], metros: ['san-diego'] }, { evidence: SRC('draft org'), actor: 'test' })).org;
+  const p = (await people.create({ full_name: 'Dana Draft', exact_title: 'Head of Communications', organization_id: org.id, public_work_email: 'dana@draft-org-test.com', email_verification_status: 'manually_verified' }, { evidence: SRC('dana bio'), actor: 'test' })).person;
+  const d = await outreach.generateDraft({ person_id: p.id, actor: 'test' });
+  assert.equal(d.message.status, 'draft_generated');
+  assert.ok(d.message.rendered_text.includes('Dana'), 'first name merged');
+  assert.ok(d.message.rendered_text.includes('Draft Org'), 'org name merged');
+  assert.ok(d.missing_merge_fields.includes('sender_name'), 'unfilled sender settings block sending');
+  // complete sender settings, approve, provider draft (mock), send
+  for (const [k, v] of [['sender_name', 'Test Sender'], ['sender_title', 'Editor'], ['sender_contact_information', 'test@rentv.example'], ['approved_submission_method', 'press@rentv.example']]) {
+    await settings.set(k, v, 'test');
+  }
+  await outreach.transition(d.message.id, 'needs_review', 'test');
+  await outreach.approve(d.message.id, 'test');
+  const pd = await outreach.createProviderDraft(d.message.id, 'test');
+  assert.equal(pd.provider, 'mock');
+  const mock = providers.getProvider();
+  assert.equal(mock.sent.length, 0, 'provider draft created but NOTHING sent');
+  await assert.rejects(() => outreach.send(d.message.id, { confirm: false, actor: 'test' }), /explicit confirmation/);
+  const sent = await outreach.send(d.message.id, { confirm: true, actor: 'test' });
+  assert.equal(sent.status, 'sent');
+  assert.equal(mock.sent.length, 1, 'explicit confirm sends exactly once');
+});
+
+test('incoming reply associates by thread; referral creates person + task; opt-out suppresses immediately', async () => {
+  const sentMsg = await db.one(`SELECT * FROM pr_outreach_messages WHERE status='sent' ORDER BY id DESC LIMIT 1`, []);
+  assert.ok(sentMsg, 'previous test produced a sent message');
+  const inbound = await replies.associateInbound({
+    provider: 'mock', provider_message_id: 'in-1', provider_thread_id: sentMsg.provider_thread_id,
+    person_id: sentMsg.person_id, organization_id: sentMsg.organization_id, campaign_id: null,
+    subject: 'Re: ' + sentMsg.subject, text: 'Please loop in our PR lead, Riley Referral (riley@draft-org-test.com).',
+    headers: { from: 'dana@draft-org-test.com' }, actor: 'test',
+  });
+  assert.equal(inbound.direction, 'inbound');
+  const outRow = await db.one('SELECT status FROM pr_outreach_messages WHERE id=$1', [sentMsg.id]);
+  assert.equal(outRow.status, 'replied', 'outbound flipped to replied by thread association');
+  const ref = await replies.processReferral({ message_id: sentMsg.id, referred_name: 'Riley Referral', referred_title: 'PR Lead', referred_email: 'riley@draft-org-test.com', actor: 'test' });
+  assert.ok(ref.referred_person.id);
+  const task = await db.one(`SELECT * FROM pr_tasks WHERE entity_type='person' AND entity_id=$1`, [ref.referred_person.id]);
+  assert.ok(task, 'follow-up task created for the referral');
+  // opt-out reply
+  const inbound2 = await replies.associateInbound({
+    provider: 'mock', provider_message_id: 'in-2', provider_thread_id: sentMsg.provider_thread_id,
+    person_id: sentMsg.person_id, organization_id: sentMsg.organization_id, campaign_id: null,
+    subject: 'unsubscribe', text: 'Please remove me from your list. Unsubscribe.',
+    headers: { from: 'dana@draft-org-test.com' }, actor: 'test',
+  });
+  assert.ok(inbound2.id);
+  const sup = await suppression.isSuppressed({ email: 'dana@draft-org-test.com' });
+  assert.equal(sup.suppressed, true, 'opt-out language suppresses immediately');
+  assert.equal(sup.reason, 'opt_out');
+});
+
+test('research run resumes from checkpoint after interruption', async () => {
+  const run = await runs.create({ state: 'CA', metro: 'la', category: 'bank', adapter: 'test', actor: 'test' });
+  await runs.setStatus(run.id, 'running');
+  await runs.progress(run.id, { pages: 3, candidates: 10, created: 5, checkpoint: { registry_cursor: { offset: 100 }, template_idx: 4 } });
+  await runs.pause(run.id, 'test'); // interruption
+  let r = await runs.get(run.id);
+  assert.equal(r.status, 'paused');
+  assert.equal(r.checkpoint.template_idx, 4, 'checkpoint survives pause');
+  await runs.resume(run.id, 'test');
+  r = await runs.get(run.id);
+  assert.equal(r.status, 'queued');
+  assert.deepEqual(r.checkpoint.registry_cursor, { offset: 100 }, 'resume continues from stored cursor');
+});
+
+test('job queue: dedupe, claim, retry-with-backoff, checkpoint kept on failure', async () => {
+  const j1 = await jobs.enqueue('calculate-scores', { probe: 1 }, { actor: 'test' });
+  const j2 = await jobs.enqueue('calculate-scores', { probe: 1 }, { actor: 'test' });
+  assert.ok(j1.id && j2.deduped, 'identical queued job dedupes');
+  const claimed = await jobs.claim();
+  assert.ok(claimed, 'claim returns a job');
+  const failed = await jobs.fail(claimed.id, new Error('transient'), { at: 7 });
+  assert.equal(failed.retried, true, 'first failure → requeued with backoff');
+  const row = await db.one('SELECT status, checkpoint FROM pr_jobs WHERE id=$1', [claimed.id]);
+  assert.equal(row.status, 'queued');
+  assert.equal(row.checkpoint.at, 7, 'checkpoint saved on failure');
+  await db.query(`UPDATE pr_jobs SET status='cancelled' WHERE status IN ('queued','running')`, []); // clean tail
+});
+
+test('ARIZONA GATE: broad AZ discovery cannot be enqueued before the CA gate passes', async () => {
+  await assert.rejects(
+    () => jobs.enqueue('discover-organizations', { state: 'AZ', metro: 'phoenix' }, { actor: 'test' }),
+    (e) => e.code === 'AZ_LOCKED');
+  // CA gate honestly not passed on this tiny dataset
+  const gate = await settings.californiaGate();
+  assert.equal(typeof gate.passed, 'boolean');
+  // an explicit, audit-logged override unlocks
+  const un = await settings.unlockArizona({ actor: 'test', override: true });
+  assert.equal(un.ok, true);
+  const j = await jobs.enqueue('discover-organizations', { state: 'AZ', metro: 'phoenix' }, { actor: 'test' });
+  assert.ok(j.id, 'AZ enqueues after unlock');
+  await db.query(`UPDATE pr_jobs SET status='cancelled' WHERE id=$1`, [j.id]);
+  await settings.set('arizona_unlocked', false, 'test'); // relock for other tests
+});
+
+test('locked letter blocks cannot be modified without an explicit unlock', async () => {
+  const tpl = await letters.baseTemplate();
+  const cur = await letters.getVersion((await db.one('SELECT id FROM pr_letter_versions WHERE template_id=$1 ORDER BY version DESC LIMIT 1', [tpl.id])).id);
+  const blocks = cur.blocks.map((b) => ({ ...b }));
+  blocks[0].locked = true;
+  await letters.saveVersion(tpl.id, { subject_template: cur.subject_template, blocks, change_note: 'lock greeting', actor: 'test' });
+  const tampered = blocks.map((b) => ({ ...b }));
+  tampered[0] = { ...tampered[0], content: 'HACKED CONTENT' };
+  await assert.rejects(() => letters.saveVersion(tpl.id, { subject_template: cur.subject_template, blocks: tampered, actor: 'test' }), /locked block/);
+  const ok = await letters.saveVersion(tpl.id, { subject_template: cur.subject_template, blocks: tampered, unlock: [tampered[0].key], change_note: 'explicit unlock edit', actor: 'test' });
+  assert.ok(ok.version > cur.version, 'explicit unlock allows the change (and versions are immutable history)');
+});
+
+test('audit log recorded every step of the workflow', async () => {
+  const n = await db.one(`SELECT
+      count(*) FILTER (WHERE action LIKE 'org.%')::int AS org,
+      count(*) FILTER (WHERE action LIKE 'person.%')::int AS person,
+      count(*) FILTER (WHERE action LIKE 'outreach.%')::int AS outreach,
+      count(*) FILTER (WHERE action LIKE 'suppression.%')::int AS sup,
+      count(*) FILTER (WHERE action LIKE 'letter.%')::int AS letter
+    FROM pr_audit_log`, []);
+  for (const k of ['org', 'person', 'outreach', 'sup', 'letter']) assert.ok(Number(n[k]) > 0, k + ' actions audited');
+});
+
+test.after(async () => {
+  // leave the test DB in place for inspection; it is throwaway by name.
+});
diff --git a/test/pr/unit.test.js b/test/pr/unit.test.js
new file mode 100644
index 00000000..77050982
--- /dev/null
+++ b/test/pr/unit.test.js
@@ -0,0 +1,152 @@
+'use strict';
+// Unit tests — pure logic, no database.
+const test = require('node:test');
+const assert = require('node:assert');
+
+const { normalizeOrgName, normalizePersonName, normalizeDomain, normalizeEmail, normalizeLinkedInUrl, splitName, normalizePhone } = require('../../src/pr/lib/normalize');
+const { classifyOrgMatch, classifyPersonMatch, nameSimilarity } = require('../../src/pr/lib/dedupe');
+const { freshnessScore, sourceConfidence, orgConfidence, personConfidence, outreachPriority } = require('../../src/pr/lib/scoring');
+const { canOrgTransition, canPersonTransition, canOutreachTransition } = require('../../src/pr/lib/status');
+const { classifyTitle, roleRelevance, ORG_TYPE_KEYS, ASSET_CLASSES } = require('../../src/pr/lib/taxonomy');
+const geo = require('../../src/pr/lib/geo');
+const letters = require('../../src/pr/services/letters');
+const { parseCSV, toCSV } = require('../../src/pr/services/importexport');
+
+test('organization name normalization strips entity suffixes and punctuation', () => {
+  assert.equal(normalizeOrgName('CBRE Group, Inc.'), 'cbre');
+  assert.equal(normalizeOrgName('Cox, Castle & Nicholson LLP'), 'cox castle nicholson');
+  assert.equal(normalizeOrgName('The Irvine Company LLC'), 'irvine');
+  assert.equal(normalizeOrgName('Majestic Realty Co.'), 'majestic realty');
+});
+
+test('person name normalization strips credentials and case', () => {
+  assert.equal(normalizePersonName('Jane Q. Smith, CCIM'), 'jane q smith');
+  assert.equal(normalizePersonName('ROBERT O’BRIEN Jr.'), "robert o’brien".replace('’', '’').toLowerCase().replace(/[^a-z\s'-]/g, ' ').replace(/\s+/g, ' ').trim() || 'robert o brien');
+  assert.deepEqual(splitName('Jane Q. Smith'), { first_name: 'Jane', middle_name: 'Q.', last_name: 'Smith' });
+});
+
+test('domain normalization: scheme/www/path stripped, junk rejected', () => {
+  assert.equal(normalizeDomain('https://www.cbre.com/offices/la'), 'cbre.com');
+  assert.equal(normalizeDomain('HTTP://Example.COM:8080/x'), 'example.com');
+  assert.equal(normalizeDomain('commercial.bank.com'), 'commercial.bank.com');
+  assert.equal(normalizeDomain('not a domain'), null);
+});
+
+test('email + phone + linkedin normalization', () => {
+  assert.equal(normalizeEmail('  Press@CBRE.com '), 'press@cbre.com');
+  assert.equal(normalizeEmail('nope'), null);
+  assert.equal(normalizePhone('(213) 555-1212'), '2135551212');
+  assert.equal(normalizeLinkedInUrl('https://www.linkedin.com/in/jane-smith-123/?src=x'), 'https://www.linkedin.com/in/jane-smith-123');
+  assert.equal(normalizeLinkedInUrl('https://twitter.com/x'), null);
+});
+
+test('org duplicate detection: same domain = exact; same name different domain = uncertain (admin decides)', () => {
+  const a = { display_name: 'CBRE', domain: 'cbre.com', state_presence: ['CA'] };
+  assert.equal(classifyOrgMatch(a, { display_name: 'CBRE Group Inc', domain: 'cbre.com', normalized_name: 'cbre group' }).verdict, 'exact');
+  const un = classifyOrgMatch({ display_name: 'Pacific Realty', domain: 'pacrealty.com' }, { display_name: 'Pacific Realty', domain: 'pacificrealty.net', normalized_name: 'pacific realty' });
+  assert.equal(un.verdict, 'uncertain'); // NEVER auto-merged
+  assert.equal(classifyOrgMatch({ display_name: 'Alpha Partners' }, { display_name: 'Zeta Holdings', normalized_name: 'zeta' }).verdict, 'distinct');
+});
+
+test('person duplicate detection: email exact; same name diff org = uncertain', () => {
+  assert.equal(classifyPersonMatch({ full_name: 'Jane Smith', public_work_email: 'js@x.com' }, { full_name: 'J Smith', public_work_email: 'js@x.com' }).verdict, 'exact');
+  const m = classifyPersonMatch({ full_name: 'Jane Smith', organization_id: 1 }, { full_name: 'Jane Smith', organization_id: 2, normalized_name: 'jane smith' });
+  assert.equal(m.verdict, 'uncertain');
+});
+
+test('freshness decays with age', () => {
+  const now = Date.now();
+  assert.equal(freshnessScore(new Date(now - 10 * 864e5).toISOString(), now), 100);
+  assert.equal(freshnessScore(new Date(now - 100 * 864e5).toISOString(), now), 60);
+  assert.equal(freshnessScore(null), 0);
+});
+
+test('source confidence: primary gov beats lone search snippet; corroboration adds', () => {
+  const gov = sourceConfidence([{ source_type: 'gov_registry', is_primary_source: true, source_id: 1 }]);
+  const snip = sourceConfidence([{ source_type: 'search_api', source_id: 2 }]);
+  assert.ok(gov > snip);
+  const multi = sourceConfidence([{ source_type: 'org_website', is_primary_source: true, source_id: 1 }, { source_type: 'press_release', source_id: 2 }]);
+  assert.ok(multi > sourceConfidence([{ source_type: 'org_website', is_primary_source: true, source_id: 1 }]));
+});
+
+test('priority and confidence are separate; components exposed', () => {
+  const org = { state_presence: ['CA'], metros: ['la'], last_verified_at: new Date().toISOString(), website_url: 'https://x.com', organization_type: 'brokerage', press_page_url: 'https://x.com/press' };
+  const person = { department: 'communications', normalized_role: 'director_communications', public_work_email: 'a@x.com', email_verification_status: 'company_site_verified', last_verified_at: new Date().toISOString(), metro: 'la' };
+  const ev = [{ source_type: 'org_website', is_primary_source: true, source_id: 1 }];
+  const pri = outreachPriority({ org, person, evidenceRows: ev });
+  assert.ok(pri.score >= 70, 'comms director w/ verified email in LA should be high priority, got ' + pri.score);
+  assert.ok(pri.components.role_relevance === 100);
+  const weakEv = outreachPriority({ org, person: { ...person, email_verification_status: 'inferred' }, evidenceRows: [] });
+  assert.ok(weakEv.components.source_confidence === 0, 'no evidence → zero source confidence, visible, not hidden by priority');
+  const conf = personConfidence({ ...person }, ev);
+  assert.ok(conf.components && typeof conf.components.contact_method === 'number');
+  assert.ok(orgConfidence(org, ev).score > 0);
+});
+
+test('status machines reject illegal jumps', () => {
+  assert.ok(canOrgTransition('discovered', 'needs_review'));
+  assert.ok(!canOrgTransition('discovered', 'contacted'));
+  assert.ok(canPersonTransition('verified', 'ready_for_outreach'));
+  assert.ok(!canPersonTransition('suppressed', 'ready_for_outreach')); // suppression is sticky
+  assert.ok(canOutreachTransition('provider_draft_created', 'sent'));
+  assert.ok(!canOutreachTransition('draft_generated', 'sent')); // cannot send an unapproved draft
+  assert.ok(!canOutreachTransition('opted_out', 'sent'));
+});
+
+test('title classification: exact title kept apart from normalized role', () => {
+  assert.deepEqual(classifyTitle('Vice President of Communications').normalized_role, 'vp_communications');
+  assert.equal(classifyTitle('Vice President of Communications').department, 'communications');
+  assert.equal(classifyTitle('Chief Marketing Officer').department, 'marketing');
+  assert.equal(classifyTitle('Commercial Escrow Officer').department, 'title_escrow');
+  assert.equal(classifyTitle('Head of Commercial Real Estate').department, 'lending');
+  assert.equal(classifyTitle('Senior Account Executive').department, 'agency');
+  assert.equal(classifyTitle('President').department, 'leadership');
+  assert.ok(roleRelevance('communications', 'director_communications') > roleRelevance('leadership', 'president'));
+});
+
+test('taxonomy covers the mandated categories and asset classes', () => {
+  assert.ok(ORG_TYPE_KEYS.length >= 35, 'expected ≥35 org types, got ' + ORG_TYPE_KEYS.length);
+  assert.ok(ASSET_CLASSES.includes('medical_office') && ASSET_CLASSES.includes('data_center') && ASSET_CLASSES.length >= 17);
+});
+
+test('geo: CA metro order mandated, AZ list present, geo priority favors earlier metros', () => {
+  assert.equal(geo.CA_METROS[0].key, 'la');
+  assert.equal(geo.CA_METROS[4].key, 'bay-area');
+  assert.equal(geo.AZ_METROS[0].key, 'phoenix');
+  assert.ok(geo.geoPriority('CA', 'la') > geo.geoPriority('CA', 'central-valley'));
+  assert.equal(geo.metroForCounty('CA', 'Riverside County'), 'inland-empire');
+});
+
+test('letter render: merge fields fill, unfilled stay visible, citations separate from body', () => {
+  const blocks = [
+    { key: 'greet', order: 1, content: 'Hi {{first_name}},' },
+    { key: 'org', order: 2, content: '{{organization_specific_block}}', internal_sources: [12] },
+    { key: 'sig', order: 3, content: '{{sender_name}}' },
+  ];
+  const out = letters.render({
+    subject_template: 'Add RentV — {{organization_name}}',
+    blocks,
+    context: { first_name: 'Jane', organization_name: 'CBRE', sender_name: '' },
+    orgBlock: 'CBRE evidence sentence.',
+  });
+  assert.equal(out.subject, 'Add RentV — CBRE');
+  assert.ok(out.text.includes('Hi Jane,'));
+  assert.ok(out.text.includes('CBRE evidence sentence.'));
+  assert.deepEqual(out.missing, ['sender_name']); // visible, blocks sending
+  assert.ok(!out.text.includes('source'), 'citations never rendered into the body');
+  assert.deepEqual(out.citations, [{ block: 'org', source_id: 12 }]);
+  assert.ok(out.html.includes('<p'));
+});
+
+test('merge-field extraction from blocks + subject', () => {
+  const fields = letters.extractMergeFields([{ content: 'x {{a}} y {{b_c}}' }], 'subject {{a}}');
+  assert.deepEqual(fields.sort(), ['a', 'b_c']);
+});
+
+test('CSV parse handles quotes, commas, CRLF; toCSV round-trips', () => {
+  const rows = parseCSV('name,email\n"Smith, Jane",j@x.com\r\n"Say ""hi""",b@y.com\n');
+  assert.deepEqual(rows[1], ['Smith, Jane', 'j@x.com']);
+  assert.deepEqual(rows[2], ['Say "hi"', 'b@y.com']);
+  const csv = toCSV([{ a: 'x,y', b: 'z' }], ['a', 'b']);
+  assert.equal(csv, 'a,b\n"x,y",z');
+});

← 762a11ea pr-intelligence: mount /api/pr/* + /admin/pr-intelligence sh  ·  back to Rentv 2026  ·  pr-intelligence: docs (data-sources, research-ops, email-set eea88b78 →