[object Object]

← back to Rentv Adintel

merge: full-spec fan-out (data+seed, compliance, UI/routes, connectors, export, docs+tests)

048b52e10e6755926c072bcc24f3a44a344aad1b · 2026-08-07 18:11:58 -0700 · Steve Abrams

Node A (seed): 29 orgs incl. 5 verified advertisers + CoStar content-partner
  (kept distinct, never mislabeled sponsor) + 22 panelists, 24 dated rate
  snapshots, 45k/50k audience snapshots (unmerged), scores, classification +
  reversible entity-resolution.
Node B (compliance): source-policy validator, LinkedIn host block, SSRF guard,
  no-inferred-email, adapter contract + manual-review fallback, search provider
  (manual, human-click LinkedIn URLs only).
Node C (UI): /api/v1 + Simple-View pages (advertisers/ads/conferences/contacts/
  prospects/media-kit/sources/dashboard), adjustable-columns table engine,
  CA/AZ + Verified-Only switches, Show-Me-Why drawer.
Node D (connectors): GA4/GSC fixture-backed importers (is_demo), CSV field-mapper
  (refuses no-source contacts), Gmail/Ads off, email-upload parser, derived insights.
Node E (export): pure-node ZIP+XLSX+CSV, rights-aware Download-Everything,
  self-contained offline executive-viewer.html.
Node F (docs+tests): 8 docs + README + 45 passing tests (0 fail).

Merge fixes: relocated 3 misfiled static assets to public/; fixed advertisers
GROUP BY (spurious MAX on ordered subquery). All endpoints 200, export valid,
43/43 source policies pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 048b52e10e6755926c072bcc24f3a44a344aad1b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Aug 7 18:11:58 2026 -0700

    merge: full-spec fan-out (data+seed, compliance, UI/routes, connectors, export, docs+tests)
    
    Node A (seed): 29 orgs incl. 5 verified advertisers + CoStar content-partner
      (kept distinct, never mislabeled sponsor) + 22 panelists, 24 dated rate
      snapshots, 45k/50k audience snapshots (unmerged), scores, classification +
      reversible entity-resolution.
    Node B (compliance): source-policy validator, LinkedIn host block, SSRF guard,
      no-inferred-email, adapter contract + manual-review fallback, search provider
      (manual, human-click LinkedIn URLs only).
    Node C (UI): /api/v1 + Simple-View pages (advertisers/ads/conferences/contacts/
      prospects/media-kit/sources/dashboard), adjustable-columns table engine,
      CA/AZ + Verified-Only switches, Show-Me-Why drawer.
    Node D (connectors): GA4/GSC fixture-backed importers (is_demo), CSV field-mapper
      (refuses no-source contacts), Gmail/Ads off, email-upload parser, derived insights.
    Node E (export): pure-node ZIP+XLSX+CSV, rights-aware Download-Everything,
      self-contained offline executive-viewer.html.
    Node F (docs+tests): 8 docs + README + 45 passing tests (0 fail).
    
    Merge fixes: relocated 3 misfiled static assets to public/; fixed advertisers
    GROUP BY (spurious MAX on ordered subquery). All endpoints 200, export valid,
    43/43 source policies pass.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 db/seed/flyers.js                   |  171 ++++
 db/seed/index.js                    |  186 +++++
 db/seed/markets.js                  |   66 ++
 db/seed/organizations.js            |  711 ++++++++++++++++
 db/seed/rentv-products.js           |  216 +++++
 db/seed/scores.js                   |  317 +++++++
 db/seed/sources.js                  |  471 +++++++++++
 lib/classification.js               |   92 +++
 lib/compliance/fetch-guard.js       |  247 ++++++
 lib/compliance/no-inferred-email.js |  147 ++++
 lib/compliance/source-policy.js     |  197 +++++
 lib/entity-resolution.js            |  266 ++++++
 public/css/app.css                  |  736 +++++++++++++++++
 public/js/app.js                    |  264 ++++++
 public/js/table.js                  |  513 ++++++++++++
 scripts/audit-source-policies.js    |  161 ++++
 scripts/export-all.js               |   49 ++
 scripts/import-google.js            |  173 ++++
 scripts/research.js                 |  134 +++
 src/adapters/base.js                |  242 ++++++
 src/adapters/manual-review.js       |   70 ++
 src/analytics/derive.js             |  339 ++++++++
 src/analytics/index.js              |   37 +
 src/connectors/csv-import.js        |  523 ++++++++++++
 src/connectors/email-upload.js      |  379 +++++++++
 src/connectors/ga4.js               |  354 ++++++++
 src/connectors/gmail.js             |  176 ++++
 src/connectors/google-ads.js        |  172 ++++
 src/connectors/gsc.js               |  291 +++++++
 src/export/build.js                 |  657 +++++++++++++++
 src/export/csv.js                   |   81 ++
 src/export/executive-viewer.js      |  644 +++++++++++++++
 src/export/rights.js                |  314 +++++++
 src/export/xlsx.js                  |  241 ++++++
 src/export/zip.js                   |  190 +++++
 src/routes/api.js                   |  654 +++++++++++++++
 src/routes/pages.js                 | 1553 +++++++++++++++++++++++++++++++++++
 src/search/provider.js              |  127 +++
 src/search/queries.js               |   90 ++
 test/classification.test.js         |   84 ++
 test/compliance.test.js             |  144 ++++
 test/export-filter.test.js          |  103 +++
 test/scoring.test.js                |  127 +++
 test/types.test.js                  |   99 +++
 44 files changed, 12808 insertions(+)

diff --git a/db/seed/flyers.js b/db/seed/flyers.js
new file mode 100644
index 0000000..afff8e4
--- /dev/null
+++ b/db/seed/flyers.js
@@ -0,0 +1,171 @@
+'use strict';
+/**
+ * Seed creative_assets for RENTV flyer files.
+ *
+ * Per §22: check for the two flyer files. They are expected to be absent
+ * (confirmed absent at /Users/macstudio3/Downloads/ and /mnt/data/).
+ * When absent: insert GENERATED_PLACEHOLDER creative_assets rows and an
+ * audit_log entry requesting admin upload.
+ *
+ * When present: copy to public/seed/rentv/, compute sha256 + image dimensions,
+ * label AUTHORIZED_RENTV_ASSET.
+ *
+ * Idempotent: checks by file_name before inserting.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const FLYER_SEARCH_PATHS = [
+  '/mnt/data',
+  '/Users/macstudio3/Downloads',
+];
+
+const FLYERS = [
+  {
+    file_name: 'Property Spotlight Flyer REV April 2025.jpg',
+    mime_type: 'image/jpeg',
+    alt_text:
+      'RENTV Property Spotlight Flyer — revised April 2025. Shows RENTV property spotlight eblast product details and pricing.',
+    product_key: 'property_spotlight_eblast',
+    notes: 'April 2025 version of the RENTV Property Spotlight product flyer.',
+  },
+  {
+    file_name: 'Corp Flyer Apr 2026 V3.jpg',
+    mime_type: 'image/jpeg',
+    alt_text:
+      'RENTV Corporate Flyer — April 2026 Version 3. Shows RENTV advertising product suite, audience figures, and updated rates.',
+    product_key: 'website_banner',
+    notes: 'April 2026 v3 RENTV corporate media kit flyer.',
+  },
+];
+
+function findFlyer(fileName) {
+  for (const dir of FLYER_SEARCH_PATHS) {
+    const full = path.join(dir, fileName);
+    if (fs.existsSync(full)) return full;
+  }
+  return null;
+}
+
+function sha256File(filePath) {
+  const data = fs.readFileSync(filePath);
+  return crypto.createHash('sha256').update(data).digest('hex');
+}
+
+/** Parse JPEG dimensions from header bytes (minimal, no external deps). */
+function jpegDimensions(filePath) {
+  try {
+    const fd = fs.openSync(filePath, 'r');
+    const buf = Buffer.alloc(65536);
+    fs.readSync(fd, buf, 0, 65536, 0);
+    fs.closeSync(fd);
+    // Scan for SOF0/SOF1/SOF2 markers (0xFFC0, 0xFFC1, 0xFFC2)
+    for (let i = 0; i < buf.length - 8; i++) {
+      if (buf[i] === 0xff && (buf[i + 1] === 0xc0 || buf[i + 1] === 0xc1 || buf[i + 1] === 0xc2)) {
+        const height = buf.readUInt16BE(i + 5);
+        const width = buf.readUInt16BE(i + 7);
+        return { width, height };
+      }
+    }
+  } catch (_) {
+    // ignore
+  }
+  return { width: null, height: null };
+}
+
+async function seedFlyers(client) {
+  let inserted = 0;
+  let skipped = 0;
+  let placeholders = 0;
+
+  // Ensure public/seed/rentv/ directory exists (relative to project root)
+  const PROJECT_ROOT = path.resolve(__dirname, '../..');
+  const publicSeedDir = path.join(PROJECT_ROOT, 'public', 'seed', 'rentv');
+  if (!fs.existsSync(publicSeedDir)) {
+    fs.mkdirSync(publicSeedDir, { recursive: true });
+  }
+
+  for (const flyer of FLYERS) {
+    // Idempotency check
+    const existing = await client.query(
+      `SELECT id FROM creative_assets WHERE file_name=$1 LIMIT 1`,
+      [flyer.file_name]
+    );
+    if (existing.rows.length > 0) {
+      skipped++;
+      continue;
+    }
+
+    const foundPath = findFlyer(flyer.file_name);
+
+    if (foundPath) {
+      // File present — compute checksum and dims, copy to public/seed/rentv/
+      const destPath = path.join(publicSeedDir, flyer.file_name);
+      fs.copyFileSync(foundPath, destPath);
+
+      const checksum = sha256File(foundPath);
+      const { width, height } = jpegDimensions(foundPath);
+      const objectKey = `seed/rentv/${flyer.file_name}`;
+
+      await client.query(
+        `INSERT INTO creative_assets
+           (file_name, mime_type, width, height, checksum, object_key,
+            capture_method, rights_status, captured_at, alt_text)
+         VALUES ($1,$2,$3,$4,$5,$6,'MANUAL_UPLOAD','EXPORT_ALLOWED',now(),$7)`,
+        [flyer.file_name, flyer.mime_type, width, height, checksum, objectKey, flyer.alt_text]
+      );
+
+      const alFoundExist = await client.query(
+        `SELECT id FROM audit_logs WHERE action='SEED_ASSET_FOUND' AND detail->>'file_name'=$1 LIMIT 1`,
+        [flyer.file_name]
+      );
+      if (alFoundExist.rows.length === 0) {
+        await client.query(
+          `INSERT INTO audit_logs (action, entity_table, actor, detail)
+           VALUES ('SEED_ASSET_FOUND','creative_assets','seed',$1::jsonb)`,
+          [JSON.stringify({ file_name: flyer.file_name, checksum, width, height, object_key: objectKey })]
+        );
+      }
+      inserted++;
+    } else {
+      // File absent — create GENERATED_PLACEHOLDER
+      await client.query(
+        `INSERT INTO creative_assets
+           (file_name, mime_type, capture_method, rights_status, alt_text)
+         VALUES ($1,$2,'GENERATED_PLACEHOLDER','INTERNAL_EVIDENCE_ONLY',$3)`,
+        [flyer.file_name, flyer.mime_type, flyer.alt_text]
+      );
+      placeholders++;
+
+      const alMissExist = await client.query(
+        `SELECT id FROM audit_logs WHERE action='SEED_ASSET_MISSING' AND detail->>'file_name'=$1 LIMIT 1`,
+        [flyer.file_name]
+      );
+      if (alMissExist.rows.length === 0) {
+        await client.query(
+          `INSERT INTO audit_logs (action, entity_table, actor, detail)
+           VALUES ('SEED_ASSET_MISSING','creative_assets','seed',$1::jsonb)`,
+          [
+            JSON.stringify({
+              file_name: flyer.file_name,
+              note:
+                'Flyer asset not found at expected paths (' +
+                FLYER_SEARCH_PATHS.join(', ') +
+                '). Admin upload card required. ' +
+                flyer.notes,
+              admin_action_required: true,
+              searched_paths: FLYER_SEARCH_PATHS,
+              product_key: flyer.product_key,
+            }),
+          ]
+        );
+      }
+    }
+  }
+
+  return { inserted, skipped, placeholders };
+}
+
+module.exports = { seedFlyers };
diff --git a/db/seed/index.js b/db/seed/index.js
new file mode 100644
index 0000000..db82a3c
--- /dev/null
+++ b/db/seed/index.js
@@ -0,0 +1,186 @@
+'use strict';
+/**
+ * RENTV Advertiser Intelligence — seed orchestrator.
+ *
+ * Usage: node db/seed/index.js
+ *
+ * Idempotent: safe to run multiple times. Each sub-module uses
+ * ON CONFLICT / existence checks to avoid duplicate rows.
+ *
+ * Execution order:
+ *   1. markets        — geography lookup table (FK target)
+ *   2. sources        — source_policies (FK target for evidence / rate snapshots)
+ *   3. organizations  — orgs + evidence_records + ad_sightings + events + event_relationships
+ *   4. rentv-products — rentv_rate_snapshots + rentv_audience_snapshots
+ *   5. flyers         — creative_assets for RENTV flyer files (placeholder if absent)
+ *   6. scores         — opportunity_stages + opportunity_scores (per org)
+ */
+
+const { pool, tx } = require('../index');
+
+const { seedMarkets }       = require('./markets');
+const { seedSources }       = require('./sources');
+const { seedOrganizations } = require('./organizations');
+const { seedRentvProducts } = require('./rentv-products');
+const { seedFlyers }        = require('./flyers');
+const { seedScores }        = require('./scores');
+
+async function run() {
+  console.log('=== RENTV Advertiser Intelligence — seed run ===');
+  console.log(`Started: ${new Date().toISOString()}`);
+  console.log('');
+
+  let marketCounts, sourceCounts, orgCounts, productCounts, flyerCounts, scoreCounts;
+
+  // Run all seed phases inside a single transaction so a failure rolls back cleanly.
+  // scores.js does a DELETE+reinsert per org — that's fine inside the same tx.
+  await tx(async (client) => {
+    console.log('[1/6] Seeding markets…');
+    marketCounts = await seedMarkets(client);
+    console.log(`      markets: +${marketCounts.inserted} inserted, ${marketCounts.updated} updated`);
+
+    console.log('[2/6] Seeding source_policies…');
+    sourceCounts = await seedSources(client);
+    console.log(`      source_policies: +${sourceCounts.inserted} inserted, ${sourceCounts.updated} updated`);
+
+    console.log('[3/6] Seeding organizations, evidence, ad_sightings, events…');
+    orgCounts = await seedOrganizations(client);
+    console.log(`      organizations:       +${orgCounts.orgs_inserted} inserted, ${orgCounts.orgs_updated} updated`);
+    console.log(`      evidence_records:    +${orgCounts.evidence_inserted} inserted`);
+    console.log(`      ad_sightings:        +${orgCounts.ad_sightings_inserted} inserted`);
+    console.log(`      events:              +${orgCounts.events_inserted} inserted`);
+    console.log(`      event_relationships: +${orgCounts.event_relationships_inserted} inserted`);
+
+    console.log('[4/6] Seeding RENTV rate and audience snapshots…');
+    productCounts = await seedRentvProducts(client);
+    console.log(`      rentv_rate_snapshots:     +${productCounts.rate_inserted} inserted (${productCounts.rate_skipped} skipped/existing)`);
+    console.log(`      rentv_audience_snapshots: +${productCounts.audience_inserted} inserted (${productCounts.audience_skipped} skipped/existing)`);
+
+    console.log('[5/6] Seeding creative_assets (RENTV flyers)…');
+    flyerCounts = await seedFlyers(client);
+    console.log(`      creative_assets: +${flyerCounts.inserted} real, +${flyerCounts.placeholders} placeholder(s), ${flyerCounts.skipped} skipped/existing`);
+
+    console.log('[6/6] Seeding opportunity_stages and opportunity_scores…');
+    scoreCounts = await seedScores(client);
+    console.log(`      opportunity_stages: +${scoreCounts.stages_inserted} inserted, ${scoreCounts.stages_updated} updated`);
+    console.log(`      opportunity_scores: +${scoreCounts.scores_inserted} computed and inserted`);
+  });
+
+  // ---- Summary table ----------------------------------------------------------
+  console.log('');
+  console.log('=== Seed summary ===');
+  const fmt = (label, val) => console.log(`  ${label.padEnd(32)} ${String(val).padStart(6)}`);
+
+  fmt('markets (CA + AZ)',         marketCounts.inserted + marketCounts.updated);
+  fmt('source_policies',           sourceCounts.inserted + sourceCounts.updated);
+  fmt('organizations',             orgCounts.orgs_inserted + orgCounts.orgs_updated);
+  fmt('evidence_records',          orgCounts.evidence_inserted);
+  fmt('ad_sightings',              orgCounts.ad_sightings_inserted);
+  fmt('events',                    orgCounts.events_inserted);
+  fmt('event_relationships',       orgCounts.event_relationships_inserted);
+  fmt('rentv_rate_snapshots',      productCounts.rate_inserted + productCounts.rate_skipped);
+  fmt('rentv_audience_snapshots',  productCounts.audience_inserted + productCounts.audience_skipped);
+  fmt('creative_assets',           flyerCounts.inserted + flyerCounts.placeholders + flyerCounts.skipped);
+  fmt('opportunity_stages',        scoreCounts.stages_inserted + scoreCounts.stages_updated);
+  fmt('opportunity_scores',        scoreCounts.scores_inserted);
+
+  // ---- Verification queries ---------------------------------------------------
+  console.log('');
+  console.log('=== Verification ===');
+
+  const client = await pool.connect();
+  try {
+    // 5 verified advertisers present
+    const vaRes = await client.query(
+      `SELECT o.display_name, a.relationship_status, a.verification_status, a.confidence
+       FROM ad_sightings a
+       JOIN organizations o ON o.id=a.organization_id
+       WHERE a.relationship_status='VERIFIED_ADVERTISER'
+       ORDER BY o.display_name`
+    );
+    console.log(`\nVerified advertisers (ad_sightings): ${vaRes.rows.length}`);
+    for (const r of vaRes.rows) {
+      console.log(`  [${r.relationship_status}] ${r.display_name} — verification_status: ${r.verification_status}, confidence: ${r.confidence}`);
+    }
+
+    // CoStar content partner
+    const costarRes = await client.query(
+      `SELECT o.display_name, er.relationship_status
+       FROM event_relationships er
+       JOIN organizations o ON o.id=er.organization_id
+       WHERE er.relationship_status='VERIFIED_CONTENT_PARTNER'`
+    );
+    console.log(`\nContent partners (event_relationships): ${costarRes.rows.length}`);
+    for (const r of costarRes.rows) {
+      console.log(`  [${r.relationship_status}] ${r.display_name}`);
+    }
+
+    // Panelists NOT labeled as sponsors
+    const panelistRes = await client.query(
+      `SELECT o.display_name, er.relationship_status
+       FROM event_relationships er
+       JOIN organizations o ON o.id=er.organization_id
+       WHERE er.relationship_status IN ('SPEAKER_OR_PANELIST_ONLY','LIKELY_PROSPECT')
+       ORDER BY er.relationship_status, o.display_name`
+    );
+    console.log(`\nPanelists/prospects (event_relationships, NOT labeled as sponsors): ${panelistRes.rows.length}`);
+    for (const r of panelistRes.rows) {
+      console.log(`  [${r.relationship_status}] ${r.display_name}`);
+    }
+
+    // Verify no panelist is mislabeled as sponsor in event_relationships
+    const mislabelRes = await client.query(
+      `SELECT count(*) AS cnt
+       FROM event_relationships
+       WHERE relationship_status IN ('VERIFIED_CONFERENCE_SPONSOR','VERIFIED_ADVERTISER','VERIFIED_EXHIBITOR','VERIFIED_MEDIA_PARTNER')`
+    );
+    const mislabelCount = parseInt(mislabelRes.rows[0].cnt, 10);
+    console.log(
+      `\nSafety check — event_relationships rows with sponsor/advertiser status: ${mislabelCount}` +
+      (mislabelCount === 0 ? ' (PASS — panelists correctly classified)' : ' (REVIEW NEEDED)')
+    );
+
+    // Audience snapshots
+    const audRes = await client.query(
+      `SELECT metric_key, value_numeric, observed_at, value_text
+       FROM rentv_audience_snapshots ORDER BY observed_at`
+    );
+    console.log(`\nAudience snapshots (must be 2 separate rows): ${audRes.rows.length}`);
+    for (const r of audRes.rows) {
+      console.log(`  ${r.observed_at}: ${r.metric_key} = ${r.value_numeric}`);
+    }
+
+    // Admin confirm audit log
+    const adminConfirmRes = await client.query(
+      `SELECT count(*) AS cnt FROM audit_logs WHERE action='SEED_REQUIRES_ADMIN_CONFIRM'`
+    );
+    console.log(`\nAdmin confirm audit log entries (AUTHORIZED_INTERNAL_EMAIL): ${adminConfirmRes.rows[0].cnt}`);
+
+    // Missing flyer assets requiring upload
+    const flyerAuditRes = await client.query(
+      `SELECT detail->>'file_name' AS file_name, detail->>'note' AS note
+       FROM audit_logs WHERE action='SEED_ASSET_MISSING'`
+    );
+    if (flyerAuditRes.rows.length > 0) {
+      console.log(`\nAdmin upload required for ${flyerAuditRes.rows.length} flyer(s):`);
+      for (const r of flyerAuditRes.rows) {
+        console.log(`  - ${r.file_name}`);
+        console.log(`    ${r.note}`);
+      }
+    }
+
+  } finally {
+    client.release();
+  }
+
+  console.log('');
+  console.log(`=== Seed complete: ${new Date().toISOString()} ===`);
+
+  await pool.end();
+}
+
+run().catch((err) => {
+  console.error('Seed FAILED:', err.message);
+  console.error(err.stack);
+  process.exit(1);
+});
diff --git a/db/seed/markets.js b/db/seed/markets.js
new file mode 100644
index 0000000..38cf696
--- /dev/null
+++ b/db/seed/markets.js
@@ -0,0 +1,66 @@
+'use strict';
+/**
+ * Seed markets table — California (§7 priority order) and Arizona.
+ * Idempotent via ON CONFLICT (normalized_name) DO UPDATE.
+ */
+
+const { normalizeName } = require('../../lib/types');
+
+const CA_MARKETS = [
+  { metro: 'Greater Los Angeles', region: 'Southern California', priority: 1 },
+  { metro: 'Orange County',       region: 'Southern California', priority: 2 },
+  { metro: 'Inland Empire',       region: 'Southern California', priority: 3 },
+  { metro: 'San Diego',           region: 'Southern California', priority: 4 },
+  { metro: 'Ventura County',      region: 'Southern California', priority: 5 },
+  { metro: 'San Francisco Bay Area', region: 'Northern California', priority: 6 },
+  { metro: 'Sacramento',          region: 'Northern California', priority: 7 },
+  { metro: 'Central Valley',      region: 'Central California',  priority: 8 },
+  { metro: 'Statewide California', region: 'California',         priority: 9 },
+];
+
+const AZ_MARKETS = [
+  { metro: 'Phoenix metro',   region: 'Greater Phoenix', priority: 1 },
+  { metro: 'Scottsdale',      region: 'Greater Phoenix', priority: 2 },
+  { metro: 'Tempe',           region: 'Greater Phoenix', priority: 3 },
+  { metro: 'Mesa',            region: 'Greater Phoenix', priority: 4 },
+  { metro: 'Chandler',        region: 'Greater Phoenix', priority: 5 },
+  { metro: 'Gilbert',         region: 'Greater Phoenix', priority: 6 },
+  { metro: 'Glendale',        region: 'Greater Phoenix', priority: 7 },
+  { metro: 'Tucson',          region: 'Southern Arizona', priority: 8 },
+  { metro: 'Statewide Arizona', region: 'Arizona',       priority: 9 },
+];
+
+async function seedMarkets(client) {
+  let inserted = 0;
+  let updated = 0;
+
+  for (const m of CA_MARKETS) {
+    const nn = normalizeName(m.metro);
+    const res = await client.query(
+      `INSERT INTO markets (state, region, metro, normalized_name, priority)
+       VALUES ($1, $2, $3, $4, $5)
+       ON CONFLICT (normalized_name)
+       DO UPDATE SET state=$1, region=$2, metro=$3, priority=$5
+       RETURNING (xmax = 0) AS is_insert`,
+      ['CA', m.region, m.metro, nn, m.priority]
+    );
+    if (res.rows[0].is_insert) inserted++; else updated++;
+  }
+
+  for (const m of AZ_MARKETS) {
+    const nn = normalizeName(m.metro);
+    const res = await client.query(
+      `INSERT INTO markets (state, region, metro, normalized_name, priority)
+       VALUES ($1, $2, $3, $4, $5)
+       ON CONFLICT (normalized_name)
+       DO UPDATE SET state=$1, region=$2, metro=$3, priority=$5
+       RETURNING (xmax = 0) AS is_insert`,
+      ['AZ', m.region, m.metro, nn, m.priority]
+    );
+    if (res.rows[0].is_insert) inserted++; else updated++;
+  }
+
+  return { inserted, updated };
+}
+
+module.exports = { seedMarkets };
diff --git a/db/seed/organizations.js b/db/seed/organizations.js
new file mode 100644
index 0000000..a4b6c1a
--- /dev/null
+++ b/db/seed/organizations.js
@@ -0,0 +1,711 @@
+'use strict';
+/**
+ * Seed organizations, evidence_records, ad_sightings, events, and
+ * event_relationships.
+ *
+ * Hard rules (spec §6.16, §6.17):
+ *  - VERIFIED_ADVERTISER status only for companies with direct RENTV ad/email evidence.
+ *  - VERIFIED_CONTENT_PARTNER for CoStar (conference presentation — NOT a paid sponsor).
+ *  - SPEAKER_OR_PANELIST_ONLY or LIKELY_PROSPECT for panelists.
+ *  - Never promote panelist to advertiser/sponsor without separate sponsor evidence.
+ *
+ * Idempotent via ON CONFLICT on (normalized_name, coalesce(domain,'')) for orgs,
+ * and unique name+type checks for events.
+ */
+
+const { normalizeName, ADVERTISER_CATEGORIES } = require('../../lib/types');
+
+// ---- 5 VERIFIED_ADVERTISER organizations (§21) ------------------------------
+
+const VERIFIED_ADVERTISERS = [
+  {
+    display_name: 'Hanley Investment Group',
+    domain: 'hanleyinvestment.com',
+    advertiser_categories: ['Brokerage and investment sales'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Corona del Mar',
+    description: 'Commercial real estate investment advisory and brokerage firm specializing in retail investment properties.',
+    activity: 'RENTV Property Spotlight / dedicated property marketing email',
+    observed_at: '2026-07-07',
+    confidence: 0.99,
+    requires_admin_confirm: true,
+    evidence_type: 'EMAIL',
+    evidence_tag: 'AUTHORIZED_INTERNAL_EMAIL',
+    evidence_source_owner: 'RENTV authorized mailbox',
+    evidence_title: 'RENTV Property Spotlight email — Hanley Investment Group property marketing',
+    evidence_excerpt: 'Dedicated property marketing email observed in RENTV authorized mailbox. Requires admin confirmation before public exposure.',
+    market_normalized: 'statewide california',
+  },
+  {
+    display_name: 'Chase Partners',
+    domain: 'chasepartners.com',
+    advertiser_categories: ['Brokerage and investment sales'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Los Angeles',
+    description: 'Commercial real estate advisory and brokerage firm based in Southern California.',
+    activity: 'Commercial Real Estate Talk sponsor',
+    observed_at: '2026-06-29',
+    confidence: 0.99,
+    requires_admin_confirm: true,
+    evidence_type: 'EMAIL',
+    evidence_tag: 'AUTHORIZED_INTERNAL_EMAIL',
+    evidence_source_owner: 'RENTV authorized mailbox',
+    evidence_title: 'RENTV CRE Talk sponsor listing — Chase Partners',
+    evidence_excerpt: 'CRE Talk sponsorship observed in RENTV authorized mailbox. Requires admin confirmation before public exposure.',
+    market_normalized: 'statewide california',
+  },
+  {
+    display_name: 'Fidelity Mortgage Lenders',
+    domain: 'fidelityml.com',
+    advertiser_categories: ['Debt fund, mortgage bank, private lender, and capital advisor'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Los Angeles',
+    description: 'Commercial mortgage lender serving the California CRE market.',
+    activity: 'Commercial Real Estate Talk sponsor',
+    observed_at: '2026-06-29',
+    confidence: 0.99,
+    requires_admin_confirm: true,
+    evidence_type: 'EMAIL',
+    evidence_tag: 'AUTHORIZED_INTERNAL_EMAIL',
+    evidence_source_owner: 'RENTV authorized mailbox',
+    evidence_title: 'RENTV CRE Talk sponsor listing — Fidelity Mortgage Lenders',
+    evidence_excerpt: 'CRE Talk sponsorship observed in RENTV authorized mailbox. Requires admin confirmation before public exposure.',
+    market_normalized: 'statewide california',
+  },
+  {
+    display_name: 'Rockefeller Group',
+    domain: 'rockgroup.com',
+    advertiser_categories: ['Developer, owner, investor, REIT, and family office'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Los Angeles',
+    description: 'National real estate developer and investor with California CRE presence.',
+    activity: 'Commercial Real Estate Talk sponsor',
+    observed_at: '2026-06-29',
+    confidence: 0.99,
+    requires_admin_confirm: true,
+    evidence_type: 'EMAIL',
+    evidence_tag: 'AUTHORIZED_INTERNAL_EMAIL',
+    evidence_source_owner: 'RENTV authorized mailbox',
+    evidence_title: 'RENTV CRE Talk sponsor listing — Rockefeller Group',
+    evidence_excerpt: 'CRE Talk sponsorship observed in RENTV authorized mailbox. Requires admin confirmation before public exposure.',
+    market_normalized: 'statewide california',
+  },
+  {
+    display_name: 'Provident Savings Bank',
+    domain: 'providentbank.com',
+    advertiser_categories: ['Commercial bank and credit union'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Riverside',
+    description: 'California community bank serving the CRE lending market.',
+    activity: 'Commercial Real Estate Talk sponsor',
+    observed_at: '2026-06-29',
+    confidence: 0.99,
+    requires_admin_confirm: true,
+    evidence_type: 'EMAIL',
+    evidence_tag: 'AUTHORIZED_INTERNAL_EMAIL',
+    evidence_source_owner: 'RENTV authorized mailbox',
+    evidence_title: 'RENTV CRE Talk sponsor listing — Provident Savings Bank',
+    evidence_excerpt: 'CRE Talk sponsorship observed in RENTV authorized mailbox. Requires admin confirmation before public exposure.',
+    market_normalized: 'statewide california',
+  },
+];
+
+// ---- CoStar Group — VERIFIED_CONTENT_PARTNER (NOT a sponsor) ----------------
+// §21: "Do not label as a paid sponsor without separate sponsor evidence."
+
+const COSTAR = {
+  display_name: 'CoStar Group',
+  domain: 'costar.com',
+  advertiser_categories: ['Proptech, data, software, AI, and marketplace'],
+  headquarters_state: 'CA',
+  headquarters_city: 'Los Angeles',
+  description:
+    'CoStar Group is a leading provider of commercial real estate data, analytics, and marketplace platforms. ' +
+    'WARNING: CoStar presented a Greater Los Angeles market report at a RENTV conference on 2026-03-26 — this is a ' +
+    'content/presentation relationship ONLY. Do not label as a paid sponsor without separate sponsor evidence.',
+  market_normalized: 'greater los angeles',
+  observed_at: '2026-03-26',
+  confidence: 0.98,
+  event_relationship_status: 'VERIFIED_CONTENT_PARTNER',
+  event_activity: 'Greater Los Angeles market report presentation at a RENTV conference',
+};
+
+// ---- 22 panelist/prospect orgs (§21) ----------------------------------------
+// Brokerages/well-known national firms → LIKELY_PROSPECT (RENTV sales opportunity).
+// Others without clear commercial advertising relationship → SPEAKER_OR_PANELIST_ONLY.
+
+const PANELISTS = [
+  {
+    display_name: 'Wonderful Real Estate Development',
+    domain: 'wonderful.com',
+    advertiser_categories: ['Developer, owner, investor, REIT, and family office'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Los Angeles',
+    relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+  },
+  {
+    display_name: 'CBRE',
+    domain: 'cbre.com',
+    advertiser_categories: ['Brokerage and investment sales', 'Leasing and tenant representation'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Los Angeles',
+    relationship_status: 'LIKELY_PROSPECT',
+  },
+  {
+    display_name: 'Trammell Crow Company',
+    domain: 'trammellcrow.com',
+    advertiser_categories: ['Developer, owner, investor, REIT, and family office'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Los Angeles',
+    relationship_status: 'LIKELY_PROSPECT',
+  },
+  {
+    display_name: 'Rexford Industrial',
+    domain: 'rexfordindustrial.com',
+    advertiser_categories: ['Developer, owner, investor, REIT, and family office'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Los Angeles',
+    relationship_status: 'LIKELY_PROSPECT',
+  },
+  {
+    display_name: 'NAI Capital Commercial',
+    domain: 'naicapital.com',
+    advertiser_categories: ['Brokerage and investment sales'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Encino',
+    relationship_status: 'LIKELY_PROSPECT',
+  },
+  {
+    display_name: 'Premier Workspaces',
+    domain: 'premierworkspaces.com',
+    advertiser_categories: ['Coworking, flexible office, and business services'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Irvine',
+    relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+  },
+  {
+    display_name: 'Cushman & Wakefield',
+    domain: 'cushmanwakefield.com',
+    advertiser_categories: ['Brokerage and investment sales', 'Leasing and tenant representation'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Los Angeles',
+    relationship_status: 'LIKELY_PROSPECT',
+  },
+  {
+    display_name: 'Nikols Mortgage Fund',
+    domain: null,
+    advertiser_categories: ['Debt fund, mortgage bank, private lender, and capital advisor'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Los Angeles',
+    relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+  },
+  {
+    display_name: 'Western Alliance Bank',
+    domain: 'westernalliancebank.com',
+    advertiser_categories: ['Commercial bank and credit union'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Phoenix',
+    relationship_status: 'LIKELY_PROSPECT',
+  },
+  {
+    display_name: 'Commonwealth Land Title Company',
+    domain: 'cltic.com',
+    advertiser_categories: ['Title, escrow, settlement, and 1031 exchange'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Los Angeles',
+    relationship_status: 'LIKELY_PROSPECT',
+  },
+  {
+    display_name: 'George Smith Partners',
+    domain: 'gspartners.com',
+    advertiser_categories: ['Debt fund, mortgage bank, private lender, and capital advisor'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Los Angeles',
+    relationship_status: 'LIKELY_PROSPECT',
+  },
+  {
+    display_name: 'Paragon Commercial Group',
+    domain: null,
+    advertiser_categories: ['Developer, owner, investor, REIT, and family office'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Los Angeles',
+    relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+  },
+  {
+    display_name: 'IPA',
+    domain: 'ipausa.com',
+    advertiser_categories: ['Brokerage and investment sales'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Irvine',
+    relationship_status: 'LIKELY_PROSPECT',
+  },
+  {
+    display_name: 'The Festival Companies',
+    domain: null,
+    advertiser_categories: ['Developer, owner, investor, REIT, and family office'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Los Angeles',
+    relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+  },
+  {
+    display_name: 'Westside Retail',
+    domain: null,
+    advertiser_categories: ['Leasing and tenant representation'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Los Angeles',
+    relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+  },
+  {
+    display_name: 'LaTerra Development',
+    domain: 'laterradevelopment.com',
+    advertiser_categories: ['Developer, owner, investor, REIT, and family office'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Brentwood',
+    relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+  },
+  {
+    display_name: 'Cypress Equity Investments',
+    domain: null,
+    advertiser_categories: ['Developer, owner, investor, REIT, and family office'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Santa Monica',
+    relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+  },
+  {
+    display_name: 'Colliers',
+    domain: 'colliers.com',
+    advertiser_categories: ['Brokerage and investment sales', 'Leasing and tenant representation'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Los Angeles',
+    relationship_status: 'LIKELY_PROSPECT',
+  },
+  {
+    display_name: 'Walker Realty Capital',
+    domain: null,
+    advertiser_categories: ['Debt fund, mortgage bank, private lender, and capital advisor'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Los Angeles',
+    relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+  },
+  {
+    display_name: 'The Zacuto Group',
+    domain: null,
+    advertiser_categories: ['Brokerage and investment sales'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Los Angeles',
+    relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+  },
+  {
+    display_name: 'Eve Capital',
+    domain: null,
+    advertiser_categories: ['Developer, owner, investor, REIT, and family office'],
+    headquarters_state: 'CA',
+    headquarters_city: 'Los Angeles',
+    relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+  },
+  {
+    display_name: 'Lyon Stahl Investment Real Estate',
+    domain: 'lyonstahl.com',
+    advertiser_categories: ['Brokerage and investment sales'],
+    headquarters_state: 'CA',
+    headquarters_city: 'El Segundo',
+    relationship_status: 'LIKELY_PROSPECT',
+  },
+];
+
+// --------------------------------------------------------------------------
+// Helper: upsert one org; return {id, wasInserted}
+// --------------------------------------------------------------------------
+async function upsertOrg(client, data) {
+  const nn = normalizeName(data.display_name);
+  const domain = data.domain || null;
+
+  const res = await client.query(
+    `INSERT INTO organizations (
+       display_name, normalized_name, domain,
+       advertiser_categories, description,
+       headquarters_state, headquarters_city,
+       active_status, first_seen_at, last_seen_at
+     ) VALUES ($1,$2,$3,$4,$5,$6,$7,'ACTIVE',now(),now())
+     ON CONFLICT (normalized_name, coalesce(domain,''))
+     DO UPDATE SET
+       display_name=EXCLUDED.display_name,
+       advertiser_categories=EXCLUDED.advertiser_categories,
+       description=EXCLUDED.description,
+       headquarters_state=EXCLUDED.headquarters_state,
+       headquarters_city=EXCLUDED.headquarters_city,
+       last_seen_at=now()
+     RETURNING id, (xmax = 0) AS is_insert`,
+    [
+      data.display_name,
+      nn,
+      domain,
+      JSON.stringify(data.advertiser_categories || []),
+      data.description || null,
+      data.headquarters_state || null,
+      data.headquarters_city || null,
+    ]
+  );
+  return { id: res.rows[0].id, wasInserted: res.rows[0].is_insert };
+}
+
+// Helper: upsert evidence_record; returns id
+async function upsertEvidence(client, data) {
+  // evidence_records has no unique constraint — check by source_title + evidence_type + observed_at to avoid dupes
+  const existing = await client.query(
+    `SELECT id FROM evidence_records WHERE source_title=$1 AND evidence_type=$2 AND observed_at=$3 LIMIT 1`,
+    [data.source_title, data.evidence_type, data.observed_at]
+  );
+  if (existing.rows.length > 0) return { id: existing.rows[0].id, wasInserted: false };
+
+  const res = await client.query(
+    `INSERT INTO evidence_records (
+       evidence_type, source_url, source_title, source_owner,
+       observed_at, retrieved_at, excerpt, confidence, export_allowed
+     ) VALUES ($1,$2,$3,$4,$5,now(),$6,$7,$8)
+     RETURNING id`,
+    [
+      data.evidence_type,
+      data.source_url || null,
+      data.source_title,
+      data.source_owner || null,
+      data.observed_at || null,
+      data.excerpt || null,
+      data.confidence || 0.5,
+      data.export_allowed || false,
+    ]
+  );
+  return { id: res.rows[0].id, wasInserted: true };
+}
+
+// Helper: upsert ad_sighting (idempotent by org+relationship_status+observed_at)
+async function upsertAdSighting(client, { org_id, relationship_status, observed_at, evidence_id, market_id, confidence, headline }) {
+  const existing = await client.query(
+    `SELECT id FROM ad_sightings WHERE organization_id=$1 AND relationship_status=$2 AND observed_at=$3 LIMIT 1`,
+    [org_id, relationship_status, observed_at]
+  );
+  if (existing.rows.length > 0) return { id: existing.rows[0].id, wasInserted: false };
+
+  const res = await client.query(
+    `INSERT INTO ad_sightings (
+       organization_id, relationship_status, observed_at,
+       first_observed_at, last_observed_at,
+       evidence_id, market_id, confidence,
+       verification_status, headline
+     ) VALUES ($1,$2,$3,$3,$3,$4,$5,$6,'UNVERIFIED',$7)
+     RETURNING id`,
+    [org_id, relationship_status, observed_at, evidence_id, market_id || null, confidence || 0.5, headline || null]
+  );
+  return { id: res.rows[0].id, wasInserted: true };
+}
+
+// Helper: upsert event by name+state+start_date
+async function upsertEvent(client, data) {
+  const existing = await client.query(
+    `SELECT id FROM events WHERE name=$1 AND state=$2 AND start_date=$3 LIMIT 1`,
+    [data.name, data.state, data.start_date]
+  );
+  if (existing.rows.length > 0) return { id: existing.rows[0].id, wasInserted: false };
+
+  const res = await client.query(
+    `INSERT INTO events (
+       name, event_type, start_date, end_date, city, state, official_url, source_evidence_id
+     ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
+     RETURNING id`,
+    [
+      data.name,
+      data.event_type || 'CONFERENCE',
+      data.start_date,
+      data.end_date || data.start_date,
+      data.city || null,
+      data.state || null,
+      data.official_url || null,
+      data.source_evidence_id || null,
+    ]
+  );
+  return { id: res.rows[0].id, wasInserted: true };
+}
+
+// Helper: upsert event_relationship (idempotent by event+org+relationship_status)
+async function upsertEventRelationship(client, { event_id, org_id, relationship_status, session_title, observed_at, confidence, evidence_id }) {
+  const existing = await client.query(
+    `SELECT id FROM event_relationships WHERE event_id=$1 AND organization_id=$2 AND relationship_status=$3 LIMIT 1`,
+    [event_id, org_id, relationship_status]
+  );
+  if (existing.rows.length > 0) return { id: existing.rows[0].id, wasInserted: false };
+
+  const res = await client.query(
+    `INSERT INTO event_relationships (
+       event_id, organization_id, relationship_status,
+       session_title, observed_at, confidence, evidence_id
+     ) VALUES ($1,$2,$3,$4,$5,$6,$7)
+     RETURNING id`,
+    [event_id, org_id, relationship_status, session_title || null, observed_at || null, confidence || 0.5, evidence_id || null]
+  );
+  return { id: res.rows[0].id, wasInserted: true };
+}
+
+// --------------------------------------------------------------------------
+
+async function seedOrganizations(client) {
+  const counts = {
+    orgs_inserted: 0,
+    orgs_updated: 0,
+    evidence_inserted: 0,
+    ad_sightings_inserted: 0,
+    event_relationships_inserted: 0,
+    events_inserted: 0,
+  };
+
+  // Look up CA statewide and Greater LA market IDs
+  const caMarket = await client.query(
+    `SELECT id FROM markets WHERE normalized_name=$1 LIMIT 1`,
+    ['statewide california']
+  );
+  const laMarket = await client.query(
+    `SELECT id FROM markets WHERE normalized_name LIKE '%los angeles%' AND state='CA' LIMIT 1`
+  );
+  const caMarketId = caMarket.rows[0]?.id || null;
+  const laMarketId = laMarket.rows[0]?.id || null;
+
+  // ---- RENTV as organizer org (needed for events) ----
+  const rentvOrg = await upsertOrg(client, {
+    display_name: 'RENTV',
+    domain: 'rentv.com',
+    advertiser_categories: ['Association, conference, publication, and media company'],
+    description: 'RENTV — leading commercial real estate media company serving California and Arizona.',
+    headquarters_state: 'CA',
+    headquarters_city: 'Los Angeles',
+  });
+  if (rentvOrg.wasInserted) counts.orgs_inserted++; else counts.orgs_updated++;
+
+  // ---- Seed VERIFIED_ADVERTISER orgs ----
+  for (const va of VERIFIED_ADVERTISERS) {
+    const { id: orgId, wasInserted } = await upsertOrg(client, va);
+    if (wasInserted) counts.orgs_inserted++; else counts.orgs_updated++;
+
+    // evidence_record
+    const { id: evidenceId, wasInserted: evInserted } = await upsertEvidence(client, {
+      evidence_type: va.evidence_type,
+      source_title: va.evidence_title,
+      source_owner: va.evidence_source_owner,
+      observed_at: va.observed_at,
+      excerpt: va.evidence_excerpt,
+      confidence: va.confidence,
+      export_allowed: false, // AUTHORIZED_INTERNAL_EMAIL — admin must confirm before export
+    });
+    if (evInserted) counts.evidence_inserted++;
+
+    // Look up market id by normalized_name
+    const mktRes = await client.query(
+      `SELECT id FROM markets WHERE normalized_name=$1 LIMIT 1`,
+      [va.market_normalized]
+    );
+    const marketId = mktRes.rows[0]?.id || caMarketId;
+
+    // ad_sighting with VERIFIED_ADVERTISER status, verification_status=UNVERIFIED (admin confirm needed)
+    const { wasInserted: adInserted } = await upsertAdSighting(client, {
+      org_id: orgId,
+      relationship_status: 'VERIFIED_ADVERTISER',
+      observed_at: va.observed_at,
+      evidence_id: evidenceId,
+      market_id: marketId,
+      confidence: va.confidence,
+      headline: va.activity,
+    });
+    if (adInserted) counts.ad_sightings_inserted++;
+
+    // organization_markets link
+    await client.query(
+      `INSERT INTO organization_markets (organization_id, market_id, relationship_type, confidence, evidence_id)
+       VALUES ($1,$2,'OPERATES_IN',$3,$4)
+       ON CONFLICT (organization_id, market_id, relationship_type) DO NOTHING`,
+      [orgId, marketId, va.confidence, evidenceId]
+    );
+
+    // Tag: AUTHORIZED_INTERNAL_EMAIL — stored as a tag for admin visibility
+    const tagRes = await client.query(
+      `INSERT INTO tags (label) VALUES ($1) ON CONFLICT (label) DO UPDATE SET label=EXCLUDED.label RETURNING id`,
+      [va.evidence_tag]
+    );
+    await client.query(
+      `INSERT INTO organization_tags (organization_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`,
+      [orgId, tagRes.rows[0].id]
+    );
+
+    // Audit log entry noting admin confirm required (idempotent: one per org+action)
+    if (va.requires_admin_confirm) {
+      const alExist = await client.query(
+        `SELECT id FROM audit_logs WHERE action='SEED_REQUIRES_ADMIN_CONFIRM' AND entity_id=$1 LIMIT 1`,
+        [orgId]
+      );
+      if (alExist.rows.length === 0) {
+        await client.query(
+          `INSERT INTO audit_logs (action, entity_table, entity_id, actor, detail)
+           VALUES ('SEED_REQUIRES_ADMIN_CONFIRM','organizations',$1,'seed',
+             $2::jsonb)`,
+          [
+            orgId,
+            JSON.stringify({
+              note: 'Record seeded from AUTHORIZED_INTERNAL_EMAIL evidence. Admin must confirm before public exposure.',
+              tag: va.evidence_tag,
+              observed_at: va.observed_at,
+            }),
+          ]
+        );
+      }
+    }
+  }
+
+  // ---- CoStar Group — VERIFIED_CONTENT_PARTNER ----
+  const { id: costarId, wasInserted: costarInserted } = await upsertOrg(client, COSTAR);
+  if (costarInserted) counts.orgs_inserted++; else counts.orgs_updated++;
+
+  // evidence for CoStar conference presentation
+  const { id: costarEvidenceId, wasInserted: costarEvInserted } = await upsertEvidence(client, {
+    evidence_type: 'MANUAL_NOTE',
+    source_title: 'RENTV conference — CoStar Group Greater Los Angeles market report presentation',
+    source_owner: 'RENTV',
+    observed_at: COSTAR.observed_at,
+    excerpt:
+      'CoStar Group presented a Greater Los Angeles market report at a RENTV conference on ' +
+      COSTAR.observed_at +
+      '. This is a content/research presentation relationship. ' +
+      COSTAR.description.split('WARNING:')[1]?.trim() || '',
+    confidence: COSTAR.confidence,
+    export_allowed: true,
+  });
+  if (costarEvInserted) counts.evidence_inserted++;
+
+  // Seed the RENTV conference event (for CoStar + panelists)
+  const { id: conferenceEventId, wasInserted: confInserted } = await upsertEvent(client, {
+    name: 'RENTV Greater Los Angeles CRE Conference 2026',
+    event_type: 'CONFERENCE',
+    start_date: '2026-03-26',
+    end_date: '2026-03-26',
+    city: 'Los Angeles',
+    state: 'CA',
+    official_url: 'https://www.rentv.com',
+    source_evidence_id: costarEvidenceId,
+  });
+  if (confInserted) counts.events_inserted++;
+
+  // CoStar event_relationship = VERIFIED_CONTENT_PARTNER (NOT SPONSOR)
+  const { wasInserted: costarRelInserted } = await upsertEventRelationship(client, {
+    event_id: conferenceEventId,
+    org_id: costarId,
+    relationship_status: 'VERIFIED_CONTENT_PARTNER',
+    session_title: 'Greater Los Angeles market report presentation',
+    observed_at: COSTAR.observed_at,
+    confidence: COSTAR.confidence,
+    evidence_id: costarEvidenceId,
+  });
+  if (costarRelInserted) counts.event_relationships_inserted++;
+
+  // Link CoStar to LA market
+  if (laMarketId) {
+    await client.query(
+      `INSERT INTO organization_markets (organization_id, market_id, relationship_type, confidence, evidence_id)
+       VALUES ($1,$2,'OPERATES_IN',$3,$4)
+       ON CONFLICT (organization_id, market_id, relationship_type) DO NOTHING`,
+      [costarId, laMarketId, COSTAR.confidence, costarEvidenceId]
+    );
+  }
+
+  // Audit log: explicit warning for CoStar (idempotent)
+  const costarAlExist = await client.query(
+    `SELECT id FROM audit_logs WHERE action='SEED_CONTENT_PARTNER_WARNING' AND entity_id=$1 LIMIT 1`,
+    [costarId]
+  );
+  if (costarAlExist.rows.length === 0) {
+  await client.query(
+    `INSERT INTO audit_logs (action, entity_table, entity_id, actor, detail)
+     VALUES ('SEED_CONTENT_PARTNER_WARNING','organizations',$1,'seed',$2::jsonb)`,
+    [
+      costarId,
+      JSON.stringify({
+        warning:
+          'CoStar Group is a VERIFIED_CONTENT_PARTNER (conference presenter), NOT a paid sponsor. ' +
+          'Do not label as a paid sponsor without separate sponsor evidence.',
+        observed_at: COSTAR.observed_at,
+        event: 'RENTV Greater Los Angeles CRE Conference 2026',
+      }),
+    ]
+  );
+  } // end idempotent CoStar audit log
+
+  // ---- Seed a second RENTV CRE Talk event for the CRE Talk sponsors ----
+  const creTalkEvidenceId = await (async () => {
+    const { id } = await upsertEvidence(client, {
+      evidence_type: 'EMAIL',
+      source_title: 'RENTV CRE Talk — sponsor listing email 2026-06-29',
+      source_owner: 'RENTV authorized mailbox',
+      observed_at: '2026-06-29',
+      excerpt:
+        'RENTV CRE Talk sponsors observed in authorized RENTV mailbox on 2026-06-29. ' +
+        'Requires admin confirmation before public exposure.',
+      confidence: 0.99,
+      export_allowed: false,
+    });
+    return id;
+  })();
+
+  const { id: creTalkEventId, wasInserted: cteInserted } = await upsertEvent(client, {
+    name: 'RENTV Commercial Real Estate Talk 2026-06-29',
+    event_type: 'CONFERENCE',
+    start_date: '2026-06-29',
+    end_date: '2026-06-29',
+    city: 'Los Angeles',
+    state: 'CA',
+    official_url: 'https://www.rentv.com',
+    source_evidence_id: creTalkEvidenceId,
+  });
+  if (cteInserted) counts.events_inserted++;
+
+  // ---- Seed 22 panelist orgs ----
+  const panelEvidenceId = await (async () => {
+    const { id } = await upsertEvidence(client, {
+      evidence_type: 'MANUAL_NOTE',
+      source_title: 'RENTV conference panelist roster — seeded from spec §21',
+      source_owner: 'RENTV',
+      observed_at: '2026-03-26',
+      excerpt:
+        'Companies listed as conference participants (panelists/speakers) in RENTV conference records. ' +
+        'SPEAKER_OR_PANELIST_ONLY status — no sponsorship claimed without separate evidence.',
+      confidence: 0.85,
+      export_allowed: true,
+    });
+    return id;
+  })();
+
+  for (const p of PANELISTS) {
+    const { id: orgId, wasInserted } = await upsertOrg(client, p);
+    if (wasInserted) counts.orgs_inserted++; else counts.orgs_updated++;
+
+    // event_relationship (panelist role) — do NOT use ad_sightings with VERIFIED_* status
+    const { wasInserted: relInserted } = await upsertEventRelationship(client, {
+      event_id: conferenceEventId,
+      org_id: orgId,
+      relationship_status: p.relationship_status,
+      session_title: null,
+      observed_at: '2026-03-26',
+      confidence: 0.85,
+      evidence_id: panelEvidenceId,
+    });
+    if (relInserted) counts.event_relationships_inserted++;
+
+    // link to CA statewide market
+    if (caMarketId) {
+      await client.query(
+        `INSERT INTO organization_markets (organization_id, market_id, relationship_type, confidence)
+         VALUES ($1,$2,'OPERATES_IN',0.85)
+         ON CONFLICT (organization_id, market_id, relationship_type) DO NOTHING`,
+        [orgId, caMarketId]
+      );
+    }
+  }
+
+  return counts;
+}
+
+module.exports = { seedOrganizations };
diff --git a/db/seed/rentv-products.js b/db/seed/rentv-products.js
new file mode 100644
index 0000000..a0238a2
--- /dev/null
+++ b/db/seed/rentv-products.js
@@ -0,0 +1,216 @@
+'use strict';
+/**
+ * Seed rentv_rate_snapshots and rentv_audience_snapshots.
+ *
+ * Per §21:
+ *  - At least 2 dated rate snapshots per product (rates may differ across dates).
+ *  - Audience: two separate rows — ~45,000 and ~50,000 net recipients — NEVER merged.
+ *  - All rates are placeholders pending official media kit; marked in package_notes.
+ *
+ * Idempotent: checks existing rows by product_key+observed_at before inserting.
+ */
+
+const RATE_PLACEHOLDER_NOTE = 'Rate placeholder pending official RENTV media kit confirmation.';
+
+// Two snapshot dates representing different observed rate cards
+const DATE_A = '2025-04-01';
+const DATE_B = '2026-04-01';
+
+// Each entry: [product_key, product_label, channel/unit, rateA, rateB, notesA, notesB]
+const RATE_PRODUCTS = [
+  {
+    product_key: 'website_banner',
+    product_label: 'Website Banner Ad',
+    unit: 'per month',
+    rateA: 1500.00,
+    rateB: 1750.00,
+    notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+    notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+  },
+  {
+    product_key: 'website_tile',
+    product_label: 'Website Tile Ad',
+    unit: 'per month',
+    rateA: 850.00,
+    rateB: 950.00,
+    notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+    notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+  },
+  {
+    product_key: 'newsletter_banner',
+    product_label: 'Newsletter Banner Ad',
+    unit: 'per eblast',
+    rateA: 1200.00,
+    rateB: 1400.00,
+    notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+    notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+  },
+  {
+    product_key: 'newsletter_tile',
+    product_label: 'Newsletter Tile Ad',
+    unit: 'per eblast',
+    rateA: 650.00,
+    rateB: 750.00,
+    notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+    notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+  },
+  {
+    product_key: 'fixed_advertorial',
+    product_label: 'Fixed Advertorial',
+    unit: 'per placement',
+    rateA: 2500.00,
+    rateB: 3000.00,
+    notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+    notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+  },
+  {
+    product_key: 'marketplace_eblast',
+    product_label: 'Marketplace E-blast',
+    unit: 'per eblast',
+    rateA: 1800.00,
+    rateB: 2000.00,
+    notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+    notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+  },
+  {
+    product_key: 'property_spotlight_eblast',
+    product_label: 'Property Spotlight E-blast',
+    unit: 'per eblast',
+    rateA: 1950.00,   // April 2025 flyer pricing
+    rateB: 2200.00,   // April 2026 pricing (different package/rate observed)
+    notesA: RATE_PLACEHOLDER_NOTE + ' Observed in April 2025 flyer (Property Spotlight Flyer REV April 2025). Package may include dedicated send.',
+    notesB: RATE_PLACEHOLDER_NOTE + ' Observed in April 2026 corporate flyer (Corp Flyer Apr 2026 V3). Different package/price version.',
+  },
+  {
+    product_key: 'video_promotion_eblast',
+    product_label: 'Video Promotion E-blast',
+    unit: 'per eblast',
+    rateA: 2200.00,
+    rateB: 2500.00,
+    notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+    notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+  },
+  {
+    product_key: 'homepage_video_promotion',
+    product_label: 'Home-Page Video Promotion',
+    unit: 'per month',
+    rateA: 3500.00,
+    rateB: 4000.00,
+    notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+    notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+  },
+  {
+    product_key: 'conference_sponsorship',
+    product_label: 'Conference Sponsorship',
+    unit: 'per event',
+    rateA: 5000.00,
+    rateB: 6000.00,
+    notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+    notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+  },
+  {
+    product_key: 'cre_talk_sponsorship',
+    product_label: 'CRE Talk Sponsorship',
+    unit: 'per episode/event',
+    rateA: 2000.00,
+    rateB: 2500.00,
+    notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+    notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+  },
+  {
+    product_key: 'the_review_channel_sponsorship',
+    product_label: 'The REview Channel / Screen Sponsorship',
+    unit: 'per month',
+    rateA: 1500.00,
+    rateB: 1800.00,
+    notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+    notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+  },
+];
+
+// Two separate audience snapshot observations (NEVER merged per §21)
+const AUDIENCE_SNAPSHOTS = [
+  {
+    metric_key: 'net_recipients',
+    metric_label: 'Net email recipients (newsletter)',
+    value_numeric: 45000,
+    observed_at: '2025-04-01',
+    source_key: 'rentv_media_kit',
+    note: 'Approximately 45,000 net recipients as stated in April 2025 media kit materials.',
+  },
+  {
+    metric_key: 'net_recipients',
+    metric_label: 'Net email recipients (newsletter)',
+    value_numeric: 50000,
+    observed_at: '2026-04-01',
+    source_key: 'rentv_media_kit',
+    note: 'Approximately 50,000 net recipients as stated in April 2026 media kit materials. Separate snapshot — not merged with 2025 figure.',
+  },
+];
+
+async function seedRentvProducts(client) {
+  let rate_inserted = 0;
+  let rate_skipped = 0;
+  let audience_inserted = 0;
+  let audience_skipped = 0;
+
+  // Rate snapshots — two per product (DATE_A and DATE_B)
+  for (const p of RATE_PRODUCTS) {
+    // DATE_A snapshot
+    const existA = await client.query(
+      `SELECT id FROM rentv_rate_snapshots WHERE product_key=$1 AND observed_at=$2 LIMIT 1`,
+      [p.product_key, DATE_A]
+    );
+    if (existA.rows.length === 0) {
+      await client.query(
+        `INSERT INTO rentv_rate_snapshots
+           (product_key, product_label, rate, currency, unit, package_notes, observed_at, source_key)
+         VALUES ($1,$2,$3,'USD',$4,$5,$6,'rentv_media_kit')`,
+        [p.product_key, p.product_label, p.rateA, p.unit, p.notesA, DATE_A]
+      );
+      rate_inserted++;
+    } else {
+      rate_skipped++;
+    }
+
+    // DATE_B snapshot (different rate/package)
+    const existB = await client.query(
+      `SELECT id FROM rentv_rate_snapshots WHERE product_key=$1 AND observed_at=$2 LIMIT 1`,
+      [p.product_key, DATE_B]
+    );
+    if (existB.rows.length === 0) {
+      await client.query(
+        `INSERT INTO rentv_rate_snapshots
+           (product_key, product_label, rate, currency, unit, package_notes, observed_at, source_key)
+         VALUES ($1,$2,$3,'USD',$4,$5,$6,'rentv_media_kit')`,
+        [p.product_key, p.product_label, p.rateB, p.unit, p.notesB, DATE_B]
+      );
+      rate_inserted++;
+    } else {
+      rate_skipped++;
+    }
+  }
+
+  // Audience snapshots — two separate rows
+  for (const a of AUDIENCE_SNAPSHOTS) {
+    const existing = await client.query(
+      `SELECT id FROM rentv_audience_snapshots WHERE metric_key=$1 AND observed_at=$2 LIMIT 1`,
+      [a.metric_key, a.observed_at]
+    );
+    if (existing.rows.length === 0) {
+      await client.query(
+        `INSERT INTO rentv_audience_snapshots
+           (metric_key, metric_label, value_numeric, value_text, observed_at, source_key)
+         VALUES ($1,$2,$3,$4,$5,$6)`,
+        [a.metric_key, a.metric_label, a.value_numeric, a.note, a.observed_at, a.source_key]
+      );
+      audience_inserted++;
+    } else {
+      audience_skipped++;
+    }
+  }
+
+  return { rate_inserted, rate_skipped, audience_inserted, audience_skipped };
+}
+
+module.exports = { seedRentvProducts };
diff --git a/db/seed/scores.js b/db/seed/scores.js
new file mode 100644
index 0000000..9eff847
--- /dev/null
+++ b/db/seed/scores.js
@@ -0,0 +1,317 @@
+'use strict';
+/**
+ * Seed opportunity_scores and opportunity_stages.
+ *
+ * For every seeded org, compute a score via scoring.explainScore() with
+ * sensible per-org factors and INSERT into opportunity_scores.
+ * Also seeds opportunity_stages (Prospect/Contacted/Proposal Sent/Won/Lost).
+ *
+ * Idempotent: replaces scores on re-run (delete+reinsert per org), stages via
+ * ON CONFLICT (key) DO UPDATE.
+ */
+
+const { explainScore } = require('../../lib/scoring');
+const { normalizeName } = require('../../lib/types');
+
+// Opportunity stages (§deliverable)
+const STAGES = [
+  { key: 'prospect',       label: 'Prospect',       sort_order: 1 },
+  { key: 'contacted',      label: 'Contacted',       sort_order: 2 },
+  { key: 'proposal_sent',  label: 'Proposal Sent',   sort_order: 3 },
+  { key: 'won',            label: 'Won',              sort_order: 4 },
+  { key: 'lost',           label: 'Lost',             sort_order: 5 },
+];
+
+/**
+ * Factor inputs per org name (normalized_name key → input object).
+ * Verified advertisers get high verifiedAdvertising + recency + evidenceQuality.
+ * Panelists get moderate categoryFit + californiaFit but low verifiedAdvertising.
+ * LIKELY_PROSPECT get higher scores than SPEAKER_OR_PANELIST_ONLY.
+ */
+const ORG_FACTOR_INPUTS = {
+  // ---- VERIFIED_ADVERTISER (high verified advertising, high recency) --------
+  'hanley investment': {
+    verifiedAdvertising: 95,
+    verifiedConferenceSpendSignal: 60,
+    recency: 98,      // observed 2026-07-07, very recent
+    repeatActivity: 70,
+    californiaFit: 100,
+    arizonaFit: 20,
+    categoryFit: 90,
+    rentvAudienceFit: 90,
+    contactCompleteness: 55,
+    evidenceQuality: 95,
+  },
+  'chase partners': {
+    verifiedAdvertising: 95,
+    verifiedConferenceSpendSignal: 90,
+    recency: 96,      // observed 2026-06-29
+    repeatActivity: 70,
+    californiaFit: 100,
+    arizonaFit: 20,
+    categoryFit: 90,
+    rentvAudienceFit: 88,
+    contactCompleteness: 50,
+    evidenceQuality: 95,
+  },
+  'fidelity mortgage lenders': {
+    verifiedAdvertising: 95,
+    verifiedConferenceSpendSignal: 90,
+    recency: 96,
+    repeatActivity: 70,
+    californiaFit: 100,
+    arizonaFit: 20,
+    categoryFit: 88,
+    rentvAudienceFit: 88,
+    contactCompleteness: 50,
+    evidenceQuality: 95,
+  },
+  'rockefeller': {
+    verifiedAdvertising: 95,
+    verifiedConferenceSpendSignal: 90,
+    recency: 96,
+    repeatActivity: 70,
+    californiaFit: 90,
+    arizonaFit: 30,
+    categoryFit: 85,
+    rentvAudienceFit: 85,
+    contactCompleteness: 50,
+    evidenceQuality: 95,
+  },
+  'provident savings bank': {
+    verifiedAdvertising: 95,
+    verifiedConferenceSpendSignal: 90,
+    recency: 96,
+    repeatActivity: 65,
+    californiaFit: 100,
+    arizonaFit: 10,
+    categoryFit: 87,
+    rentvAudienceFit: 87,
+    contactCompleteness: 50,
+    evidenceQuality: 95,
+  },
+  // ---- VERIFIED_CONTENT_PARTNER (not a paid sponsor) -----------------------
+  'costar': {
+    verifiedAdvertising: 20,   // content partner only, NOT paid advertiser
+    verifiedConferenceSpendSignal: 70,
+    recency: 90,
+    repeatActivity: 60,
+    californiaFit: 95,
+    arizonaFit: 70,
+    categoryFit: 80,
+    rentvAudienceFit: 80,
+    contactCompleteness: 40,
+    evidenceQuality: 90,
+  },
+  // ---- LIKELY_PROSPECT orgs ------------------------------------------------
+  'cbre': {
+    verifiedAdvertising: 0,
+    verifiedConferenceSpendSignal: 50,
+    recency: 75,
+    repeatActivity: 40,
+    californiaFit: 98,
+    arizonaFit: 80,
+    categoryFit: 95,
+    rentvAudienceFit: 95,
+    contactCompleteness: 35,
+    evidenceQuality: 60,
+  },
+  'trammell crow': {
+    verifiedAdvertising: 0,
+    verifiedConferenceSpendSignal: 50,
+    recency: 70,
+    repeatActivity: 35,
+    californiaFit: 90,
+    arizonaFit: 70,
+    categoryFit: 90,
+    rentvAudienceFit: 88,
+    contactCompleteness: 30,
+    evidenceQuality: 55,
+  },
+  'rexford industrial': {
+    verifiedAdvertising: 0,
+    verifiedConferenceSpendSignal: 45,
+    recency: 70,
+    repeatActivity: 30,
+    californiaFit: 98,
+    arizonaFit: 20,
+    categoryFit: 85,
+    rentvAudienceFit: 85,
+    contactCompleteness: 30,
+    evidenceQuality: 55,
+  },
+  'nai capital': {
+    verifiedAdvertising: 0,
+    verifiedConferenceSpendSignal: 45,
+    recency: 70,
+    repeatActivity: 30,
+    californiaFit: 98,
+    arizonaFit: 20,
+    categoryFit: 90,
+    rentvAudienceFit: 90,
+    contactCompleteness: 30,
+    evidenceQuality: 55,
+  },
+  'cushman wakefield': {
+    verifiedAdvertising: 0,
+    verifiedConferenceSpendSignal: 55,
+    recency: 70,
+    repeatActivity: 40,
+    californiaFit: 95,
+    arizonaFit: 75,
+    categoryFit: 93,
+    rentvAudienceFit: 92,
+    contactCompleteness: 35,
+    evidenceQuality: 60,
+  },
+  'western alliance bank': {
+    verifiedAdvertising: 0,
+    verifiedConferenceSpendSignal: 45,
+    recency: 70,
+    repeatActivity: 30,
+    californiaFit: 80,
+    arizonaFit: 90,
+    categoryFit: 85,
+    rentvAudienceFit: 83,
+    contactCompleteness: 30,
+    evidenceQuality: 55,
+  },
+  'commonwealth land title': {
+    verifiedAdvertising: 0,
+    verifiedConferenceSpendSignal: 40,
+    recency: 65,
+    repeatActivity: 25,
+    californiaFit: 90,
+    arizonaFit: 30,
+    categoryFit: 82,
+    rentvAudienceFit: 82,
+    contactCompleteness: 25,
+    evidenceQuality: 50,
+  },
+  'george smith partners': {
+    verifiedAdvertising: 0,
+    verifiedConferenceSpendSignal: 45,
+    recency: 65,
+    repeatActivity: 30,
+    californiaFit: 95,
+    arizonaFit: 20,
+    categoryFit: 85,
+    rentvAudienceFit: 84,
+    contactCompleteness: 30,
+    evidenceQuality: 50,
+  },
+  'ipa': {
+    verifiedAdvertising: 0,
+    verifiedConferenceSpendSignal: 45,
+    recency: 65,
+    repeatActivity: 30,
+    californiaFit: 90,
+    arizonaFit: 50,
+    categoryFit: 88,
+    rentvAudienceFit: 88,
+    contactCompleteness: 25,
+    evidenceQuality: 50,
+  },
+  'colliers': {
+    verifiedAdvertising: 0,
+    verifiedConferenceSpendSignal: 50,
+    recency: 70,
+    repeatActivity: 35,
+    californiaFit: 95,
+    arizonaFit: 75,
+    categoryFit: 93,
+    rentvAudienceFit: 90,
+    contactCompleteness: 30,
+    evidenceQuality: 55,
+  },
+  'lyon stahl investment real estate': {
+    verifiedAdvertising: 0,
+    verifiedConferenceSpendSignal: 40,
+    recency: 65,
+    repeatActivity: 25,
+    californiaFit: 95,
+    arizonaFit: 10,
+    categoryFit: 85,
+    rentvAudienceFit: 83,
+    contactCompleteness: 25,
+    evidenceQuality: 50,
+  },
+  // ---- SPEAKER_OR_PANELIST_ONLY (lower scores, no verified advertising) ----
+  // Default for any org not specifically listed above
+  '_default_speaker': {
+    verifiedAdvertising: 0,
+    verifiedConferenceSpendSignal: 35,
+    recency: 60,
+    repeatActivity: 20,
+    californiaFit: 80,
+    arizonaFit: 15,
+    categoryFit: 70,
+    rentvAudienceFit: 70,
+    contactCompleteness: 20,
+    evidenceQuality: 45,
+  },
+};
+
+function getFactorInput(normalizedName) {
+  // Try exact match, then prefix match on the normalized name
+  if (ORG_FACTOR_INPUTS[normalizedName]) return ORG_FACTOR_INPUTS[normalizedName];
+  for (const key of Object.keys(ORG_FACTOR_INPUTS)) {
+    if (key === '_default_speaker') continue;
+    if (normalizedName.startsWith(key) || key.startsWith(normalizedName.split(' ')[0])) {
+      return ORG_FACTOR_INPUTS[key];
+    }
+  }
+  return ORG_FACTOR_INPUTS['_default_speaker'];
+}
+
+async function seedScores(client) {
+  let stages_inserted = 0;
+  let stages_updated = 0;
+  let scores_inserted = 0;
+
+  // Seed opportunity_stages
+  for (const s of STAGES) {
+    const res = await client.query(
+      `INSERT INTO opportunity_stages (key, label, sort_order)
+       VALUES ($1,$2,$3)
+       ON CONFLICT (key) DO UPDATE SET label=EXCLUDED.label, sort_order=EXCLUDED.sort_order
+       RETURNING (xmax = 0) AS is_insert`,
+      [s.key, s.label, s.sort_order]
+    );
+    if (res.rows[0].is_insert) stages_inserted++; else stages_updated++;
+  }
+
+  // Fetch all seeded orgs
+  const orgsRes = await client.query(
+    `SELECT id, display_name, normalized_name FROM organizations`
+  );
+
+  for (const org of orgsRes.rows) {
+    // Delete existing score(s) for this org (re-seed is a full replace)
+    await client.query(`DELETE FROM opportunity_scores WHERE organization_id=$1`, [org.id]);
+
+    const factorInput = getFactorInput(org.normalized_name);
+    const explained = explainScore(factorInput);
+
+    await client.query(
+      `INSERT INTO opportunity_scores (organization_id, score, factors, computed_at)
+       VALUES ($1,$2,$3::jsonb,now())`,
+      [
+        org.id,
+        explained.score,
+        JSON.stringify({
+          input: factorInput,
+          weights: explained.weights,
+          factors: explained.factors,
+          score: explained.score,
+          note: 'Computed by seed/scores.js. Factors are initial estimates — update with real evidence.',
+        }),
+      ]
+    );
+    scores_inserted++;
+  }
+
+  return { stages_inserted, stages_updated, scores_inserted };
+}
+
+module.exports = { seedScores };
diff --git a/db/seed/sources.js b/db/seed/sources.js
new file mode 100644
index 0000000..ac3a3a1
--- /dev/null
+++ b/db/seed/sources.js
@@ -0,0 +1,471 @@
+'use strict';
+/**
+ * Seed source_policies — RENTV-owned/authorized sources (enabled=true) plus
+ * CA/AZ publication/association sources requiring manual review (enabled=false).
+ * Idempotent via ON CONFLICT (source_key) DO UPDATE.
+ */
+
+const REVIEWED_AT = '2026-08-07T00:00:00Z';
+
+// ---- RENTV-owned / authorized sources (enabled, automated or upload OK) ----
+const RENTV_SOURCES = [
+  {
+    source_key: 'rentv_public_web',
+    display_name: 'RENTV.com public website',
+    owner: 'RENTV',
+    base_url: 'https://www.rentv.com',
+    access_method: 'first_party_public_web',
+    allows_automated_access: true,
+    allows_screenshot_capture: true,
+    allows_internal_storage: true,
+    allows_export: true,
+    enabled: true,
+    review_notes: 'RENTV-owned property. Full automated access permitted.',
+  },
+  {
+    source_key: 'rentvreview_public_web',
+    display_name: 'RENTVReview.com public website',
+    owner: 'RENTV',
+    base_url: 'https://www.rentvreview.com',
+    access_method: 'first_party_public_web',
+    allows_automated_access: true,
+    allows_screenshot_capture: true,
+    allows_internal_storage: true,
+    allows_export: true,
+    enabled: true,
+    review_notes: 'RENTV-owned property. Full automated access permitted.',
+  },
+  {
+    source_key: 'rentv_conference_pages',
+    display_name: 'RENTV conference and event pages',
+    owner: 'RENTV',
+    base_url: 'https://www.rentv.com',
+    access_method: 'first_party_public_web',
+    allows_automated_access: true,
+    allows_screenshot_capture: true,
+    allows_internal_storage: true,
+    allows_export: true,
+    enabled: true,
+    review_notes: 'RENTV-owned conference pages. Sponsor data may be exported as evidence.',
+  },
+  {
+    source_key: 'rentv_cre_talk_pages',
+    display_name: 'RENTV CRE Talk pages',
+    owner: 'RENTV',
+    base_url: 'https://www.rentv.com',
+    access_method: 'first_party_public_web',
+    allows_automated_access: true,
+    allows_screenshot_capture: true,
+    allows_internal_storage: true,
+    allows_export: true,
+    enabled: true,
+    review_notes: 'RENTV CRE Talk sponsor listings are first-party evidence.',
+  },
+  {
+    source_key: 'rentv_newsletters',
+    display_name: 'RENTV newsletters and browser-view archives',
+    owner: 'RENTV',
+    base_url: 'https://www.rentv.com',
+    access_method: 'first_party_public_web',
+    allows_automated_access: true,
+    allows_screenshot_capture: true,
+    allows_internal_storage: true,
+    allows_export: true,
+    enabled: true,
+    review_notes: 'RENTV-authored newsletters. Sponsor and ad evidence may be retained.',
+  },
+  {
+    source_key: 'rentv_constant_contact_export',
+    display_name: 'RENTV Constant Contact export (CSV upload)',
+    owner: 'RENTV',
+    base_url: 'https://app.constantcontact.com',
+    access_method: 'manual_upload',
+    allows_automated_access: false,
+    allows_screenshot_capture: false,
+    allows_internal_storage: true,
+    allows_export: true,
+    enabled: true,
+    review_notes: 'Manual CSV export from authorized RENTV Constant Contact account. Upload only.',
+  },
+  {
+    source_key: 'rentv_gmail_authorized',
+    display_name: 'RENTV authorized Gmail mailbox (Gmail API)',
+    owner: 'RENTV',
+    base_url: 'https://mail.google.com',
+    access_method: 'authorized_mailbox',
+    allows_automated_access: false,
+    allows_screenshot_capture: false,
+    allows_internal_storage: true,
+    allows_export: false,
+    enabled: false,
+    review_notes: 'Disabled by default. Enable only after OAuth setup and admin review. Store selected messages only.',
+  },
+  {
+    source_key: 'rentv_manual_upload',
+    display_name: 'RENTV manual file upload (.eml, HTML, PDF, JPG, PNG, CSV, XLSX)',
+    owner: 'RENTV',
+    base_url: null,
+    access_method: 'manual_upload',
+    allows_automated_access: false,
+    allows_screenshot_capture: false,
+    allows_internal_storage: true,
+    allows_export: true,
+    enabled: true,
+    review_notes: 'Admin-uploaded files. Rights status per file.',
+  },
+  {
+    source_key: 'rentv_media_kit',
+    display_name: 'RENTV media kit and rate card',
+    owner: 'RENTV',
+    base_url: 'https://www.rentv.com',
+    access_method: 'first_party_public_web',
+    allows_automated_access: true,
+    allows_screenshot_capture: true,
+    allows_internal_storage: true,
+    allows_export: true,
+    enabled: true,
+    review_notes: 'First-party RENTV rate/audience data. Export allowed.',
+  },
+];
+
+// ---- External CA/AZ sources — manual_review_only, automation disabled -------
+const EXTERNAL_SOURCES = [
+  // CA Business Journals
+  {
+    source_key: 'la_business_journal',
+    display_name: 'Los Angeles Business Journal',
+    owner: 'American City Business Journals',
+    base_url: 'https://labusinessjournal.com',
+    access_method: 'manual_review_only',
+    review_notes: 'Paywall/subscr. No automated scraping. Manual review of public event/sponsor pages only.',
+  },
+  {
+    source_key: 'oc_business_journal',
+    display_name: 'Orange County Business Journal',
+    owner: 'Orange County Business Journal',
+    base_url: 'https://www.ocbj.com',
+    access_method: 'manual_review_only',
+    review_notes: 'Paywall. Manual review only.',
+  },
+  {
+    source_key: 'san_diego_business_journal',
+    display_name: 'San Diego Business Journal',
+    owner: 'American City Business Journals',
+    base_url: 'https://www.sdbj.com',
+    access_method: 'manual_review_only',
+    review_notes: 'Paywall. Manual review only.',
+  },
+  {
+    source_key: 'sf_business_times',
+    display_name: 'San Francisco Business Times',
+    owner: 'American City Business Journals',
+    base_url: 'https://www.bizjournals.com/sanfrancisco',
+    access_method: 'manual_review_only',
+    review_notes: 'Paywall. Manual review only.',
+  },
+  {
+    source_key: 'sv_business_journal',
+    display_name: 'Silicon Valley Business Journal',
+    owner: 'American City Business Journals',
+    base_url: 'https://www.bizjournals.com/sanjose',
+    access_method: 'manual_review_only',
+    review_notes: 'Paywall. Manual review only.',
+  },
+  {
+    source_key: 'sacramento_business_journal',
+    display_name: 'Sacramento Business Journal',
+    owner: 'American City Business Journals',
+    base_url: 'https://www.bizjournals.com/sacramento',
+    access_method: 'manual_review_only',
+    review_notes: 'Paywall. Manual review only.',
+  },
+  // CRE publications
+  {
+    source_key: 'bisnow',
+    display_name: 'Bisnow market and event pages',
+    owner: 'Bisnow',
+    base_url: 'https://www.bisnow.com',
+    access_method: 'manual_review_only',
+    review_notes: 'Terms restrict scraping. Manual review of public event/sponsor pages only.',
+  },
+  {
+    source_key: 'connect_cre',
+    display_name: 'Connect CRE',
+    owner: 'Connect Media',
+    base_url: 'https://www.connectcre.com',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only. Robots policy not verified as permitting automation.',
+  },
+  {
+    source_key: 'globest',
+    display_name: 'GlobeSt',
+    owner: 'ALM Media',
+    base_url: 'https://www.globest.com',
+    access_method: 'manual_review_only',
+    review_notes: 'ALM paywall. Manual review only.',
+  },
+  {
+    source_key: 'the_registry',
+    display_name: 'The Registry',
+    owner: 'The Registry Media',
+    base_url: 'https://theregistrysf.com',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  {
+    source_key: 'commercial_observer',
+    display_name: 'Commercial Observer',
+    owner: 'Observer Media',
+    base_url: 'https://commercialobserver.com',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  {
+    source_key: 'the_real_deal_ca',
+    display_name: 'The Real Deal (California)',
+    owner: 'The Real Deal',
+    base_url: 'https://therealdeal.com',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  // Associations — CA
+  {
+    source_key: 'naiop_so_cal',
+    display_name: 'NAIOP Southern California',
+    owner: 'NAIOP SoCal Chapter',
+    base_url: 'https://www.naiopsc.com',
+    access_method: 'manual_review_only',
+    review_notes: 'Public event/sponsor pages only. Manual review.',
+  },
+  {
+    source_key: 'naiop_inland_empire',
+    display_name: 'NAIOP Inland Empire',
+    owner: 'NAIOP IE Chapter',
+    base_url: 'https://www.naiopie.com',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  {
+    source_key: 'naiop_san_diego',
+    display_name: 'NAIOP San Diego',
+    owner: 'NAIOP SD Chapter',
+    base_url: 'https://www.naiopsd.com',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  {
+    source_key: 'naiop_nor_cal',
+    display_name: 'NAIOP Northern California',
+    owner: 'NAIOP NorCal Chapter',
+    base_url: 'https://www.naiopnc.com',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  {
+    source_key: 'uli_los_angeles',
+    display_name: 'ULI Los Angeles',
+    owner: 'Urban Land Institute — LA District',
+    base_url: 'https://la.uli.org',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  {
+    source_key: 'uli_oc_ie',
+    display_name: 'ULI Orange County / Inland Empire',
+    owner: 'Urban Land Institute — OC/IE District',
+    base_url: 'https://ocie.uli.org',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  {
+    source_key: 'uli_san_diego',
+    display_name: 'ULI San Diego / Tijuana',
+    owner: 'Urban Land Institute — SD District',
+    base_url: 'https://sandiego.uli.org',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  {
+    source_key: 'uli_san_francisco',
+    display_name: 'ULI San Francisco',
+    owner: 'Urban Land Institute — SF District',
+    base_url: 'https://sf.uli.org',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  {
+    source_key: 'boma_greater_la',
+    display_name: 'BOMA Greater Los Angeles',
+    owner: 'BOMA Greater LA',
+    base_url: 'https://www.bomagla.org',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  {
+    source_key: 'boma_oc',
+    display_name: 'BOMA Orange County',
+    owner: 'BOMA OC',
+    base_url: 'https://bomaoc.org',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  {
+    source_key: 'boma_san_diego',
+    display_name: 'BOMA San Diego',
+    owner: 'BOMA San Diego',
+    base_url: 'https://bomasd.org',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  {
+    source_key: 'crew_los_angeles',
+    display_name: 'CREW Los Angeles',
+    owner: 'CREW Network — LA Chapter',
+    base_url: 'https://www.crewla.org',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  {
+    source_key: 'crew_orange_county',
+    display_name: 'CREW Orange County',
+    owner: 'CREW Network — OC Chapter',
+    base_url: 'https://www.crewoc.com',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  {
+    source_key: 'crew_san_diego',
+    display_name: 'CREW San Diego',
+    owner: 'CREW Network — SD Chapter',
+    base_url: 'https://www.crewsd.org',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  {
+    source_key: 'crew_san_francisco',
+    display_name: 'CREW San Francisco',
+    owner: 'CREW Network — SF Chapter',
+    base_url: 'https://www.crewsf.org',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  // AZ sources
+  {
+    source_key: 'phoenix_business_journal',
+    display_name: 'Phoenix Business Journal',
+    owner: 'American City Business Journals',
+    base_url: 'https://www.bizjournals.com/phoenix',
+    access_method: 'manual_review_only',
+    review_notes: 'Paywall. Manual review only.',
+  },
+  {
+    source_key: 'az_big_media',
+    display_name: 'AZ Big Media / AZRE',
+    owner: 'AZ Big Media',
+    base_url: 'https://azbigmedia.com',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  {
+    source_key: 'naiop_arizona',
+    display_name: 'NAIOP Arizona',
+    owner: 'NAIOP AZ Chapter',
+    base_url: 'https://www.naiop-az.org',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  {
+    source_key: 'uli_arizona',
+    display_name: 'ULI Arizona',
+    owner: 'Urban Land Institute — AZ District',
+    base_url: 'https://arizona.uli.org',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  {
+    source_key: 'boma_greater_phoenix',
+    display_name: 'BOMA Greater Phoenix',
+    owner: 'BOMA Phoenix',
+    base_url: 'https://www.bomaphoenix.org',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  {
+    source_key: 'valley_partnership',
+    display_name: 'Valley Partnership',
+    owner: 'Valley Partnership',
+    base_url: 'https://www.valleypartnership.org',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+  {
+    source_key: 'azcrew',
+    display_name: 'AZCREW',
+    owner: 'CREW Network — AZ Chapter',
+    base_url: 'https://www.azcrew.org',
+    access_method: 'manual_review_only',
+    review_notes: 'Manual review only.',
+  },
+];
+
+async function seedSources(client) {
+  let inserted = 0;
+  let updated = 0;
+
+  const allSources = [...RENTV_SOURCES, ...EXTERNAL_SOURCES];
+
+  for (const s of allSources) {
+    // external sources default to manual_review_only restrictions
+    const isExternal = !RENTV_SOURCES.includes(s);
+    const allows_automated = isExternal ? false : (s.allows_automated_access ?? false);
+    const allows_screenshot = isExternal ? false : (s.allows_screenshot_capture ?? false);
+    const allows_internal = s.allows_internal_storage ?? true;
+    const allows_export = isExternal ? false : (s.allows_export ?? false);
+    const enabled = isExternal ? false : (s.enabled ?? false);
+
+    const res = await client.query(
+      `INSERT INTO source_policies (
+         source_key, display_name, owner, base_url, access_method,
+         allows_automated_access, allows_screenshot_capture,
+         allows_internal_storage, allows_export,
+         prohibited_hosts, permitted_paths, prohibited_paths,
+         reviewed_at, review_notes, enabled
+       ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
+       ON CONFLICT (source_key) DO UPDATE SET
+         display_name=EXCLUDED.display_name,
+         owner=EXCLUDED.owner,
+         base_url=EXCLUDED.base_url,
+         access_method=EXCLUDED.access_method,
+         allows_automated_access=EXCLUDED.allows_automated_access,
+         allows_screenshot_capture=EXCLUDED.allows_screenshot_capture,
+         allows_internal_storage=EXCLUDED.allows_internal_storage,
+         allows_export=EXCLUDED.allows_export,
+         reviewed_at=EXCLUDED.reviewed_at,
+         review_notes=EXCLUDED.review_notes,
+         enabled=EXCLUDED.enabled
+       RETURNING (xmax = 0) AS is_insert`,
+      [
+        s.source_key,
+        s.display_name,
+        s.owner || null,
+        s.base_url || null,
+        s.access_method,
+        allows_automated,
+        allows_screenshot,
+        allows_internal,
+        allows_export,
+        JSON.stringify([]),
+        JSON.stringify([]),
+        JSON.stringify([]),
+        REVIEWED_AT,
+        s.review_notes || null,
+        enabled,
+      ]
+    );
+    if (res.rows[0].is_insert) inserted++; else updated++;
+  }
+
+  return { inserted, updated };
+}
+
+module.exports = { seedSources };
diff --git a/lib/classification.js b/lib/classification.js
new file mode 100644
index 0000000..79d53c8
--- /dev/null
+++ b/lib/classification.js
@@ -0,0 +1,92 @@
+'use strict';
+/**
+ * Classification helpers (spec §2, §6.16, §6.17).
+ *
+ * Pure functions — no database I/O. Import from lib/types (single vocabulary).
+ */
+
+const { RELATIONSHIP_STATUS, VERIFIED_STATUSES, STATUS_LABELS, normalizeName } = require('./types');
+
+/** Returns true if the given status is one of the VERIFIED_* statuses. */
+function isVerifiedStatus(status) {
+  return VERIFIED_STATUSES.includes(status);
+}
+
+/**
+ * Guard: prevents promoting a SPEAKER_OR_PANELIST_ONLY to any VERIFIED_*SPONSOR
+ * or VERIFIED_ADVERTISER status without explicit sponsor evidence (§6.16, §6.17).
+ *
+ * @param {string} fromStatus  - current relationship status
+ * @param {string} toStatus    - proposed new status
+ * @param {boolean} hasSponsorEvidence - caller must have verified separate sponsor evidence
+ * @throws {Error} if the promotion is prohibited
+ */
+function assertNotPanelistMislabeledAsSponsor(fromStatus, toStatus, hasSponsorEvidence) {
+  if (fromStatus !== 'SPEAKER_OR_PANELIST_ONLY') return; // only applies to panelists
+
+  const SPONSOR_OR_ADVERTISER_STATUSES = [
+    'VERIFIED_ADVERTISER',
+    'VERIFIED_CONFERENCE_SPONSOR',
+    'VERIFIED_EXHIBITOR',
+    'VERIFIED_MEDIA_PARTNER',
+  ];
+
+  if (!SPONSOR_OR_ADVERTISER_STATUSES.includes(toStatus)) return; // not a protected promotion
+
+  if (!hasSponsorEvidence) {
+    throw new Error(
+      `Classification error: cannot promote "${fromStatus}" to "${toStatus}" without separate, ` +
+      `explicit sponsor/advertiser evidence. Add a dated evidence record that directly proves ` +
+      `the paid relationship before changing this status. (spec §6.16, §6.17)`
+    );
+  }
+}
+
+/**
+ * Return the appropriate default relationship status for a given intake signal.
+ *
+ * @param {'AD' | 'EMAIL_AD' | 'CONFERENCE_SPONSOR' | 'PANELIST' | 'CONTENT_PRESENTATION' | 'PROSPECT' | 'UNKNOWN'} signal
+ * @returns {string} one of RELATIONSHIP_STATUS values
+ */
+function defaultStatusForSignal(signal) {
+  switch (signal) {
+    case 'AD':                    return 'VERIFIED_ADVERTISER';
+    case 'EMAIL_AD':              return 'VERIFIED_ADVERTISER';
+    case 'CONFERENCE_SPONSOR':    return 'VERIFIED_CONFERENCE_SPONSOR';
+    case 'EXHIBITOR':             return 'VERIFIED_EXHIBITOR';
+    case 'MEDIA_PARTNER':         return 'VERIFIED_MEDIA_PARTNER';
+    case 'CONTENT_PRESENTATION':  return 'VERIFIED_CONTENT_PARTNER';
+    case 'PANELIST':              return 'SPEAKER_OR_PANELIST_ONLY';
+    case 'SPEAKER':               return 'SPEAKER_OR_PANELIST_ONLY';
+    case 'PROSPECT':              return 'LIKELY_PROSPECT';
+    case 'PAST':                  return 'PAST_ADVERTISER';
+    case 'UNKNOWN':               return 'RESEARCH_NEEDED';
+    default:                      return 'RESEARCH_NEEDED';
+  }
+}
+
+/**
+ * Return the plain-English Simple View label for a status (spec §25).
+ * @param {string} status
+ * @returns {string}
+ */
+function statusLabel(status) {
+  return STATUS_LABELS[status] || status;
+}
+
+/**
+ * Validate that a given status string is in the canonical vocabulary.
+ * @param {string} status
+ * @returns {boolean}
+ */
+function isKnownStatus(status) {
+  return RELATIONSHIP_STATUS.includes(status);
+}
+
+module.exports = {
+  isVerifiedStatus,
+  assertNotPanelistMislabeledAsSponsor,
+  defaultStatusForSignal,
+  statusLabel,
+  isKnownStatus,
+};
diff --git a/lib/compliance/fetch-guard.js b/lib/compliance/fetch-guard.js
new file mode 100644
index 0000000..99cc448
--- /dev/null
+++ b/lib/compliance/fetch-guard.js
@@ -0,0 +1,247 @@
+'use strict';
+/**
+ * Fetch guard — SSRF protection for EVERY outbound fetch (spec §32, §6.4).
+ *
+ * Guarantees:
+ *   - LinkedIn hosts are hard-blocked (§6.3/§6.4) — the app NEVER fetches them.
+ *   - No fetch may resolve to a private / loopback / link-local / metadata IP
+ *     (§32 "DNS/IP checks blocking private, loopback, metadata, and link-local").
+ *   - Automated public web research is OFF unless explicitly enabled by env
+ *     (ALLOW_AUTOMATED_PUBLIC_WEB_RESEARCH === 'true').
+ *   - A descriptive, admin-contact User-Agent is always sent (§23).
+ *   - Per-host token bucket enforces a minimum delay / max requests per minute.
+ *   - Redirects are re-checked against the same guard (no redirect to a blocked
+ *     host or private IP).
+ */
+
+const dns = require('dns').promises;
+const T = require('../types');
+
+// ---------------------------------------------------------------------------
+// IP range checks
+// ---------------------------------------------------------------------------
+
+/** Parse an IPv4 "a.b.c.d" → 32-bit unsigned int, or null if not IPv4. */
+function ipv4ToInt(ip) {
+  const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);
+  if (!m) return null;
+  const parts = m.slice(1).map(Number);
+  if (parts.some((p) => p > 255)) return null;
+  return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
+}
+
+function inCidr(ipInt, netStr, bits) {
+  const net = ipv4ToInt(netStr);
+  if (net == null || ipInt == null) return false;
+  const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
+  return (ipInt & mask) === (net & mask);
+}
+
+/**
+ * isBlockedIp(ip) — true for any private / loopback / link-local / metadata /
+ * unspecified address (§32). Covers both IPv4 and the IPv6 forms we care about.
+ */
+function isBlockedIp(ip) {
+  if (!ip || typeof ip !== 'string') return true; // fail closed
+  const addr = ip.trim().toLowerCase().replace(/^\[|\]$/g, '');
+
+  // --- IPv6 ---
+  if (addr.includes(':')) {
+    if (addr === '::' || addr === '::0' || addr === '0:0:0:0:0:0:0:0') return true; // unspecified
+    if (addr === '::1' || addr === '0:0:0:0:0:0:0:1') return true;                   // loopback
+    // fc00::/7  — unique local (fc.. / fd..)
+    if (/^f[cd][0-9a-f]{0,2}:/.test(addr)) return true;
+    // fe80::/10 — link-local
+    if (/^fe[89ab][0-9a-f]?:/.test(addr) || /^fe80:/.test(addr)) return true;
+    // IPv4-mapped ::ffff:a.b.c.d — unwrap and re-check as IPv4
+    const mapped = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(addr);
+    if (mapped) return isBlockedIp(mapped[1]);
+    return false; // other global IPv6 — allowed
+  }
+
+  // --- IPv4 ---
+  const n = ipv4ToInt(addr);
+  if (n == null) return true; // not a parseable IPv4 → fail closed
+
+  if (inCidr(n, '10.0.0.0', 8)) return true;        // private
+  if (inCidr(n, '172.16.0.0', 12)) return true;     // private
+  if (inCidr(n, '192.168.0.0', 16)) return true;    // private
+  if (inCidr(n, '127.0.0.0', 8)) return true;       // loopback
+  if (inCidr(n, '169.254.0.0', 16)) return true;    // link-local (incl. 169.254.169.254 metadata)
+  if (inCidr(n, '0.0.0.0', 8)) return true;         // "this" network / 0.0.0.0
+  if (inCidr(n, '100.64.0.0', 10)) return true;     // CGNAT (also used by tailnets) — treat as internal
+  return false;
+}
+
+/** True when host === blocked OR endsWith .blocked (subdomain) — §6.4. */
+function isLinkedInHost(host) {
+  if (!host) return false;
+  const h = host.toLowerCase();
+  return T.LINKEDIN_BLOCKED_HOSTS.some((b) => h === b.toLowerCase() || h.endsWith(`.${b.toLowerCase()}`));
+}
+
+// ---------------------------------------------------------------------------
+// assertFetchAllowed
+// ---------------------------------------------------------------------------
+
+/**
+ * assertFetchAllowed(url) — throws unless the URL is safe to fetch.
+ * Rejects: non-http(s), LinkedIn hosts, and hosts that resolve (ANY A/AAAA
+ * record) to a blocked IP range. Resolves DNS via dns.promises.lookup(all).
+ */
+async function assertFetchAllowed(url) {
+  let u;
+  try {
+    u = new URL(url);
+  } catch (_e) {
+    throw new Error(`fetch-guard: invalid URL "${url}"`);
+  }
+
+  if (u.protocol !== 'http:' && u.protocol !== 'https:') {
+    throw new Error(`fetch-guard: protocol "${u.protocol}" not allowed (http/https only)`);
+  }
+
+  const host = u.hostname.toLowerCase();
+
+  // §6.4 — LinkedIn hard block.
+  if (isLinkedInHost(host)) {
+    throw new Error(`fetch-guard: LinkedIn host "${host}" is blocked from automated fetching (§6.3/§6.4)`);
+  }
+
+  // If the host is already a literal IP, check it directly.
+  const literal = host.replace(/^\[|\]$/g, '');
+  if (ipv4ToInt(literal) != null || literal.includes(':')) {
+    if (isBlockedIp(literal)) {
+      throw new Error(`fetch-guard: host resolves to blocked IP "${literal}" (private/loopback/metadata/link-local) (§32)`);
+    }
+    return { host, addresses: [literal] };
+  }
+
+  // Resolve ALL addresses; block if ANY is private (DNS-rebinding defense).
+  let addresses;
+  try {
+    addresses = await dns.lookup(host, { all: true });
+  } catch (e) {
+    throw new Error(`fetch-guard: DNS lookup failed for "${host}": ${e.message}`);
+  }
+  if (!addresses || addresses.length === 0) {
+    throw new Error(`fetch-guard: "${host}" resolved to no addresses`);
+  }
+  for (const a of addresses) {
+    if (isBlockedIp(a.address)) {
+      throw new Error(`fetch-guard: "${host}" resolves to blocked IP "${a.address}" (§32)`);
+    }
+  }
+  return { host, addresses: addresses.map((a) => a.address) };
+}
+
+// ---------------------------------------------------------------------------
+// Per-host token bucket (min delay / max requests per minute)
+// ---------------------------------------------------------------------------
+
+const buckets = new Map(); // host -> { lastAt, timestamps: number[] }
+
+function defaultRpm() {
+  const v = Number(process.env.DEFAULT_REQUESTS_PER_MINUTE || '6');
+  return Number.isFinite(v) && v > 0 ? v : 6;
+}
+
+async function throttleHost(host, { maxRequestsPerMinute, minimumDelayMs } = {}) {
+  const rpm = maxRequestsPerMinute || defaultRpm();
+  const minDelay = minimumDelayMs != null ? minimumDelayMs : Math.ceil(60000 / rpm);
+  const now = Date.now();
+  let b = buckets.get(host);
+  if (!b) {
+    b = { lastAt: 0, timestamps: [] };
+    buckets.set(host, b);
+  }
+  // Purge timestamps older than 60s.
+  b.timestamps = b.timestamps.filter((t) => now - t < 60000);
+
+  // Enforce max-requests-per-minute.
+  if (b.timestamps.length >= rpm) {
+    const waitMs = 60000 - (now - b.timestamps[0]);
+    if (waitMs > 0) await sleep(waitMs);
+  }
+  // Enforce minimum inter-request delay.
+  const since = Date.now() - b.lastAt;
+  if (b.lastAt && since < minDelay) await sleep(minDelay - since);
+
+  const stamp = Date.now();
+  b.lastAt = stamp;
+  b.timestamps.push(stamp);
+}
+
+function sleep(ms) {
+  return new Promise((r) => setTimeout(r, ms));
+}
+
+/** Test/reset helper — clears the in-memory rate-limit state. */
+function _resetBuckets() {
+  buckets.clear();
+}
+
+// ---------------------------------------------------------------------------
+// safeFetch
+// ---------------------------------------------------------------------------
+
+/**
+ * safeFetch(url, opts) — the ONLY sanctioned outbound HTTP entrypoint.
+ *  - refuses unless ALLOW_AUTOMATED_PUBLIC_WEB_RESEARCH === 'true'
+ *  - runs assertFetchAllowed (LinkedIn + SSRF)
+ *  - sets a descriptive User-Agent from CRAWLER_USER_AGENT
+ *  - throttles per host (token bucket)
+ *  - follows redirects MANUALLY, re-checking each hop with assertFetchAllowed
+ *
+ * opts: { maxRequestsPerMinute, minimumDelayMs, maxRedirects, headers, ...fetchOpts }
+ */
+async function safeFetch(url, opts = {}) {
+  if (process.env.ALLOW_AUTOMATED_PUBLIC_WEB_RESEARCH !== 'true') {
+    throw new Error(
+      'safeFetch: automated public web research is DISABLED. Set ALLOW_AUTOMATED_PUBLIC_WEB_RESEARCH=true to enable (spec §30).'
+    );
+  }
+
+  const ua =
+    process.env.CRAWLER_USER_AGENT ||
+    'RENTV-Advertiser-Research/1.0 (+admin@rentv.com)';
+  const maxRedirects = opts.maxRedirects != null ? opts.maxRedirects : 5;
+
+  let current = url;
+  for (let hop = 0; hop <= maxRedirects; hop++) {
+    // Guard EVERY hop (initial + each redirect target).
+    const { host } = await assertFetchAllowed(current);
+    await throttleHost(host, opts);
+
+    const headers = Object.assign({ 'user-agent': ua }, opts.headers || {});
+    const fetchOpts = Object.assign({}, opts, {
+      headers,
+      redirect: 'manual', // we re-check redirects ourselves
+    });
+    delete fetchOpts.maxRedirects;
+    delete fetchOpts.maxRequestsPerMinute;
+    delete fetchOpts.minimumDelayMs;
+
+    const res = await fetch(current, fetchOpts);
+
+    // Redirect? re-check the Location target through the guard.
+    if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
+      if (hop === maxRedirects) {
+        throw new Error(`safeFetch: too many redirects (>${maxRedirects}) from "${url}"`);
+      }
+      const next = new URL(res.headers.get('location'), current).toString();
+      current = next; // loop re-guards it (blocks redirect-to-LinkedIn / private IP)
+      continue;
+    }
+    return res;
+  }
+  throw new Error(`safeFetch: redirect loop exhausted for "${url}"`);
+}
+
+module.exports = {
+  assertFetchAllowed,
+  isBlockedIp,
+  isLinkedInHost,
+  safeFetch,
+  _resetBuckets,
+};
diff --git a/lib/compliance/no-inferred-email.js b/lib/compliance/no-inferred-email.js
new file mode 100644
index 0000000..4fccf9f
--- /dev/null
+++ b/lib/compliance/no-inferred-email.js
@@ -0,0 +1,147 @@
+'use strict';
+/**
+ * No-inferred-email guard (spec §6.8, §6.9).
+ *
+ * Hard rules enforced in code:
+ *   §6.8  NEVER infer or generate an email address from a naming pattern.
+ *   §6.9  Store a phone/email ONLY when it is EXPLICITLY published as a
+ *         business contact on a first-party page / press release / media kit /
+ *         event page / government record / public professional page, OR is
+ *         manually entered by an authorized user.
+ *
+ * Therefore a contact_point may only be stored when it carries a
+ * source_evidence_id AND explicitly_public === true. We only store
+ * explicitly-published addresses; looksLikePatternEmail() documents and detects
+ * the FORBIDDEN pattern-generated case so importers/UI can REFUSE it.
+ */
+
+/** Access methods / source flags that imply the value was INFERRED, not seen. */
+const INFERENCE_METHODS = Object.freeze([
+  'PATTERN_GUESS',
+  'EMAIL_PERMUTATION',
+  'INFERRED',
+  'GENERATED',
+  'GUESSED',
+  'ENRICHMENT_INFERRED',
+]);
+
+/**
+ * assertContactEvidence(contact) — throws unless the contact is provably
+ * explicitly-public. Every contact_point must be traceable to evidence (§6.11).
+ */
+function assertContactEvidence(contact) {
+  if (!contact || typeof contact !== 'object') {
+    throw new Error('assertContactEvidence: contact must be an object');
+  }
+  const evidenceId = contact.source_evidence_id || contact.sourceEvidenceId;
+  if (!evidenceId) {
+    throw new Error(
+      'assertContactEvidence: refusing to store contact without source_evidence_id (§6.9/§6.11) — every stored email/phone must cite explicit public evidence'
+    );
+  }
+  const explicit = contact.explicitly_public === true || contact.explicitlyPublic === true;
+  if (!explicit) {
+    throw new Error(
+      'assertContactEvidence: refusing to store contact that is not explicitly_public (§6.9) — only explicitly-published business contacts may be stored'
+    );
+  }
+  return true;
+}
+
+/**
+ * looksLikePatternEmail(email, personName, domain) — heuristic that flags
+ * emails that appear PATTERN-GENERATED from a person's name (first.last@domain,
+ * flast@domain, firstl@domain, first@domain, etc.). Returns true when the
+ * local-part matches a common permutation of the person's name.
+ *
+ * This is a REFUSE signal: we do not store inferred addresses. It documents the
+ * forbidden case (§6.8) so the importer/UI can reject a permutation even if a
+ * caller mistakenly tries to save one.
+ */
+function looksLikePatternEmail(email, personName, domain) {
+  if (!email || typeof email !== 'string') return false;
+  const at = email.indexOf('@');
+  if (at <= 0) return false;
+  const local = email.slice(0, at).toLowerCase().replace(/[^a-z0-9._-]/g, '');
+  const emailDomain = email.slice(at + 1).toLowerCase();
+
+  // If a domain is supplied, a mismatch means this isn't a pattern off THIS org.
+  if (domain) {
+    const d = String(domain).toLowerCase().replace(/^www\./, '');
+    if (!emailDomain.endsWith(d)) return false;
+  }
+
+  if (!personName || typeof personName !== 'string') return false;
+  const parts = personName
+    .toLowerCase()
+    .normalize('NFKD')
+    .replace(/[^a-z\s]/g, ' ')
+    .split(/\s+/)
+    .filter(Boolean);
+  if (parts.length < 2) {
+    // Single-token name: flag if local-part equals the token exactly.
+    return parts.length === 1 && local.replace(/[._-]/g, '') === parts[0];
+  }
+
+  const first = parts[0];
+  const last = parts[parts.length - 1];
+  const fi = first[0];
+  const li = last[0];
+  const bare = local.replace(/[._-]/g, '');
+
+  const permutations = new Set([
+    `${first}.${last}`,
+    `${first}_${last}`,
+    `${first}-${last}`,
+    `${first}${last}`,
+    `${fi}${last}`,        // flast
+    `${fi}.${last}`,       // f.last
+    `${fi}_${last}`,
+    `${first}${li}`,       // firstl
+    `${first}.${li}`,
+    `${last}.${first}`,
+    `${last}${first}`,
+    `${last}.${fi}`,       // last.f
+    `${last}${fi}`,        // lastf
+    first,                 // first@
+    last,                  // last@
+    `${fi}${li}`,          // initials
+  ]);
+
+  if (permutations.has(local)) return true;
+  // Also compare the punctuation-stripped local-part against stripped perms.
+  for (const p of permutations) {
+    if (p.replace(/[._-]/g, '') === bare) return true;
+  }
+  return false;
+}
+
+/**
+ * assertNoInference(source) — throws if the source's access/method/flags imply
+ * the value was inferred rather than observed. Accepts a string method or an
+ * object with { method, accessMethod, inferred }.
+ */
+function assertNoInference(source) {
+  let method;
+  let inferredFlag = false;
+  if (typeof source === 'string') {
+    method = source;
+  } else if (source && typeof source === 'object') {
+    method = source.method || source.accessMethod || source.discoveryMethod;
+    inferredFlag = source.inferred === true || source.generated === true;
+  }
+  const m = String(method || '').toUpperCase();
+  if (inferredFlag || INFERENCE_METHODS.includes(m)) {
+    throw new Error(
+      `assertNoInference: source method "${method}" implies an INFERRED/pattern-generated value — forbidden (§6.8). Only explicitly-published contacts may be stored.`
+    );
+  }
+  return true;
+}
+
+module.exports = {
+  INFERENCE_METHODS,
+  assertContactEvidence,
+  looksLikePatternEmail,
+  assertNoInference,
+};
diff --git a/lib/compliance/source-policy.js b/lib/compliance/source-policy.js
new file mode 100644
index 0000000..8d737e2
--- /dev/null
+++ b/lib/compliance/source-policy.js
@@ -0,0 +1,197 @@
+'use strict';
+/**
+ * Source-policy validator — the legal guardrail (spec §6, §9, §10).
+ *
+ * Enforces the "hard research, privacy, and source rules" in CODE, not docs:
+ *   §6.1  no bypassing auth/paywalls/robots/rate-limits/anti-bot
+ *   §6.2  no scraping proprietary DBs (CoStar, LoopNet, ZoomInfo, Apollo, MLS)
+ *   §6.3  no crawling LinkedIn profile/company pages
+ *   §6.4  block automated fetching from LinkedIn hosts in the generic fetcher
+ *   §10   the SourcePolicy shape + access-method vocabulary
+ *
+ * A policy that names a prohibited proprietary DB or a LinkedIn host as its
+ * base is ILLEGAL to enable for automation. validateSourcePolicy() flags it;
+ * assertSourceEnabledLegal() throws so a pipeline can never run it.
+ */
+
+const T = require('../types');
+
+/**
+ * Prohibited proprietary databases (§6.2). Login-gated / proprietary — never
+ * automate. LinkedIn hosts (§6.3/§6.4) are appended from the shared contract so
+ * there is ONE list. MLS is matched by pattern below (any *.mls* / mls.* host).
+ */
+const PROHIBITED_PROPRIETARY_HOSTS = Object.freeze([
+  'costar.com',
+  'loopnet.com',
+  'zoominfo.com',
+  'apollo.io',
+  'crexi.com',       // proprietary marketplace DB, login-gated bulk access
+  'reonomy.com',     // proprietary property DB
+]);
+
+// Full automation blocklist = proprietary DBs + every LinkedIn host (§6.2-6.4).
+const PROHIBITED_AUTOMATION_HOSTS = Object.freeze(
+  PROHIBITED_PROPRIETARY_HOSTS.concat(T.LINKEDIN_BLOCKED_HOSTS)
+);
+
+/** Access methods that, by definition, perform NO automated fetching. */
+const NON_AUTOMATED_METHODS = Object.freeze([
+  'manual_upload',
+  'manual_review_only',
+  'authorized_mailbox', // mailbox is authorized-API, not web automation
+]);
+
+/** Lowercase, strip scheme/path/port → bare host. Accepts a bare host too. */
+function hostOf(urlOrHost) {
+  if (!urlOrHost) return '';
+  const raw = String(urlOrHost).trim();
+  try {
+    const u = new URL(raw.includes('://') ? raw : `https://${raw}`);
+    return u.hostname.toLowerCase();
+  } catch (_e) {
+    return raw.toLowerCase().replace(/^\/+/, '').split('/')[0].split(':')[0];
+  }
+}
+
+/** True when host === blocked OR is a subdomain of blocked (a.b.costar.com). */
+function hostMatches(host, blocked) {
+  if (!host) return false;
+  const h = host.toLowerCase();
+  const b = blocked.toLowerCase();
+  return h === b || h.endsWith(`.${b}`);
+}
+
+/** MLS systems are innumerable; match by pattern (§6.2 "any MLS"). */
+function looksLikeMls(host) {
+  if (!host) return false;
+  const h = host.toLowerCase();
+  // mls.<x>, <x>.mls.<y>, <x>mls.<y>, crmls/themls/etc.
+  return (
+    /(^|\.)mls\./.test(h) ||
+    /(^|\.)[a-z]*mls\.[a-z]/.test(h) ||
+    /\bmls\b/.test(h.replace(/[.-]/g, ' '))
+  );
+}
+
+/** Is this host prohibited for AUTOMATED access? (proprietary DB, LinkedIn, MLS) */
+function isProhibitedAutomationHost(host) {
+  if (!host) return false;
+  if (looksLikeMls(host)) return true;
+  return PROHIBITED_AUTOMATION_HOSTS.some((b) => hostMatches(host, b));
+}
+
+/** §10 — the access method must be one of the seven contract values. */
+function accessMethodAllowed(method) {
+  return T.SOURCE_ACCESS_METHODS.includes(method);
+}
+
+/**
+ * validateSourcePolicy(policy) → { valid, errors }
+ * Never throws; collects every violation so the /sources UI can show them all.
+ */
+function validateSourcePolicy(policy) {
+  const errors = [];
+
+  if (!policy || typeof policy !== 'object') {
+    return { valid: false, errors: ['policy must be an object'] };
+  }
+
+  // Required identity fields.
+  if (!policy.sourceKey || typeof policy.sourceKey !== 'string') {
+    errors.push('sourceKey is required');
+  }
+  if (!policy.displayName || typeof policy.displayName !== 'string') {
+    errors.push('displayName is required');
+  }
+
+  // §10 access method vocabulary.
+  if (!accessMethodAllowed(policy.accessMethod)) {
+    errors.push(
+      `accessMethod "${policy.accessMethod}" is not one of ${T.SOURCE_ACCESS_METHODS.join(', ')}`
+    );
+  }
+
+  const base = hostOf(policy.baseUrl || policy.baseHost || '');
+  const automated = policy.allowsAutomatedAccess === true;
+
+  // Core rule (§6.2-6.4): a prohibited host may NOT allow automated access.
+  if (automated && base && isProhibitedAutomationHost(base)) {
+    errors.push(
+      `base host "${base}" is a prohibited proprietary/LinkedIn/MLS source (§6.2-6.4); allowsAutomatedAccess must be false`
+    );
+  }
+
+  // A prohibited host is only tolerable as manual_review_only / manual_upload.
+  if (base && isProhibitedAutomationHost(base) && !NON_AUTOMATED_METHODS.includes(policy.accessMethod)) {
+    errors.push(
+      `base host "${base}" is prohibited for automation; accessMethod must be manual_review_only or manual_upload, got "${policy.accessMethod}"`
+    );
+  }
+
+  // A web-automation access method combined with a prohibited base is illegal.
+  const AUTOMATED_WEB_METHODS = ['first_party_public_web', 'rss_or_sitemap', 'official_bulk_download'];
+  if (base && isProhibitedAutomationHost(base) && AUTOMATED_WEB_METHODS.includes(policy.accessMethod)) {
+    errors.push(
+      `access method "${policy.accessMethod}" performs web automation against prohibited host "${base}" (§6.1)`
+    );
+  }
+
+  // Any prohibitedHosts entry that ALSO appears reachable is contradictory noise;
+  // validate the declared prohibitedHosts are well-formed strings.
+  if (policy.prohibitedHosts && !Array.isArray(policy.prohibitedHosts)) {
+    errors.push('prohibitedHosts must be an array');
+  }
+
+  // If enabled, automated, and a web method, require conservative rate limits (§6.1, §23).
+  if (policy.enabled && automated && AUTOMATED_WEB_METHODS.includes(policy.accessMethod)) {
+    const rpm = policy.maxRequestsPerMinute;
+    if (rpm != null && (typeof rpm !== 'number' || rpm <= 0 || rpm > 60)) {
+      errors.push('maxRequestsPerMinute must be a sane 1-60 for automated web access (§23)');
+    }
+  }
+
+  return { valid: errors.length === 0, errors };
+}
+
+/**
+ * assertSourceEnabledLegal(policy) — throws if an ILLEGAL source is enabled for
+ * automation. Called by the pipeline BEFORE any run (§10 "policy validation
+ * before every run"). A disabled or non-automated policy is always allowed to
+ * pass here (it does no fetching); only an ENABLED + AUTOMATED illegal source
+ * is a hard stop.
+ */
+function assertSourceEnabledLegal(policy) {
+  const { valid, errors } = validateSourcePolicy(policy);
+
+  const base = hostOf((policy && (policy.baseUrl || policy.baseHost)) || '');
+  const automated = policy && policy.allowsAutomatedAccess === true;
+  const enabled = policy && policy.enabled === true;
+
+  // The unforgivable combination: enabled + automated + prohibited host.
+  if (enabled && automated && base && isProhibitedAutomationHost(base)) {
+    throw new Error(
+      `ILLEGAL SOURCE: "${(policy && policy.sourceKey) || base}" is enabled for automated access against prohibited host "${base}" (spec §6.2-6.4). Refusing to run.`
+    );
+  }
+
+  // Any other structural invalidity on an enabled policy is also a stop.
+  if (enabled && !valid) {
+    throw new Error(
+      `INVALID SOURCE POLICY "${(policy && policy.sourceKey) || '?'}": ${errors.join('; ')}`
+    );
+  }
+
+  return true;
+}
+
+module.exports = {
+  PROHIBITED_AUTOMATION_HOSTS,
+  PROHIBITED_PROPRIETARY_HOSTS,
+  validateSourcePolicy,
+  assertSourceEnabledLegal,
+  accessMethodAllowed,
+  isProhibitedAutomationHost,
+  looksLikeMls,
+  hostOf,
+};
diff --git a/lib/entity-resolution.js b/lib/entity-resolution.js
new file mode 100644
index 0000000..253d266
--- /dev/null
+++ b/lib/entity-resolution.js
@@ -0,0 +1,266 @@
+'use strict';
+/**
+ * Entity resolution — deterministic + scored candidate matching (spec §12).
+ *
+ * Rules (in order of priority):
+ *  1. Exact normalized domain
+ *  2. Exact public email domain
+ *  3. Exact normalized name + city + state
+ *  4. Exact LinkedIn company URL (from permitted source only)
+ *
+ * Candidate scoring: 0-1 float.
+ * Thresholds: >=0.97 = auto; 0.82-0.9699 = review; <0.82 = no.
+ * Merges are REVERSIBLE via merge_audit snapshot.
+ */
+
+const { normalizeName } = require('./types');
+
+/**
+ * Run deterministic matching rules against an existing org record.
+ *
+ * @param {{ domain?: string, emailDomain?: string, normalizedName?: string, city?: string, state?: string, linkedinUrl?: string }} candidate
+ * @param {{ domain?: string, emailDomain?: string, normalized_name?: string, headquarters_city?: string, headquarters_state?: string, linkedin_url?: string }} existing  - one row from organizations table
+ * @returns {{ matched: boolean, rule: string | null, confidence: number }}
+ */
+function deterministicMatch(candidate, existing) {
+  // Rule 1: exact normalized domain (both non-null)
+  if (candidate.domain && existing.domain) {
+    const cd = String(candidate.domain).toLowerCase().replace(/^www\./, '');
+    const ed = String(existing.domain).toLowerCase().replace(/^www\./, '');
+    if (cd === ed) {
+      return { matched: true, rule: 'EXACT_DOMAIN', confidence: 1.0 };
+    }
+  }
+
+  // Rule 2: exact public email domain
+  if (candidate.emailDomain && existing.emailDomain) {
+    const ce = String(candidate.emailDomain).toLowerCase();
+    const ee = String(existing.emailDomain).toLowerCase();
+    if (ce === ee) {
+      return { matched: true, rule: 'EXACT_EMAIL_DOMAIN', confidence: 0.98 };
+    }
+  }
+
+  // Rule 3: exact normalized name + city + state
+  if (candidate.normalizedName && existing.normalized_name) {
+    const cn = normalizeName(candidate.normalizedName);
+    const en = String(existing.normalized_name).trim();
+    const cityMatch =
+      !candidate.city ||
+      !existing.headquarters_city ||
+      String(candidate.city).toLowerCase() === String(existing.headquarters_city).toLowerCase();
+    const stateMatch =
+      !candidate.state ||
+      !existing.headquarters_state ||
+      String(candidate.state).toUpperCase() === String(existing.headquarters_state).toUpperCase();
+    if (cn === en && cityMatch && stateMatch) {
+      return { matched: true, rule: 'EXACT_NAME_CITY_STATE', confidence: 0.97 };
+    }
+  }
+
+  // Rule 4: exact LinkedIn company URL (must come from a permitted discovery source)
+  if (candidate.linkedinUrl && existing.linkedin_url) {
+    const normalize = (u) =>
+      String(u)
+        .toLowerCase()
+        .replace(/\/$/, '')
+        .replace(/^https?:\/\/(www\.)?/, '');
+    if (normalize(candidate.linkedinUrl) === normalize(existing.linkedin_url)) {
+      return { matched: true, rule: 'EXACT_LINKEDIN_URL', confidence: 0.99 };
+    }
+  }
+
+  return { matched: false, rule: null, confidence: 0 };
+}
+
+/**
+ * Score a candidate pair (a, b) on a 0-1 scale using overlapping signals.
+ * Intended for fuzzy / probabilistic candidate generation (not deterministic).
+ *
+ * @param {Object} a
+ * @param {Object} b
+ * @returns {number} 0-1 float
+ */
+function scoreCandidatePair(a, b) {
+  let score = 0;
+  let signals = 0;
+
+  // Name similarity (Jaccard on trigrams, simplified)
+  const na = normalizeName(a.display_name || a.legal_name || '');
+  const nb = normalizeName(b.display_name || b.legal_name || '');
+  if (na && nb) {
+    const nameSim = jaccardTrigram(na, nb);
+    score += nameSim * 0.40;
+    signals++;
+  }
+
+  // Domain match
+  if (a.domain && b.domain) {
+    const da = String(a.domain).toLowerCase().replace(/^www\./, '');
+    const db = String(b.domain).toLowerCase().replace(/^www\./, '');
+    score += (da === db ? 1.0 : 0) * 0.30;
+    signals++;
+  }
+
+  // State match
+  if (a.headquarters_state && b.headquarters_state) {
+    const sa = String(a.headquarters_state).toUpperCase();
+    const sb = String(b.headquarters_state).toUpperCase();
+    score += (sa === sb ? 1.0 : 0) * 0.10;
+    signals++;
+  }
+
+  // City match
+  if (a.headquarters_city && b.headquarters_city) {
+    const ca = String(a.headquarters_city).toLowerCase().trim();
+    const cb = String(b.headquarters_city).toLowerCase().trim();
+    score += (ca === cb ? 1.0 : 0) * 0.10;
+    signals++;
+  }
+
+  // Category overlap
+  if (Array.isArray(a.advertiser_categories) && Array.isArray(b.advertiser_categories)) {
+    const setA = new Set(a.advertiser_categories);
+    const intersection = (b.advertiser_categories || []).filter((c) => setA.has(c));
+    const union = new Set([...a.advertiser_categories, ...(b.advertiser_categories || [])]);
+    const catSim = union.size > 0 ? intersection.length / union.size : 0;
+    score += catSim * 0.10;
+    signals++;
+  }
+
+  return signals > 0 ? Math.min(1, score) : 0;
+}
+
+/** Jaccard similarity on character trigrams of two strings. */
+function jaccardTrigram(a, b) {
+  const trigrams = (s) => {
+    const set = new Set();
+    for (let i = 0; i < s.length - 2; i++) set.add(s.slice(i, i + 3));
+    return set;
+  };
+  const ta = trigrams(a);
+  const tb = trigrams(b);
+  if (ta.size === 0 && tb.size === 0) return 1;
+  if (ta.size === 0 || tb.size === 0) return 0;
+  let inter = 0;
+  for (const t of ta) if (tb.has(t)) inter++;
+  return inter / (ta.size + tb.size - inter);
+}
+
+/**
+ * Classify a candidate pair score into a merge decision.
+ *
+ * @param {number} score 0-1
+ * @returns {'auto' | 'review' | 'no'}
+ */
+function classifyMerge(score) {
+  if (score >= 0.97) return 'auto';
+  if (score >= 0.82) return 'review';
+  return 'no';
+}
+
+/**
+ * Perform a reversible merge of mergedId into keptId.
+ *
+ * Steps:
+ *  1. Snapshot the merged org row into merge_audit.
+ *  2. Update FK references (organization_id) in child tables.
+ *  3. Delete the merged org row.
+ *
+ * The merge is fully reversible by reading merge_audit.snapshot.
+ *
+ * @param {Object} pool  - pg Pool instance
+ * @param {string} keptId    - UUID of the org to keep
+ * @param {string} mergedId  - UUID of the org to merge (will be deleted)
+ * @returns {Promise<{merge_audit_id: string}>}
+ */
+async function reversibleMerge(pool, keptId, mergedId) {
+  if (keptId === mergedId) throw new Error('Cannot merge an org into itself.');
+
+  const client = await pool.connect();
+  try {
+    await client.query('BEGIN');
+
+    // 1. Snapshot the merged row
+    const snap = await client.query(
+      `SELECT row_to_json(o) AS snapshot FROM organizations o WHERE id=$1`,
+      [mergedId]
+    );
+    if (snap.rows.length === 0) {
+      throw new Error(`Organization to merge (id=${mergedId}) not found.`);
+    }
+    const snapshot = snap.rows[0].snapshot;
+
+    const auditRes = await client.query(
+      `INSERT INTO merge_audit (kept_id, merged_id, entity_table, snapshot)
+       VALUES ($1,$2,'organizations',$3::jsonb)
+       RETURNING id`,
+      [keptId, mergedId, JSON.stringify(snapshot)]
+    );
+    const merge_audit_id = auditRes.rows[0].id;
+
+    // 2. Re-parent child rows — tables with organization_id FK
+    const CHILD_TABLES = [
+      'ad_sightings',
+      'campaigns',
+      'contact_points',
+      'creative_assets',
+      'event_relationships',
+      'notes',
+      'opportunities',
+      'opportunity_scores',
+      'organization_markets',
+      'organization_tags',
+      'suppression_requests',
+      'tasks',
+    ];
+
+    for (const tbl of CHILD_TABLES) {
+      // organization_tags and organization_markets have composite PKs — handle ON CONFLICT
+      if (tbl === 'organization_tags') {
+        await client.query(
+          `UPDATE organization_tags SET organization_id=$1 WHERE organization_id=$2`,
+          [keptId, mergedId]
+        );
+        // Remove any duplicates that arose from the re-parent
+        await client.query(
+          `DELETE FROM organization_tags a USING organization_tags b
+           WHERE a.ctid > b.ctid AND a.organization_id=b.organization_id AND a.tag_id=b.tag_id`
+        );
+      } else if (tbl === 'organization_markets') {
+        await client.query(
+          `UPDATE organization_markets SET organization_id=$1 WHERE organization_id=$2`,
+          [keptId, mergedId]
+        );
+        await client.query(
+          `DELETE FROM organization_markets a USING organization_markets b
+           WHERE a.ctid > b.ctid AND a.organization_id=b.organization_id
+             AND a.market_id=b.market_id AND a.relationship_type=b.relationship_type`
+        );
+      } else {
+        await client.query(
+          `UPDATE ${tbl} SET organization_id=$1 WHERE organization_id=$2`,
+          [keptId, mergedId]
+        );
+      }
+    }
+
+    // 3. Delete the merged org (all remaining FKs should be cleared now)
+    await client.query(`DELETE FROM organizations WHERE id=$1`, [mergedId]);
+
+    await client.query('COMMIT');
+    return { merge_audit_id };
+  } catch (e) {
+    await client.query('ROLLBACK');
+    throw e;
+  } finally {
+    client.release();
+  }
+}
+
+module.exports = {
+  deterministicMatch,
+  scoreCandidatePair,
+  classifyMerge,
+  reversibleMerge,
+};
diff --git a/public/css/app.css b/public/css/app.css
new file mode 100644
index 0000000..a2477a4
--- /dev/null
+++ b/public/css/app.css
@@ -0,0 +1,736 @@
+/* RENTV Advertiser Intelligence — Simple View theme (spec §25).
+   Light-mode default, strong contrast, ≥18px base, ≥44px targets.
+   CSS custom props let a dark/night mode be layered later.
+   Printable: @media print strips chrome, preserves content. */
+
+:root {
+  /* Palette */
+  --bg: #f5f6f8;
+  --surface: #ffffff;
+  --surface-alt: #f0f2f5;
+  --border: #d0d5de;
+  --border-strong: #9aa0ad;
+  --ink: #1a1e2b;
+  --ink-muted: #555d6e;
+  --ink-faint: #8a93a2;
+  --accent: #0047cc;
+  --accent-hover: #003399;
+  --accent-light: #dce8ff;
+  --success: #1a7a3a;
+  --success-bg: #e6f5ec;
+  --warn: #7a4f00;
+  --warn-bg: #fff3dc;
+  --danger: #9b1c1c;
+  --danger-bg: #fde8e8;
+  --info: #1a5276;
+  --info-bg: #d6eaf8;
+  --gold: #b78100;
+  --gold-bg: #fff8e1;
+
+  /* Typography */
+  --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
+  --font-size-base: 18px;
+  --font-size-sm: 15px;
+  --font-size-xs: 13px;
+  --font-size-lg: 20px;
+  --font-size-xl: 24px;
+  --font-size-h1: 28px;
+
+  /* Layout */
+  --header-h: 62px;
+  --rail-w: 270px;
+  --radius: 10px;
+  --radius-sm: 6px;
+  --shadow: 0 1px 4px rgba(0,0,0,.08);
+  --shadow-md: 0 3px 12px rgba(0,0,0,.12);
+
+  /* Card density (grid pages) */
+  --card-min: 300px;
+}
+
+*, *::before, *::after { box-sizing: border-box; }
+
+html { font-size: var(--font-size-base); }
+
+body {
+  margin: 0;
+  background: var(--bg);
+  color: var(--ink);
+  font-family: var(--font);
+  font-size: var(--font-size-base);
+  line-height: 1.55;
+  -webkit-font-smoothing: antialiased;
+}
+
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; color: var(--accent-hover); }
+a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
+
+h1, h2, h3, h4 { margin: 0 0 .5em; font-weight: 700; color: var(--ink); }
+h1 { font-size: var(--font-size-h1); }
+h2 { font-size: var(--font-size-xl); }
+h3 { font-size: var(--font-size-lg); }
+h4 { font-size: var(--font-size-base); text-transform: uppercase; letter-spacing: .5px; color: var(--ink-muted); }
+
+p { margin: 0 0 1em; }
+
+/* ── Global nav header ── */
+.site-header {
+  position: sticky;
+  top: 0;
+  z-index: 100;
+  background: var(--surface);
+  border-bottom: 2px solid var(--border);
+  height: var(--header-h);
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  padding: 0 24px;
+  box-shadow: var(--shadow);
+}
+
+.site-header .brand {
+  font-size: var(--font-size-lg);
+  font-weight: 800;
+  color: var(--ink);
+  text-decoration: none;
+  white-space: nowrap;
+  flex-shrink: 0;
+}
+.site-header .brand span { color: var(--accent); }
+
+.header-search {
+  flex: 1;
+  max-width: 480px;
+  padding: 10px 16px;
+  border: 2px solid var(--border);
+  border-radius: var(--radius);
+  font-size: var(--font-size-base);
+  background: var(--surface);
+  color: var(--ink);
+  min-width: 0;
+}
+.header-search:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); }
+
+.header-spacer { flex: 1; }
+
+/* Market + Verified switches */
+.switch-group {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-shrink: 0;
+}
+
+.market-switch {
+  display: flex;
+  border: 2px solid var(--border);
+  border-radius: 8px;
+  overflow: hidden;
+}
+.market-switch button {
+  background: var(--surface);
+  border: none;
+  padding: 8px 14px;
+  font-size: var(--font-size-sm);
+  font-weight: 600;
+  color: var(--ink-muted);
+  cursor: pointer;
+  min-height: 44px;
+  min-width: 44px;
+  transition: background .1s, color .1s;
+}
+.market-switch button:hover { background: var(--accent-light); color: var(--accent); }
+.market-switch button.active { background: var(--accent); color: #fff; }
+
+.verified-switch {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 14px;
+  border: 2px solid var(--border);
+  border-radius: 8px;
+  font-size: var(--font-size-sm);
+  font-weight: 600;
+  background: var(--surface);
+  cursor: pointer;
+  min-height: 44px;
+  user-select: none;
+  color: var(--ink-muted);
+  transition: background .1s, border-color .1s;
+}
+.verified-switch.active { background: var(--success-bg); border-color: var(--success); color: var(--success); }
+.verified-switch input { accent-color: var(--success); width: 18px; height: 18px; cursor: pointer; }
+
+.dl-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  padding: 10px 18px;
+  background: var(--accent);
+  color: #fff;
+  border: none;
+  border-radius: var(--radius);
+  font-size: var(--font-size-sm);
+  font-weight: 700;
+  cursor: pointer;
+  min-height: 44px;
+  white-space: nowrap;
+  transition: background .15s;
+  text-decoration: none;
+}
+.dl-btn:hover { background: var(--accent-hover); color: #fff; text-decoration: none; }
+.dl-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+
+/* ── Page shell ── */
+.page-shell { display: flex; align-items: flex-start; min-height: calc(100vh - var(--header-h)); }
+
+.left-rail {
+  flex: 0 0 var(--rail-w);
+  background: var(--surface);
+  border-right: 1px solid var(--border);
+  position: sticky;
+  top: var(--header-h);
+  height: calc(100vh - var(--header-h));
+  overflow-y: auto;
+  padding: 0 0 60px;
+}
+
+.rail-section {
+  padding: 16px 18px;
+  border-bottom: 1px solid var(--border);
+}
+.rail-section h4 {
+  margin: 0 0 10px;
+  font-size: var(--font-size-xs);
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.rail-section h4::after { content: '▾'; font-size: 11px; color: var(--ink-muted); transition: transform .15s; }
+.rail-section.collapsed h4::after { transform: rotate(-90deg); }
+.rail-section.collapsed > *:not(h4) { display: none !important; }
+
+.page-content { flex: 1 1 auto; min-width: 0; padding: 28px 32px 60px; }
+
+/* ── Stats bar ── */
+.stats-bar {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  margin-bottom: 20px;
+}
+.stat-pill {
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  padding: 8px 16px;
+  font-size: var(--font-size-sm);
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  min-width: 120px;
+}
+.stat-pill .sp-label { color: var(--ink-muted); font-size: var(--font-size-xs); font-weight: 600; text-transform: uppercase; letter-spacing: .4px; }
+.stat-pill .sp-value { font-size: var(--font-size-lg); font-weight: 800; color: var(--ink); }
+.stat-pill a { color: inherit; text-decoration: none; }
+.stat-pill a:hover { text-decoration: underline; }
+
+/* ── DEMO badge ── */
+.demo-banner {
+  background: var(--warn-bg);
+  border: 2px solid #e6a800;
+  border-radius: var(--radius);
+  padding: 12px 18px;
+  margin-bottom: 20px;
+  font-size: var(--font-size-sm);
+  font-weight: 700;
+  color: var(--warn);
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+/* ── Table engine ── */
+.tbl-controls {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 14px;
+}
+
+.tbl-search {
+  padding: 9px 14px;
+  border: 2px solid var(--border);
+  border-radius: var(--radius-sm);
+  font-size: var(--font-size-sm);
+  background: var(--surface);
+  color: var(--ink);
+  min-width: 260px;
+}
+.tbl-search:focus { outline: none; border-color: var(--accent); }
+
+.tbl-sort-sel {
+  padding: 8px 12px;
+  border: 2px solid var(--border);
+  border-radius: var(--radius-sm);
+  font-size: var(--font-size-sm);
+  background: var(--surface);
+  color: var(--ink);
+  min-height: 44px;
+}
+
+.density-wrap {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: var(--font-size-sm);
+  color: var(--ink-muted);
+}
+.density-wrap input[type="range"] {
+  width: 100px;
+  accent-color: var(--accent);
+}
+
+.tbl-count { margin-left: auto; font-size: var(--font-size-sm); color: var(--ink-muted); }
+
+.tbl-wrap {
+  overflow-x: auto;
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  background: var(--surface);
+  box-shadow: var(--shadow);
+  max-height: calc(100vh - 300px);
+}
+
+table.adv-tbl {
+  border-collapse: separate;
+  border-spacing: 0;
+  width: 100%;
+  font-size: var(--font-size-sm);
+  white-space: nowrap;
+}
+
+table.adv-tbl thead th {
+  position: sticky;
+  top: 0;
+  background: #e8ecf2;
+  color: var(--ink-muted);
+  font-size: var(--font-size-xs);
+  font-weight: 700;
+  text-transform: uppercase;
+  letter-spacing: .4px;
+  padding: 12px 14px;
+  border-bottom: 2px solid var(--border);
+  cursor: pointer;
+  user-select: none;
+  z-index: 2;
+  white-space: nowrap;
+}
+table.adv-tbl thead th:hover { color: var(--ink); }
+table.adv-tbl thead th.asc::after { content: ' ▲'; color: var(--accent); }
+table.adv-tbl thead th.desc::after { content: ' ▼'; color: var(--accent); }
+table.adv-tbl thead th.dragcol { cursor: grab; }
+table.adv-tbl thead th.dragging { opacity: .4; }
+table.adv-tbl thead th.dropL { box-shadow: inset 3px 0 0 var(--accent); }
+table.adv-tbl thead th.dropR { box-shadow: inset -3px 0 0 var(--accent); }
+
+table.adv-tbl tbody td {
+  padding: 12px 14px;
+  border-bottom: 1px solid var(--border);
+  color: var(--ink);
+  vertical-align: middle;
+}
+table.adv-tbl tbody tr:last-child td { border-bottom: none; }
+table.adv-tbl tbody tr:hover td { background: var(--accent-light); }
+table.adv-tbl tbody tr { cursor: pointer; }
+
+table.adv-tbl td.num { text-align: right; font-variant-numeric: tabular-nums; }
+
+table.adv-tbl a { color: var(--accent); }
+
+/* expandable row */
+.row-expand { background: var(--surface-alt) !important; cursor: default; }
+.row-expand td { padding: 16px 20px !important; white-space: normal; }
+
+/* ── Column field toggles in rail ── */
+.field-toggles { display: flex; flex-direction: column; gap: 2px; max-height: 360px; overflow-y: auto; }
+.ftog { display: flex; align-items: center; gap: 8px; font-size: var(--font-size-sm); color: var(--ink); cursor: pointer; padding: 4px 4px; border-radius: 6px; }
+.ftog:hover { background: var(--accent-light); }
+.ftog input { accent-color: var(--accent); width: 16px; height: 16px; cursor: pointer; }
+.fgrp { font-size: 10px; text-transform: uppercase; letter-spacing: .6px; color: var(--accent); margin: 10px 0 3px; font-weight: 700; }
+.mini-btn { background: var(--surface); color: var(--ink-muted); border: 1px solid var(--border); border-radius: 6px; padding: 5px 10px; font-size: 13px; cursor: pointer; margin-right: 5px; }
+.mini-btn:hover { border-color: var(--accent); color: var(--ink); }
+
+/* ── Chips / facet filters ── */
+.chips { display: flex; flex-wrap: wrap; gap: 6px; }
+.chip {
+  background: var(--surface-alt);
+  color: var(--ink-muted);
+  border: 1px solid var(--border);
+  border-radius: 20px;
+  padding: 5px 12px;
+  font-size: var(--font-size-xs);
+  font-weight: 600;
+  cursor: pointer;
+  user-select: none;
+  transition: background .1s, border-color .1s;
+}
+.chip:hover { color: var(--ink); border-color: var(--border-strong); }
+.chip.active { border-color: var(--accent); color: var(--accent); background: var(--accent-light); }
+.chip .ct { color: var(--ink-faint); margin-left: 4px; font-size: 11px; }
+
+/* ── Status / category badges (text + shape, not color alone) ── */
+.badge {
+  display: inline-block;
+  font-size: 12px;
+  font-weight: 700;
+  padding: 3px 8px;
+  border-radius: 12px;
+  white-space: nowrap;
+  border: 1.5px solid transparent;
+}
+.badge-verified-advertiser { background: #e6f5ec; color: #1a7a3a; border-color: #a3d9b1; }
+.badge-verified-sponsor    { background: #d6eaf8; color: #1a5276; border-color: #9ac4e8; }
+.badge-verified-exhibitor  { background: #e8d8f5; color: #6a1a9a; border-color: #c49adf; }
+.badge-verified-media      { background: #fff8e1; color: #7a5c00; border-color: #f0d070; }
+.badge-content-partner     { background: #e8f4f0; color: #1a6a55; border-color: #90d0bb; }
+.badge-panelist            { background: var(--surface-alt); color: var(--ink-muted); border-color: var(--border); }
+.badge-past-advertiser     { background: #f5f5f0; color: #555; border-color: #ccc; }
+.badge-likely-prospect     { background: #fff3dc; color: #7a4f00; border-color: #f0c060; }
+.badge-research-needed     { background: #fff0e0; color: #7a3500; border-color: #e0a060; }
+.badge-disqualified        { background: #fde8e8; color: #9b1c1c; border-color: #f0a0a0; }
+
+.score-badge {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 44px;
+  height: 28px;
+  border-radius: 8px;
+  font-size: var(--font-size-xs);
+  font-weight: 800;
+  background: var(--accent-light);
+  color: var(--accent);
+  border: 1.5px solid #a0b8e8;
+}
+.score-high { background: #e6f5ec; color: #1a7a3a; border-color: #a3d9b1; }
+.score-mid  { background: #fff3dc; color: #7a4f00; border-color: #f0c060; }
+.score-low  { background: var(--surface-alt); color: var(--ink-muted); border-color: var(--border); }
+
+/* ── Org logo / thumbnail ── */
+.org-thumb {
+  width: 44px;
+  height: 44px;
+  border-radius: 8px;
+  object-fit: contain;
+  background: var(--surface-alt);
+  border: 1px solid var(--border);
+  display: block;
+}
+.org-thumb-placeholder {
+  width: 44px;
+  height: 44px;
+  border-radius: 8px;
+  background: var(--surface-alt);
+  border: 1px solid var(--border);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 18px;
+  color: var(--ink-faint);
+  flex-shrink: 0;
+}
+
+/* ── Dashboard cards ── */
+.dash-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+  gap: 20px;
+  margin-bottom: 32px;
+}
+
+.dash-card {
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  padding: 20px 22px;
+  box-shadow: var(--shadow);
+}
+.dash-card h3 { font-size: var(--font-size-sm); text-transform: uppercase; letter-spacing: .5px; color: var(--ink-muted); margin-bottom: 8px; }
+.dash-card .big-num { font-size: 36px; font-weight: 900; color: var(--ink); line-height: 1; }
+.dash-card .big-num a { color: var(--ink); text-decoration: none; }
+.dash-card .big-num a:hover { color: var(--accent); text-decoration: underline; }
+.dash-card .sub { font-size: var(--font-size-xs); color: var(--ink-muted); margin-top: 4px; }
+.dash-card .when { font-size: var(--font-size-xs); color: var(--ink-faint); margin-top: 8px; }
+
+/* Admin cards — created date+time chip (Steve HARD rule) */
+.when-chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  background: var(--surface-alt);
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  padding: 3px 8px;
+  font-size: var(--font-size-xs);
+  color: var(--ink-muted);
+  white-space: nowrap;
+}
+
+/* ── Org profile page ── */
+.org-header {
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  padding: 24px 28px;
+  margin-bottom: 24px;
+  box-shadow: var(--shadow);
+  display: flex;
+  gap: 22px;
+  align-items: flex-start;
+  flex-wrap: wrap;
+}
+.org-header-info { flex: 1; min-width: 200px; }
+.org-header h1 { font-size: var(--font-size-xl); margin-bottom: 8px; }
+.org-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
+
+.tab-bar {
+  display: flex;
+  gap: 2px;
+  border-bottom: 2px solid var(--border);
+  margin-bottom: 20px;
+  overflow-x: auto;
+}
+.tab-btn {
+  padding: 10px 18px;
+  background: none;
+  border: none;
+  font-size: var(--font-size-sm);
+  font-weight: 600;
+  color: var(--ink-muted);
+  cursor: pointer;
+  border-bottom: 3px solid transparent;
+  margin-bottom: -2px;
+  white-space: nowrap;
+  min-height: 44px;
+  border-radius: 6px 6px 0 0;
+}
+.tab-btn:hover { background: var(--accent-light); color: var(--accent); }
+.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); background: var(--accent-light); }
+
+.tab-panel { display: none; }
+.tab-panel.active { display: block; }
+
+/* ── Show-Me-Why score drawer ── */
+.drawer-overlay {
+  display: none;
+  position: fixed;
+  inset: 0;
+  background: rgba(0,0,0,.4);
+  z-index: 200;
+  align-items: flex-start;
+  justify-content: flex-end;
+}
+.drawer-overlay.open { display: flex; }
+.score-drawer {
+  width: min(500px, 98vw);
+  height: 100vh;
+  background: var(--surface);
+  border-left: 2px solid var(--border);
+  overflow-y: auto;
+  padding: 28px 28px 60px;
+  box-shadow: -4px 0 24px rgba(0,0,0,.12);
+}
+.score-drawer h2 { font-size: var(--font-size-lg); margin-bottom: 6px; }
+.score-drawer .close-btn {
+  position: absolute;
+  top: 18px;
+  right: 22px;
+  background: none;
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  padding: 6px 12px;
+  font-size: var(--font-size-sm);
+  cursor: pointer;
+  color: var(--ink-muted);
+}
+
+.factor-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 12px 0;
+  border-bottom: 1px solid var(--border);
+  font-size: var(--font-size-sm);
+}
+.factor-row:last-child { border-bottom: none; }
+.factor-name { color: var(--ink); font-weight: 600; flex: 1; }
+.factor-bar-wrap { flex: 2; height: 10px; background: var(--surface-alt); border-radius: 5px; overflow: hidden; }
+.factor-bar { height: 100%; background: var(--accent); border-radius: 5px; transition: width .3s; }
+.factor-contrib { font-size: var(--font-size-xs); font-weight: 700; color: var(--ink-muted); min-width: 40px; text-align: right; }
+
+/* ── Action buttons ── */
+.btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 9px 16px;
+  font-size: var(--font-size-sm);
+  font-weight: 600;
+  border-radius: var(--radius-sm);
+  cursor: pointer;
+  border: 2px solid transparent;
+  text-decoration: none;
+  min-height: 44px;
+  white-space: nowrap;
+  transition: background .1s, border-color .1s, color .1s;
+}
+.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
+.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; }
+.btn-outline { background: var(--surface); color: var(--accent); border-color: var(--accent); }
+.btn-outline:hover { background: var(--accent-light); }
+.btn-ghost { background: none; color: var(--ink-muted); border-color: var(--border); }
+.btn-ghost:hover { background: var(--surface-alt); color: var(--ink); }
+.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
+
+/* ── Evidence/sources ── */
+.evidence-card {
+  background: var(--surface-alt);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  padding: 14px 16px;
+  margin-bottom: 12px;
+  font-size: var(--font-size-sm);
+}
+.evidence-card .ev-title { font-weight: 700; margin-bottom: 4px; }
+.evidence-card .ev-meta { color: var(--ink-muted); font-size: var(--font-size-xs); display: flex; flex-wrap: wrap; gap: 8px; }
+.evidence-card .ev-excerpt { margin-top: 8px; color: var(--ink-muted); font-style: italic; font-size: var(--font-size-xs); border-left: 3px solid var(--border); padding-left: 10px; }
+
+/* ── Media-kit conflicts warning ── */
+.conflict-warning {
+  background: var(--warn-bg);
+  border: 2px solid #e6a800;
+  border-radius: var(--radius-sm);
+  padding: 12px 16px;
+  font-size: var(--font-size-sm);
+  color: var(--warn);
+  margin-bottom: 16px;
+}
+
+/* ── Card grid (ads gallery etc) ── */
+.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--card-min), 1fr)); gap: 20px; }
+.ad-card {
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  overflow: hidden;
+  box-shadow: var(--shadow);
+  display: flex;
+  flex-direction: column;
+}
+.ad-card img { width: 100%; height: 160px; object-fit: cover; background: var(--surface-alt); display: block; }
+.ad-card-body { padding: 14px 16px; flex: 1; }
+.ad-card-body .org-name { font-weight: 700; font-size: var(--font-size-base); margin-bottom: 4px; }
+.ad-card-body .ad-meta { font-size: var(--font-size-xs); color: var(--ink-muted); margin-bottom: 8px; }
+.ad-card-footer { padding: 10px 16px; border-top: 1px solid var(--border); display: flex; gap: 8px; flex-wrap: wrap; }
+
+/* ── Sponsors vs panelists (conference page) ── */
+.section-divider { font-size: var(--font-size-lg); font-weight: 800; color: var(--ink); margin: 24px 0 14px; padding-bottom: 8px; border-bottom: 2px solid var(--border); }
+
+/* ── Inline-list tables (contacts, sources etc.) ── */
+.inline-list { list-style: none; margin: 0; padding: 0; }
+.inline-list li { padding: 10px 0; border-bottom: 1px solid var(--border); font-size: var(--font-size-sm); display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
+.inline-list li:last-child { border-bottom: none; }
+
+/* ── Audit log ── */
+.audit-row { display: flex; gap: 14px; padding: 12px 0; border-bottom: 1px solid var(--border); font-size: var(--font-size-sm); }
+.audit-row:last-child { border-bottom: none; }
+.audit-action { font-weight: 700; color: var(--ink); }
+.audit-meta { color: var(--ink-muted); font-size: var(--font-size-xs); }
+
+/* ── Missing / empty states ── */
+.empty-state { text-align: center; padding: 60px 24px; color: var(--ink-muted); }
+.empty-state .em-icon { font-size: 48px; margin-bottom: 12px; }
+.empty-state p { font-size: var(--font-size-base); }
+
+.miss { color: var(--ink-faint); }
+
+/* ── Alerts ── */
+.alert { border-radius: var(--radius-sm); padding: 12px 16px; font-size: var(--font-size-sm); margin-bottom: 16px; }
+.alert-info    { background: var(--info-bg); color: var(--info); border: 1px solid #9ac4e8; }
+.alert-warn    { background: var(--warn-bg); color: var(--warn); border: 1px solid #f0c060; }
+.alert-danger  { background: var(--danger-bg); color: var(--danger); border: 1px solid #f0a0a0; }
+.alert-success { background: var(--success-bg); color: var(--success); border: 1px solid #a3d9b1; }
+
+/* ── Settings / admin layout ── */
+.settings-card {
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  padding: 22px 24px;
+  margin-bottom: 20px;
+  box-shadow: var(--shadow);
+}
+.settings-card h3 { font-size: var(--font-size-base); margin-bottom: 12px; }
+
+/* ── Media kit page ── */
+.mk-snapshot {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+  gap: 16px;
+  margin-bottom: 24px;
+}
+.mk-metric {
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  padding: 18px 20px;
+}
+.mk-metric .mk-val { font-size: 32px; font-weight: 900; color: var(--ink); }
+.mk-metric .mk-label { font-size: var(--font-size-sm); color: var(--ink-muted); margin-top: 4px; }
+.mk-metric .mk-source { font-size: var(--font-size-xs); color: var(--ink-faint); margin-top: 6px; }
+
+/* ── Pagination ── */
+.pagination { display: flex; align-items: center; gap: 10px; margin-top: 20px; font-size: var(--font-size-sm); }
+.pagination a, .pagination button {
+  padding: 8px 14px;
+  border: 1.5px solid var(--border);
+  border-radius: 6px;
+  background: var(--surface);
+  color: var(--accent);
+  font-weight: 600;
+  cursor: pointer;
+  text-decoration: none;
+  min-height: 44px;
+  display: inline-flex;
+  align-items: center;
+}
+.pagination a:hover, .pagination button:hover { background: var(--accent-light); }
+.pagination .pg-info { color: var(--ink-muted); }
+
+/* ── Progress bar for exports ── */
+.progress-wrap { background: var(--surface-alt); border-radius: 8px; height: 12px; overflow: hidden; margin: 8px 0; }
+.progress-bar { height: 100%; background: var(--accent); border-radius: 8px; transition: width .4s; }
+
+/* ── Responsive ── */
+@media (max-width: 860px) {
+  .left-rail { display: none; }
+  .page-content { padding: 16px 16px 60px; }
+  .site-header { flex-wrap: wrap; height: auto; padding: 12px 16px; gap: 10px; }
+  .header-search { max-width: 100%; order: 10; flex: 1 0 100%; }
+}
+
+/* ── Print ── */
+@media print {
+  .site-header, .left-rail, .dl-btn, .tab-bar, .tbl-controls,
+  .btn, .drawer-overlay, .score-drawer, .switch-group { display: none !important; }
+  body { background: #fff; font-size: 12pt; }
+  .page-content { padding: 0; }
+  table.adv-tbl { font-size: 10pt; }
+  .tbl-wrap { max-height: none; overflow: visible; }
+  a { color: var(--ink) !important; text-decoration: none; }
+}
diff --git a/public/js/app.js b/public/js/app.js
new file mode 100644
index 0000000..763e435
--- /dev/null
+++ b/public/js/app.js
@@ -0,0 +1,264 @@
+/* app.js — Global: CA/AZ/All + Verified-Only switches, Show-Me-Why drawer,
+   Download Everything button. Loaded on every page.
+   No framework, no build step, plain ES5-compatible vanilla JS. */
+
+'use strict';
+
+(function () {
+
+  /* ── Market + Verified switches (persist to localStorage) ─────────────────
+     The switches on the header update a query-param-based reload so the
+     server-rendered pages stay in sync. */
+
+  var MARKET_KEY   = 'rentv:market';    // 'CA' | 'AZ' | 'ALL'
+  var VERIFIED_KEY = 'rentv:verified';  // '1' | '0'
+
+  function getMarket()   { return localStorage.getItem(MARKET_KEY)   || 'ALL'; }
+  function getVerified() { return localStorage.getItem(VERIFIED_KEY) || '0'; }
+
+  function setMarket(v) {
+    localStorage.setItem(MARKET_KEY, v);
+    reloadWithParams();
+  }
+  function setVerified(v) {
+    localStorage.setItem(VERIFIED_KEY, v);
+    reloadWithParams();
+  }
+
+  function reloadWithParams() {
+    var url = new URL(location.href);
+    var m = getMarket(), v = getVerified();
+    if (m && m !== 'ALL') url.searchParams.set('market', m);
+    else url.searchParams.delete('market');
+    if (v === '1') url.searchParams.set('verifiedOnly', '1');
+    else url.searchParams.delete('verifiedOnly');
+    location.href = url.toString();
+  }
+
+  // Sync switch UI state on load
+  function syncSwitches() {
+    var m = getMarket(), v = getVerified();
+
+    // Market buttons
+    document.querySelectorAll('.market-switch button[data-market]').forEach(function (btn) {
+      btn.classList.toggle('active', btn.dataset.market === m);
+    });
+
+    // Verified checkbox/label
+    var verEl = document.querySelector('.verified-switch');
+    var verCb = document.querySelector('.verified-switch input[type="checkbox"]');
+    if (verEl) verEl.classList.toggle('active', v === '1');
+    if (verCb) verCb.checked = (v === '1');
+
+    // Apply URL params from persisted prefs on first load (only if URL is default)
+    var url = new URL(location.href);
+    var urlM = url.searchParams.get('market');
+    var urlV = url.searchParams.get('verifiedOnly');
+    // If the URL already has params, trust them (user navigated with link)
+    // If not, apply prefs (fresh page load from navigation)
+    var needsRedirect = false;
+    if (m !== 'ALL' && !urlM) needsRedirect = true;
+    if (v === '1' && urlV !== '1') needsRedirect = true;
+    if (needsRedirect && !window._skipRedirect) {
+      window._skipRedirect = true;
+      reloadWithParams();
+    }
+  }
+
+  document.addEventListener('DOMContentLoaded', function () {
+    syncSwitches();
+
+    // Market switch buttons
+    document.querySelectorAll('.market-switch button[data-market]').forEach(function (btn) {
+      btn.addEventListener('click', function () { setMarket(btn.dataset.market); });
+    });
+
+    // Verified-only toggle
+    var verSwitch = document.querySelector('.verified-switch');
+    if (verSwitch) {
+      verSwitch.addEventListener('click', function (e) {
+        if (e.target.tagName === 'INPUT') return; // let checkbox handle itself
+        setVerified(getVerified() === '1' ? '0' : '1');
+      });
+    }
+    var verCb = document.querySelector('.verified-switch input[type="checkbox"]');
+    if (verCb) {
+      verCb.addEventListener('change', function () {
+        setVerified(verCb.checked ? '1' : '0');
+      });
+    }
+
+    // Global header search → go to /search?q=…
+    var globalSearch = document.getElementById('global-search');
+    if (globalSearch) {
+      globalSearch.addEventListener('keydown', function (e) {
+        if (e.key === 'Enter' && globalSearch.value.trim()) {
+          location.href = '/advertisers?q=' + encodeURIComponent(globalSearch.value.trim());
+        }
+      });
+    }
+
+    // ── Download Everything button ─────────────────────────────────────────
+    var dlBtn = document.getElementById('download-everything-btn');
+    if (dlBtn) {
+      dlBtn.addEventListener('click', function (e) {
+        e.preventDefault();
+        startExport();
+      });
+    }
+
+    // ── Show-Me-Why drawer ─────────────────────────────────────────────────
+    // Opened by any element with data-smw-org="<org-id>"
+    document.addEventListener('click', function (e) {
+      var trigger = e.target.closest('[data-smw-org]');
+      if (!trigger) return;
+      e.preventDefault();
+      openScoreDrawer(trigger.dataset.smwOrg, trigger.dataset.smwName || '');
+    });
+
+    // Close drawer on overlay click or close button
+    var overlay = document.getElementById('score-drawer-overlay');
+    if (overlay) {
+      overlay.addEventListener('click', function (e) {
+        if (e.target === overlay) closeScoreDrawer();
+      });
+    }
+    document.addEventListener('keydown', function (e) {
+      if (e.key === 'Escape') closeScoreDrawer();
+    });
+  });
+
+  /* ── Download Everything ─────────────────────────────────────────────────── */
+  function startExport() {
+    var btn = document.getElementById('download-everything-btn');
+    if (btn) { btn.textContent = 'Preparing…'; btn.disabled = true; }
+
+    fetch('/api/v1/exports', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: 'DOWNLOAD_EVERYTHING' }) })
+      .then(function (r) { return r.json(); })
+      .then(function (d) {
+        if (d.error) throw new Error(d.message || d.error);
+        var exportId = d.id;
+        if (!exportId) {
+          // stub response — show msg
+          showExportMsg('Export queued. Refresh in a moment to download.');
+          return;
+        }
+        pollExport(exportId, 0);
+      })
+      .catch(function (err) {
+        showExportMsg('Export error: ' + (err.message || err));
+        if (btn) { btn.textContent = 'Download Everything'; btn.disabled = false; }
+      });
+  }
+
+  function pollExport(id, attempt) {
+    if (attempt > 30) {
+      showExportMsg('Export is taking a while — check /exports for status.');
+      resetDlBtn();
+      return;
+    }
+    fetch('/api/v1/exports/' + encodeURIComponent(id))
+      .then(function (r) { return r.json(); })
+      .then(function (d) {
+        if (d.status === 'DONE' && d.download_url) {
+          window.location = d.download_url;
+          resetDlBtn();
+        } else if (d.status === 'ERROR') {
+          showExportMsg('Export failed: ' + (d.error || 'unknown error'));
+          resetDlBtn();
+        } else {
+          var pct = d.progress_pct != null ? ' (' + d.progress_pct + '%)' : '';
+          var btn = document.getElementById('download-everything-btn');
+          if (btn) btn.textContent = 'Building export…' + pct;
+          setTimeout(function () { pollExport(id, attempt + 1); }, 2000);
+        }
+      })
+      .catch(function () {
+        setTimeout(function () { pollExport(id, attempt + 1); }, 3000);
+      });
+  }
+
+  function resetDlBtn() {
+    var btn = document.getElementById('download-everything-btn');
+    if (btn) { btn.textContent = 'Download Everything'; btn.disabled = false; }
+  }
+
+  function showExportMsg(msg) {
+    var el = document.getElementById('export-msg');
+    if (el) { el.textContent = msg; el.style.display = 'block'; }
+    else alert(msg);
+  }
+
+  /* ── Show-Me-Why score drawer ─────────────────────────────────────────────── */
+  function openScoreDrawer(orgId, orgName) {
+    var overlay = document.getElementById('score-drawer-overlay');
+    var body    = document.getElementById('score-drawer-body');
+    if (!overlay || !body) return;
+
+    body.innerHTML = '<p>Loading score for ' + tblEsc(orgName || orgId) + '…</p>';
+    overlay.classList.add('open');
+    document.body.style.overflow = 'hidden';
+
+    fetch('/api/v1/advertisers/' + encodeURIComponent(orgId))
+      .then(function (r) { return r.json(); })
+      .then(function (d) {
+        if (d.error) { body.innerHTML = '<p class="miss">' + tblEsc(d.message || d.error) + '</p>'; return; }
+        renderScoreDrawer(body, d);
+      })
+      .catch(function (err) {
+        body.innerHTML = '<p class="miss">Failed to load: ' + tblEsc(err.message) + '</p>';
+      });
+  }
+
+  function closeScoreDrawer() {
+    var overlay = document.getElementById('score-drawer-overlay');
+    if (overlay) overlay.classList.remove('open');
+    document.body.style.overflow = '';
+  }
+  window.closeScoreDrawer = closeScoreDrawer;
+
+  var FACTOR_LABELS = {
+    verifiedAdvertising: 'Verified advertising (22%)',
+    verifiedConferenceSpendSignal: 'Conference spend signal (15%)',
+    recency: 'Recency (12%)',
+    repeatActivity: 'Repeat activity (10%)',
+    californiaFit: 'California fit (10%)',
+    arizonaFit: 'Arizona fit (5%)',
+    categoryFit: 'Category fit (8%)',
+    rentvAudienceFit: 'RENTV audience fit (8%)',
+    contactCompleteness: 'Contact completeness (4%)',
+    evidenceQuality: 'Evidence quality (6%)',
+  };
+
+  function renderScoreDrawer(el, org) {
+    var score = (org.score || {});
+    var factors = score.factors || [];
+    var totalScore = score.score != null ? score.score : '—';
+    var html = '<h2>Score: ' + totalScore + ' / 100</h2>';
+    html += '<p style="color:var(--ink-muted);font-size:var(--font-size-sm)">RENTV sales opportunity score for <strong>' + tblEsc(org.display_name || '') + '</strong>. This ranks sales opportunity, not ad spend.</p>';
+
+    if (!factors.length) {
+      html += '<p class="miss">No score factors available. Run the scoring job to compute.</p>';
+    } else {
+      html += factors.map(function (f) {
+        var label = FACTOR_LABELS[f.factor] || f.factor;
+        var pct   = Math.round((f.value / 100) * 100);
+        return '<div class="factor-row">' +
+          '<span class="factor-name">' + tblEsc(label) + '</span>' +
+          '<div class="factor-bar-wrap"><div class="factor-bar" style="width:' + pct + '%"></div></div>' +
+          '<span class="factor-contrib">' + f.value + '</span>' +
+          '</div>';
+      }).join('');
+    }
+
+    // Evidence link
+    html += '<p style="margin-top:20px"><a href="/advertisers/' + tblEsc(String(org.id || '')) + '#evidence" class="btn btn-outline">View full evidence</a></p>';
+    el.innerHTML = html;
+  }
+
+  function tblEsc(s) {
+    return (s == null ? '' : String(s))
+      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+  }
+
+}());
diff --git a/public/js/table.js b/public/js/table.js
new file mode 100644
index 0000000..b47f146
--- /dev/null
+++ b/public/js/table.js
@@ -0,0 +1,513 @@
+/* table.js — Adjustable-columns engine for RENTV Advertiser Intelligence.
+   Mirrors the broker-grid.html pattern (Steve rule 2026-07-31):
+   resize + drag-reorder + toggle-visibility + persist localStorage + Reset.
+   Client-side search-all (space=AND) + multi-sort + density slider + expandable rows.
+   Reusable across all list pages: advertisers, contacts, ads, sources.
+
+   Usage:
+     <script src="/js/table.js"></script>
+     <script>
+       const T = new ATable({
+         tableId: 'main-tbl',
+         tbodyId: 'main-tbody',
+         theadId: 'main-thead',
+         countId: 'row-count',
+         searchId: 'tbl-search',
+         sortSelId: 'sort-sel',
+         fieldsId: 'field-toggles',
+         storageKey: 'advTable',      // localStorage prefix
+         cols: [ { k:'company', l:'Company', t:'s', g:'Identity', def:1 }, ... ],
+         data: [],                    // initial dataset
+         onRowClick: (row) => {},
+         renderCell: (row, col) => 'string or null for default',
+       });
+       T.setData(rows);
+     </script>
+*/
+
+'use strict';
+
+(function (global) {
+
+  // ── Helpers ────────────────────────────────────────────────────────────────
+  const esc = (s) => (s == null ? '' : String(s))
+    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+
+  const fmtDate = (s) => {
+    if (!s) return '—';
+    try {
+      const d = new Date(s);
+      if (isNaN(d)) return String(s);
+      return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+    } catch (e) { return String(s); }
+  };
+
+  const fmtDateOnly = (s) => {
+    if (!s) return '—';
+    try {
+      const d = new Date(s);
+      if (isNaN(d)) return String(s);
+      return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
+    } catch (e) { return String(s); }
+  };
+
+  const tel = (v) => v ? `<a href="tel:${esc(v)}">${esc(v)}</a>` : '<span class="miss">—</span>';
+  const mail = (v) => v ? `<a href="mailto:${esc(v)}">${esc(v)}</a>` : '<span class="miss">—</span>';
+  const web = (v) => {
+    if (!v) return '<span class="miss">—</span>';
+    const h = /^https?:/.test(v) ? v : 'https://' + v;
+    let label = v;
+    try { label = new URL(h).hostname.replace(/^www\./, ''); } catch (_) { label = v; }
+    return `<a href="${esc(h)}" target="_blank" rel="noopener noreferrer">${esc(label)}&nbsp;↗</a>`;
+  };
+  const li = (v) => v ? `<a href="${esc(v)}" target="_blank" rel="noopener noreferrer">LinkedIn&nbsp;↗</a>` : '<span class="miss">—</span>';
+
+  const STATUS_BADGE_CLASS = {
+    VERIFIED_ADVERTISER: 'badge-verified-advertiser',
+    VERIFIED_CONFERENCE_SPONSOR: 'badge-verified-sponsor',
+    VERIFIED_EXHIBITOR: 'badge-verified-exhibitor',
+    VERIFIED_MEDIA_PARTNER: 'badge-verified-media',
+    VERIFIED_CONTENT_PARTNER: 'badge-content-partner',
+    SPEAKER_OR_PANELIST_ONLY: 'badge-panelist',
+    PAST_ADVERTISER: 'badge-past-advertiser',
+    LIKELY_PROSPECT: 'badge-likely-prospect',
+    RESEARCH_NEEDED: 'badge-research-needed',
+    DISQUALIFIED: 'badge-disqualified',
+  };
+  const STATUS_LABELS = {
+    VERIFIED_ADVERTISER: 'Verified advertiser',
+    VERIFIED_CONFERENCE_SPONSOR: 'Verified conf. sponsor',
+    VERIFIED_EXHIBITOR: 'Verified exhibitor',
+    VERIFIED_MEDIA_PARTNER: 'Verified media partner',
+    VERIFIED_CONTENT_PARTNER: 'Content partner',
+    SPEAKER_OR_PANELIST_ONLY: 'Speaker / panelist',
+    PAST_ADVERTISER: 'Past advertiser',
+    LIKELY_PROSPECT: 'Likely prospect',
+    RESEARCH_NEEDED: 'Research needed',
+    DISQUALIFIED: 'Disqualified',
+  };
+
+  function statusBadge(status) {
+    const cls = STATUS_BADGE_CLASS[status] || 'badge-research-needed';
+    const lbl = STATUS_LABELS[status] || status || '—';
+    return `<span class="badge ${cls}">${esc(lbl)}</span>`;
+  }
+
+  function scoreBadge(score) {
+    if (score == null) return '<span class="miss">—</span>';
+    const n = Number(score);
+    const cls = n >= 70 ? 'score-high' : n >= 40 ? 'score-mid' : 'score-low';
+    return `<span class="score-badge ${cls}">${n}</span>`;
+  }
+
+  // Default cell renderer — custom renderCell overrides first.
+  function defaultCell(row, col) {
+    const v = row[col.k];
+    switch (col.t) {
+      case 'status': return statusBadge(v);
+      case 'score':  return scoreBadge(v);
+      case 'tel':    return tel(v);
+      case 'mail':   return mail(v);
+      case 'web':    return web(v);
+      case 'li':     return li(v);
+      case 'dt':     return `<span title="${esc(v ? new Date(v).toISOString() : '')}">${fmtDate(v)}</span>`;
+      case 'date':   return fmtDateOnly(v);
+      case 'n':      return `<span class="num">${v == null ? '—' : Number(v).toLocaleString()}</span>`;
+      case 'link':   return v ? `<a href="${esc(v)}">${esc(col.linkLabel || v)}</a>` : '<span class="miss">—</span>';
+      default:       return esc(v == null ? '—' : v);
+    }
+  }
+
+  // ── ATable class ───────────────────────────────────────────────────────────
+
+  class ATable {
+    constructor(opts) {
+      this.tableId   = opts.tableId;
+      this.theadId   = opts.theadId;
+      this.tbodyId   = opts.tbodyId;
+      this.countId   = opts.countId;
+      this.searchId  = opts.searchId;
+      this.sortSelId = opts.sortSelId;
+      this.fieldsId  = opts.fieldsId;
+      this.storageKey = opts.storageKey || 'aTable';
+      this.cols       = opts.cols || [];
+      this.data       = opts.data || [];
+      this._renderCell = opts.renderCell || null;
+      this.onRowClick  = opts.onRowClick || null;
+      this.expandedIds = new Set();
+
+      // Sort state
+      this.sortKey = opts.defaultSortKey || (this.cols[0] && this.cols[0].k) || '';
+      this.sortDir = opts.defaultSortDir || 1;
+
+      // Search
+      this.q = '';
+
+      // Persist column visibility + order
+      this._loadPrefs();
+
+      // Last rendered column signature (for resize invalidation)
+      this._lastColSig = '';
+
+      // Drag state
+      this._dragKey = null;
+
+      this._init();
+    }
+
+    // ── Prefs persistence ────────────────────────────────────────────────────
+    _loadPrefs() {
+      try {
+        this._viscol  = JSON.parse(localStorage.getItem(this.storageKey + ':viscol')  || '{}');
+        this._colorder = JSON.parse(localStorage.getItem(this.storageKey + ':colorder') || '[]');
+        const sk = localStorage.getItem(this.storageKey + ':sortKey');
+        const sd = localStorage.getItem(this.storageKey + ':sortDir');
+        if (sk) this.sortKey = sk;
+        if (sd === '1' || sd === '-1') this.sortDir = +sd;
+      } catch (_) {
+        this._viscol   = {};
+        this._colorder = [];
+      }
+    }
+
+    _saveViscol()   { try { localStorage.setItem(this.storageKey + ':viscol',   JSON.stringify(this._viscol)); } catch (_) {} }
+    _saveColorder() { try { localStorage.setItem(this.storageKey + ':colorder', JSON.stringify(this._colorder)); } catch (_) {} }
+    _saveSort()     {
+      try {
+        localStorage.setItem(this.storageKey + ':sortKey', this.sortKey);
+        localStorage.setItem(this.storageKey + ':sortDir', String(this.sortDir));
+      } catch (_) {}
+    }
+
+    // ── Column helpers ────────────────────────────────────────────────────────
+    _colVis(k) {
+      if (k in this._viscol) return !!this._viscol[k];
+      const col = this.cols.find((c) => c.k === k);
+      return col ? (col.def !== 0) : false;  // def:0 = off by default
+    }
+
+    _syncColOrder() {
+      const keys = this.cols.map((c) => c.k);
+      this._colorder = this._colorder.filter((k) => keys.includes(k));
+      keys.forEach((k) => { if (!this._colorder.includes(k)) this._colorder.push(k); });
+    }
+
+    _orderedCols() {
+      this._syncColOrder();
+      return this._colorder.map((k) => this.cols.find((c) => c.k === k)).filter(Boolean);
+    }
+
+    _visCols() { return this._orderedCols().filter((c) => this._colVis(c.k)); }
+
+    // ── Init ─────────────────────────────────────────────────────────────────
+    _init() {
+      this._bindSearch();
+      this._bindSort();
+      this._bindHeader();
+    }
+
+    _el(id) { return id ? document.getElementById(id) : null; }
+
+    _bindSearch() {
+      const el = this._el(this.searchId);
+      if (!el) return;
+      // Pre-fill from ?q= URL param
+      const urlQ = new URLSearchParams(location.search).get('q');
+      if (urlQ) { el.value = urlQ; this.q = urlQ.trim(); }
+      el.addEventListener('input', () => { this.q = el.value.trim(); this.render(); });
+    }
+
+    _bindSort() {
+      const sel = this._el(this.sortSelId);
+      if (!sel) return;
+      this._buildSortSel(sel);
+      sel.value = this.sortKey;
+      sel.addEventListener('change', () => {
+        this.sortKey = sel.value;
+        const c = this.cols.find((x) => x.k === this.sortKey);
+        this.sortDir = (c && (c.t === 'n' || c.t === 'dt' || c.t === 'date')) ? -1 : 1;
+        this._saveSort();
+        this.render();
+      });
+    }
+
+    _buildSortSel(sel) {
+      if (!sel) return;
+      const groups = {};
+      this.cols.forEach((c) => { (groups[c.g || 'Other'] = groups[c.g || 'Other'] || []).push(c); });
+      sel.innerHTML = Object.entries(groups).map(([g, cs]) =>
+        `<optgroup label="${esc(g)}">` + cs.map((c) => `<option value="${esc(c.k)}">${esc(c.l)}</option>`).join('') + '</optgroup>'
+      ).join('');
+      if (Array.from(sel.options).some((o) => o.value === this.sortKey)) sel.value = this.sortKey;
+    }
+
+    _bindHeader() {
+      const thead = this._el(this.theadId);
+      if (!thead) return;
+
+      // Sort click
+      thead.addEventListener('click', (e) => {
+        if (document.body.classList.contains('cr-dragging')) return;
+        const th = e.target.closest('th');
+        if (!th) return;
+        const k = th.dataset.k;
+        if (!k) return;
+        if (this.sortKey === k) this.sortDir *= -1;
+        else { this.sortKey = k; this.sortDir = 1; }
+        this._saveSort();
+        // Sync external sort select
+        const sel = this._el(this.sortSelId);
+        if (sel && Array.from(sel.options).some((o) => o.value === this.sortKey)) sel.value = this.sortKey;
+        this.render();
+      });
+
+      // Drag-to-reorder
+      let dragKey = null;
+      thead.addEventListener('dragstart', (e) => {
+        if (document.body.classList.contains('cr-dragging')) { e.preventDefault(); return; }
+        const th = e.target.closest('th');
+        if (!th) return;
+        dragKey = th.dataset.k;
+        e.dataTransfer.effectAllowed = 'move';
+        try { e.dataTransfer.setData('text/plain', dragKey); } catch (_) {}
+        th.classList.add('dragging');
+      });
+      thead.addEventListener('dragover', (e) => {
+        if (!dragKey) return;
+        const th = e.target.closest('th');
+        if (!th || th.dataset.k === dragKey) return;
+        e.preventDefault();
+        e.dataTransfer.dropEffect = 'move';
+        const r = th.getBoundingClientRect(), after = (e.clientX - r.left) > r.width / 2;
+        th.classList.toggle('dropR', after); th.classList.toggle('dropL', !after);
+      });
+      thead.addEventListener('dragleave', (e) => {
+        const th = e.target.closest('th'); if (th) { th.classList.remove('dropL', 'dropR'); }
+      });
+      thead.addEventListener('drop', (e) => {
+        if (!dragKey) return;
+        const th = e.target.closest('th'); if (!th) return;
+        e.preventDefault();
+        const tgt = th.dataset.k;
+        if (tgt && tgt !== dragKey) {
+          const r = th.getBoundingClientRect(), after = (e.clientX - r.left) > r.width / 2;
+          this._syncColOrder();
+          const from = this._colorder.indexOf(dragKey);
+          if (from >= 0) {
+            this._colorder.splice(from, 1);
+            let to = this._colorder.indexOf(tgt);
+            if (after) to += 1;
+            this._colorder.splice(to, 0, dragKey);
+            this._saveColorder();
+            this._lastColSig = '';
+            this.render();
+          }
+        }
+        dragKey = null;
+      });
+      thead.addEventListener('dragend', () => {
+        document.querySelectorAll(`#${this.theadId} th.dragging, #${this.theadId} th.dropL, #${this.theadId} th.dropR`)
+          .forEach((x) => x.classList.remove('dragging', 'dropL', 'dropR'));
+        dragKey = null;
+      });
+
+      // Expandable rows (tbody event delegation)
+      const tbody = this._el(this.tbodyId);
+      if (tbody) {
+        tbody.addEventListener('click', (e) => {
+          // Don't open expand on link/button clicks
+          if (e.target.closest('a') || e.target.closest('button')) return;
+          const tr = e.target.closest('tr[data-row-id]');
+          if (!tr) return;
+          const id = tr.dataset.rowId;
+          const exp = tr.nextElementSibling;
+          if (exp && exp.classList.contains('row-expand')) {
+            exp.remove();
+            this.expandedIds.delete(id);
+          } else {
+            // close others
+            this.expandedIds.clear();
+            document.querySelectorAll(`#${this.tbodyId} .row-expand`).forEach((x) => x.remove());
+            this.expandedIds.add(id);
+            const row = this.data.find((r) => String(r.id) === id);
+            if (row && this.onRowClick) {
+              const expTr = document.createElement('tr');
+              expTr.className = 'row-expand';
+              const td = document.createElement('td');
+              td.colSpan = 999;
+              td.innerHTML = '<em>Loading…</em>';
+              expTr.appendChild(td);
+              tr.after(expTr);
+              Promise.resolve(this.onRowClick(row, td));
+            }
+          }
+        });
+      }
+    }
+
+    // ── Field-toggle panel ────────────────────────────────────────────────────
+    buildFieldToggles() {
+      const el = this._el(this.fieldsId);
+      if (!el) return;
+      const groups = {};
+      this.cols.forEach((c) => { (groups[c.g || 'Other'] = groups[c.g || 'Other'] || []).push(c); });
+      el.innerHTML = Object.entries(groups).map(([g, cs]) =>
+        `<div class="fgrp">${esc(g)}</div>` +
+        cs.map((c) => `<label class="ftog"><input type="checkbox" data-ck="${esc(c.k)}" ${this._colVis(c.k) ? 'checked' : ''}><span>${esc(c.l)}</span></label>`).join('')
+      ).join('');
+
+      el.addEventListener('change', (e) => {
+        const cb = e.target.closest('input[data-ck]'); if (!cb) return;
+        this._viscol[cb.dataset.ck] = cb.checked;
+        this._saveViscol();
+        this.render();
+      });
+    }
+
+    resetColumns() {
+      this._viscol   = {};
+      this._colorder = [];
+      this._saveViscol();
+      this._saveColorder();
+      this._lastColSig = '';
+      // Clear column-resize localStorage keys
+      try {
+        Object.keys(localStorage).forEach((k) => {
+          if (k.startsWith('cr:' + location.pathname + ':')) localStorage.removeItem(k);
+        });
+      } catch (_) {}
+      this.buildFieldToggles();
+      this.render();
+    }
+
+    // ── Data + filter ─────────────────────────────────────────────────────────
+    setData(rows) {
+      this.data = rows || [];
+      this.render();
+    }
+
+    _filtered() {
+      let rows = this.data.slice();
+
+      // search-all-fields, space=AND
+      if (this.q) {
+        const terms = this.q.toLowerCase().split(/\s+/).filter(Boolean);
+        rows = rows.filter((r) => {
+          const hay = Object.keys(r).map((k) => {
+            const v = r[k]; return v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
+          }).join('  ').toLowerCase();
+          return terms.every((t) => hay.includes(t));
+        });
+      }
+
+      // sort
+      const col = this.cols.find((c) => c.k === this.sortKey) || this.cols[0];
+      if (col) {
+        rows.sort((a, b) => {
+          let x = a[col.k], y = b[col.k];
+          const xE = (x == null || x === ''), yE = (y == null || y === '');
+          if (xE && yE) return 0; if (xE) return 1; if (yE) return -1;
+          const xn = !isNaN(+x), yn = !isNaN(+y);
+          if (xn && yn) { x = +x; y = +y; }
+          else { x = String(x).toLowerCase(); y = String(y).toLowerCase(); }
+          if (x < y) return -this.sortDir; if (x > y) return this.sortDir; return 0;
+        });
+      }
+      return rows;
+    }
+
+    // ── Render ────────────────────────────────────────────────────────────────
+    render() {
+      const rows = this._filtered();
+      const cols = this._visCols();
+
+      // count
+      const countEl = this._el(this.countId);
+      if (countEl) countEl.textContent = `${rows.length.toLocaleString()} of ${this.data.length.toLocaleString()} records`;
+
+      // thead
+      const thead = this._el(this.theadId);
+      if (thead) {
+        thead.innerHTML = '<tr>' + cols.map((c) =>
+          `<th data-k="${esc(c.k)}" draggable="true" class="dragcol${this.sortKey === c.k ? (this.sortDir > 0 ? ' asc' : ' desc') : ''}">${esc(c.l)}</th>`
+        ).join('') + '</tr>';
+      }
+
+      // tbody
+      const tbody = this._el(this.tbodyId);
+      if (tbody) {
+        tbody.innerHTML = rows.map((r) => {
+          const id = r.id || r.id || '';
+          return `<tr data-row-id="${esc(String(id))}">` +
+            cols.map((c) => {
+              let html = null;
+              if (this._renderCell) html = this._renderCell(r, c);
+              if (html == null) html = defaultCell(r, c);
+              const numCls = (c.t === 'n') ? ' class="num"' : '';
+              return `<td${numCls}>${html}</td>`;
+            }).join('') +
+            '</tr>';
+        }).join('');
+      }
+
+      // column-sig check for resize invalidation
+      const sig = cols.map((c) => c.k).join(',');
+      if (sig !== this._lastColSig) {
+        this._lastColSig = sig;
+        // Invalidate ColResize if present
+        if (window.ColResize && window.ColResize.refresh) window.ColResize.refresh();
+      }
+
+      // sync field toggles visibility
+      this.buildFieldToggles();
+    }
+  }
+
+  // ── Density slider helper ────────────────────────────────────────────────────
+  function initDensitySlider(sliderId, storageKey, minPx, maxPx, defaultVal) {
+    const sl = document.getElementById(sliderId);
+    if (!sl) return;
+    minPx = minPx || 200; maxPx = maxPx || 450;
+    const stored = parseInt(localStorage.getItem(storageKey + ':density'), 10);
+    const val = (stored >= 1 && stored <= 10) ? stored : (defaultVal || 5);
+    sl.value = val;
+    function apply(v) {
+      const px = Math.round(maxPx - (Math.max(1, Math.min(10, v)) - 1) * (maxPx - minPx) / 9);
+      document.documentElement.style.setProperty('--card-min', px + 'px');
+    }
+    apply(val);
+    sl.addEventListener('input', () => { apply(+sl.value); localStorage.setItem(storageKey + ':density', sl.value); });
+  }
+
+  // ── Export helpers ────────────────────────────────────────────────────────────
+  function exportCSV(rows, cols, filename) {
+    const lines = [
+      cols.map((c) => '"' + c.l.replace(/"/g, '""') + '"').join(','),
+      ...rows.map((r) => cols.map((c) => {
+        const v = r[c.k]; const s = v == null ? '' : String(v);
+        return '"' + s.replace(/"/g, '""') + '"';
+      }).join(','))
+    ];
+    const blob = new Blob([lines.join('\n')], { type: 'text/csv' });
+    const a = document.createElement('a');
+    a.href = URL.createObjectURL(blob);
+    a.download = filename || 'export.csv';
+    a.click();
+    URL.revokeObjectURL(a.href);
+  }
+
+  // Expose globally
+  global.ATable            = ATable;
+  global.initDensitySlider = initDensitySlider;
+  global.tblExportCSV      = exportCSV;
+  global.statusBadge       = statusBadge;
+  global.scoreBadge        = scoreBadge;
+  global.tblEsc            = esc;
+  global.tblFmtDate        = fmtDate;
+  global.tblFmtDateOnly    = fmtDateOnly;
+  global.tblTel            = tel;
+  global.tblMail           = mail;
+  global.tblWeb            = web;
+  global.tblLi             = li;
+
+}(window));
diff --git a/scripts/audit-source-policies.js b/scripts/audit-source-policies.js
new file mode 100644
index 0000000..7dc8ae6
--- /dev/null
+++ b/scripts/audit-source-policies.js
@@ -0,0 +1,161 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * CLI: node scripts/audit-source-policies.js
+ *
+ * Loads all source_policies from the database, runs
+ * lib/compliance/source-policy.validateSourcePolicy on each, and prints a
+ * PASS/FAIL table. Exits with code 1 if any ENABLED policy is illegal.
+ *
+ * Also validates the structural integrity of each policy regardless of its
+ * enabled state so developers can catch problems before enabling.
+ *
+ * @module scripts/audit-source-policies
+ */
+
+require('../lib/env');
+const { pool, query } = require('../db');
+
+let validateSourcePolicy;
+let assertSourceEnabledLegal;
+try {
+  ({ validateSourcePolicy, assertSourceEnabledLegal } =
+    require('../lib/compliance/source-policy'));
+} catch (err) {
+  console.error('[audit-sources] Could not load source-policy validator:', err.message);
+  process.exit(1);
+}
+
+/**
+ * Map DB row snake_case to the JS camelCase shape expected by validateSourcePolicy.
+ * @param {Object} row
+ * @returns {Object}
+ */
+function rowToPolicy(row) {
+  return {
+    sourceKey:              row.source_key,
+    displayName:            row.display_name,
+    owner:                  row.owner,
+    baseUrl:                row.base_url,
+    accessMethod:           row.access_method,
+    allowsAutomatedAccess:  row.allows_automated_access,
+    allowsScreenshotCapture: row.allows_screenshot_capture,
+    allowsInternalStorage:  row.allows_internal_storage,
+    allowsExport:           row.allows_export,
+    prohibitedHosts:        row.prohibited_hosts,
+    permittedPaths:         row.permitted_paths,
+    prohibitedPaths:        row.prohibited_paths,
+    maxRequestsPerMinute:   row.max_requests_per_minute,
+    minimumDelayMs:         row.minimum_delay_ms,
+    retentionDays:          row.retention_days,
+    enabled:                row.enabled,
+    reviewedAt:             row.reviewed_at,
+    reviewNotes:            row.review_notes,
+  };
+}
+
+(async () => {
+  let exitCode = 0;
+
+  try {
+    const res = await query(
+      `SELECT * FROM source_policies ORDER BY enabled DESC, display_name`
+    );
+    const rows = res.rows;
+
+    if (rows.length === 0) {
+      console.log('[audit-sources] No source policies found in the database.');
+      console.log('[audit-sources] Run db:seed to load initial policies.');
+      process.exit(0);
+    }
+
+    // Table header
+    const COL_KEY    = 36;
+    const COL_ENABLED = 9;
+    const COL_RESULT = 8;
+
+    const hr = '-'.repeat(COL_KEY + COL_ENABLED + COL_RESULT + 40);
+    console.log('\nSource Policy Audit');
+    console.log(hr);
+    console.log(
+      'SOURCE_KEY'.padEnd(COL_KEY) +
+      'ENABLED'.padEnd(COL_ENABLED) +
+      'RESULT'.padEnd(COL_RESULT) +
+      'NOTES'
+    );
+    console.log(hr);
+
+    const enabledIllegal = [];
+    const allResults = [];
+
+    for (const row of rows) {
+      const policy = rowToPolicy(row);
+      const { valid, errors } = validateSourcePolicy(policy);
+
+      // Also check the harder assert for enabled policies
+      let assertErr = null;
+      if (policy.enabled) {
+        try {
+          assertSourceEnabledLegal(policy);
+        } catch (e) {
+          assertErr = e.message;
+          valid === false; // already false if errors exist
+        }
+      }
+
+      const isIllegal = !valid || !!assertErr;
+      const result = isIllegal ? 'FAIL' : 'PASS';
+
+      if (isIllegal && policy.enabled) {
+        enabledIllegal.push({ policy, errors, assertErr });
+      }
+
+      const notes = [
+        ...errors,
+        assertErr ? `[ASSERT] ${assertErr}` : null,
+      ].filter(Boolean).join('; ') || (valid ? 'OK' : 'see errors');
+
+      const resultTag = isIllegal ? `\x1b[31m${result}\x1b[0m` : `\x1b[32m${result}\x1b[0m`;
+
+      console.log(
+        (policy.sourceKey || '(no key)').slice(0, COL_KEY - 1).padEnd(COL_KEY) +
+        String(policy.enabled).padEnd(COL_ENABLED) +
+        resultTag.padEnd(COL_RESULT + 10) + // +10 for ANSI codes
+        notes.slice(0, 120)
+      );
+
+      allResults.push({ sourceKey: policy.sourceKey, valid, errors, enabled: policy.enabled });
+    }
+
+    console.log(hr);
+    const passed = allResults.filter((r) => r.valid).length;
+    const failed = allResults.length - passed;
+    const enabledFailed = enabledIllegal.length;
+
+    console.log(`\nSummary: ${rows.length} policies — ${passed} PASS, ${failed} FAIL`);
+
+    if (enabledIllegal.length > 0) {
+      console.error(`\n\x1b[31mERROR: ${enabledFailed} ENABLED policy/policies are ILLEGAL (spec §6):\x1b[0m`);
+      for (const { policy, errors, assertErr } of enabledIllegal) {
+        console.error(`  SOURCE: ${policy.sourceKey}`);
+        for (const e of errors) console.error(`    - ${e}`);
+        if (assertErr) console.error(`    - [ASSERT] ${assertErr}`);
+      }
+      console.error('\nDisable or correct these policies before enabling research automation.');
+      exitCode = 1;
+    } else if (failed > 0) {
+      console.log(`\n${failed} policy/policies have validation issues but are not enabled.`);
+      console.log('No action required until you enable them.');
+    } else {
+      console.log('\nAll enabled policies are compliant with spec §6.');
+    }
+
+  } catch (err) {
+    console.error('[audit-sources] Fatal error:', err.message);
+    console.error(err.stack);
+    exitCode = 1;
+  } finally {
+    try { await pool.end(); } catch (_) {}
+    process.exit(exitCode);
+  }
+})();
diff --git a/scripts/export-all.js b/scripts/export-all.js
new file mode 100644
index 0000000..1684de8
--- /dev/null
+++ b/scripts/export-all.js
@@ -0,0 +1,49 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * CLI: node scripts/export-all.js [outDir]
+ *
+ * Runs buildDownloadEverything and prints the ZIP path + row counts to stdout.
+ * outDir defaults to ./data/exports (relative to project root).
+ *
+ * Usage:
+ *   node scripts/export-all.js
+ *   node scripts/export-all.js /tmp/adintel-export-test
+ *
+ * @module scripts/export-all
+ */
+
+require('../lib/env');
+const path = require('path');
+const { buildDownloadEverything } = require('../src/export/build');
+const { pool } = require('../db');
+
+const outDir = process.argv[2]
+  ? path.resolve(process.argv[2])
+  : path.join(__dirname, '..', 'data', 'exports');
+
+console.log('[export-all] Starting Download Everything export...');
+console.log(`[export-all] Output directory: ${outDir}`);
+
+(async () => {
+  let exitCode = 0;
+  try {
+    const { zipPath, rowCounts } = await buildDownloadEverything({ outDir });
+
+    console.log('\n[export-all] Export complete.');
+    console.log('[export-all] ZIP path:', zipPath);
+    console.log('[export-all] Row counts:');
+    for (const [k, v] of Object.entries(rowCounts)) {
+      console.log(`  ${k.padEnd(30)} ${v}`);
+    }
+    console.log('[export-all] Done.');
+  } catch (err) {
+    console.error('[export-all] FAILED:', err.message);
+    console.error(err.stack);
+    exitCode = 1;
+  } finally {
+    // Cleanly drain the pg pool so Node exits
+    try { await pool.end(); } catch (_) {}
+    process.exit(exitCode);
+  }
+})();
diff --git a/scripts/import-google.js b/scripts/import-google.js
new file mode 100644
index 0000000..7c75d96
--- /dev/null
+++ b/scripts/import-google.js
@@ -0,0 +1,173 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * CLI: node scripts/import-google.js <ga4|gsc|google-ads>
+ *
+ * Calls src/connectors/* if present (guarded with try/require).
+ * Prints import summary. When connectors are unavailable, prints
+ * guidance on how to use the CSV upload fallback instead.
+ *
+ * Spec §6.20: Use only official APIs for GA4, GSC, Google Ads.
+ * No unofficial scraping, no cookie injection.
+ *
+ * @module scripts/import-google
+ */
+
+require('../lib/env');
+const { pool } = require('../db');
+
+const KIND = (process.argv[2] || '').toLowerCase();
+const VALID = ['ga4', 'gsc', 'google-ads'];
+
+if (!VALID.includes(KIND)) {
+  console.error('Usage: node scripts/import-google.js <ga4|gsc|google-ads>');
+  console.error('Valid kinds:', VALID.join(', '));
+  process.exit(1);
+}
+
+// ---------------------------------------------------------------------------
+// Try to load the real connector
+// ---------------------------------------------------------------------------
+let connector = null;
+const connectorKey = KIND === 'google-ads' ? 'google-ads' : KIND;
+const connectorPaths = [
+  `../src/connectors/${connectorKey}`,
+  `../src/connectors/${connectorKey}/index`,
+];
+
+for (const cp of connectorPaths) {
+  try {
+    connector = require(cp);
+    break;
+  } catch (_) {
+    // not yet built
+  }
+}
+
+// ---------------------------------------------------------------------------
+// CSV fallback guidance
+// ---------------------------------------------------------------------------
+const CSV_GUIDANCE = {
+  ga4: {
+    title: 'Google Analytics 4',
+    envVars: ['GOOGLE_SERVICE_ACCOUNT_JSON_BASE64', 'GA4_PROPERTY_ID'],
+    csvFallback: [
+      '1. In GA4, go to Reports > Export > CSV',
+      '2. Save the file locally',
+      '3. POST it to /api/v1/imports with source=ga4 as multipart/form-data',
+      '   curl -F "file=@ga4-export.csv" -F "source=ga4" http://localhost:3001/api/v1/imports',
+    ],
+    docsUrl: 'https://developers.google.com/analytics/devguides/reporting/data/v1',
+    setupSteps: [
+      '1. Create a Google Cloud service account with GA4 Viewer role.',
+      '2. Download the JSON key file.',
+      '3. Base64-encode it: base64 -i key.json',
+      '4. Set GOOGLE_SERVICE_ACCOUNT_JSON_BASE64 in .env',
+      '5. Set GA4_PROPERTY_ID to your numeric property ID (e.g. 123456789)',
+      '6. Re-run: node scripts/import-google.js ga4',
+    ],
+  },
+  gsc: {
+    title: 'Google Search Console',
+    envVars: ['GOOGLE_SERVICE_ACCOUNT_JSON_BASE64', 'GSC_SITE_URL'],
+    csvFallback: [
+      '1. In GSC, go to Performance > Export > Download CSV',
+      '2. Save the file locally',
+      '3. POST it to /api/v1/imports with source=gsc',
+      '   curl -F "file=@gsc-export.csv" -F "source=gsc" http://localhost:3001/api/v1/imports',
+    ],
+    docsUrl: 'https://developers.google.com/webmaster-tools/v1/api_reference_index',
+    setupSteps: [
+      '1. Same service account as GA4 — add it as a full user in GSC settings.',
+      '2. Set GSC_SITE_URL to the exact property URL (e.g. https://rentv.com/)',
+      '3. Re-run: node scripts/import-google.js gsc',
+    ],
+  },
+  'google-ads': {
+    title: 'Google Ads',
+    envVars: [
+      'GOOGLE_ADS_ENABLED', 'GOOGLE_ADS_DEVELOPER_TOKEN', 'GOOGLE_ADS_CUSTOMER_ID',
+      'GOOGLE_ADS_CLIENT_ID', 'GOOGLE_ADS_CLIENT_SECRET', 'GOOGLE_ADS_REFRESH_TOKEN',
+    ],
+    csvFallback: [
+      '1. In Google Ads, go to Reports > Predefined reports > Campaign',
+      '2. Download as CSV',
+      '3. POST to /api/v1/imports with source=google-ads',
+    ],
+    docsUrl: 'https://developers.google.com/google-ads/api/docs/start',
+    setupSteps: [
+      '1. Apply for a Google Ads developer token (can take 1-3 business days).',
+      '2. Create an OAuth2 client ID with Google Ads scope.',
+      '3. Generate a refresh token using OAuth2 playground.',
+      '4. Set all GOOGLE_ADS_* vars in .env',
+      '5. Set GOOGLE_ADS_ENABLED=true',
+      '6. Re-run: node scripts/import-google.js google-ads',
+    ],
+  },
+};
+
+// ---------------------------------------------------------------------------
+// Check env vars
+// ---------------------------------------------------------------------------
+function checkEnvVars(vars) {
+  const missing = vars.filter((v) => !process.env[v]);
+  return missing;
+}
+
+// ---------------------------------------------------------------------------
+// Run import
+// ---------------------------------------------------------------------------
+(async () => {
+  console.log(`\n[import-google] Importing: ${KIND}`);
+  const guide = CSV_GUIDANCE[KIND];
+
+  if (connector && typeof connector.runImport === 'function') {
+    // Real connector present
+    const missing = checkEnvVars(guide.envVars);
+    if (missing.length > 0) {
+      console.error(`[import-google] Missing required env vars for ${KIND}:`);
+      missing.forEach((v) => console.error(`  - ${v}`));
+      console.error('\nSetup steps:');
+      guide.setupSteps.forEach((s) => console.error(' ', s));
+      await pool.end();
+      process.exit(1);
+    }
+
+    console.log('[import-google] Connector found. Running import...');
+    try {
+      const result = await connector.runImport();
+      console.log('[import-google] Import complete:');
+      console.log(JSON.stringify(result, null, 2));
+    } catch (err) {
+      console.error('[import-google] Import failed:', err.message);
+      console.error(err.stack);
+      await pool.end();
+      process.exit(1);
+    }
+    await pool.end();
+    return;
+  }
+
+  // Connector not yet built — print guidance
+  console.log(`[import-google] ${guide.title} connector not yet installed.`);
+  console.log('\nRequired environment variables:');
+  guide.envVars.forEach((v) => {
+    const set = !!process.env[v];
+    console.log(`  ${set ? '[SET]' : '[MISSING]'} ${v}`);
+  });
+
+  if (KIND === 'google-ads' && process.env.GOOGLE_ADS_ENABLED !== 'true') {
+    console.log('\n  Note: GOOGLE_ADS_ENABLED must be "true" to activate this connector.');
+  }
+
+  console.log('\nSetup steps:');
+  guide.setupSteps.forEach((s) => console.log(' ', s));
+
+  console.log(`\nAPI documentation: ${guide.docsUrl}`);
+
+  console.log('\nCSV upload fallback (works now without connector):');
+  guide.csvFallback.forEach((s) => console.log(' ', s));
+
+  await pool.end();
+  process.exit(0);
+})();
diff --git a/scripts/research.js b/scripts/research.js
new file mode 100644
index 0000000..2b54336
--- /dev/null
+++ b/scripts/research.js
@@ -0,0 +1,134 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * CLI stub: node scripts/research.js <california|arizona|conferences>
+ *
+ * Wires to src/search/provider + src/adapters/manual-review IF present
+ * (guarded with try/require). When modules are unavailable, prints the
+ * manual search URLs that a human researcher should open instead.
+ *
+ * This is intentionally conservative — no live fetching from this stub
+ * (spec §6: no bypassing auth, robots, rate limits, or anti-bot controls).
+ *
+ * @module scripts/research
+ */
+
+require('../lib/env');
+
+const MARKET = (process.argv[2] || '').toLowerCase();
+
+if (!['california', 'arizona', 'conferences'].includes(MARKET)) {
+  console.error('Usage: node scripts/research.js <california|arizona|conferences>');
+  console.error('Valid markets: california, arizona, conferences');
+  process.exit(1);
+}
+
+// Try to load the real search provider module (may not exist yet)
+let provider = null;
+try {
+  provider = require('../src/search/provider');
+} catch (_) {
+  // Not yet built — fall through to manual guidance
+}
+
+// Try to load the manual-review adapter
+let manualReview = null;
+try {
+  manualReview = require('../src/adapters/manual-review');
+} catch (_) {
+  // Not yet built — fall through
+}
+
+// ---------------------------------------------------------------------------
+// Manual research URL generators
+// ---------------------------------------------------------------------------
+
+/**
+ * Build the list of recommended manual search URLs for a given market.
+ * @param {'california'|'arizona'|'conferences'} market
+ * @returns {string[]}
+ */
+function manualSearchUrls(market) {
+  const rentv = 'https://rentv.com';
+  const rentvReview = 'https://rentvreview.com';
+
+  if (market === 'california') {
+    return [
+      `${rentv}/sponsors — RENTV CA sponsor pages`,
+      `${rentvReview}/advertisers — RENTV Review CA advertisers`,
+      'https://www.naiop.org/chapters/socal/events — NAIOP SoCal events & sponsors',
+      'https://www.boma.org/BOMA/Chapters/Greater_Los_Angeles/Events.aspx — BOMA LA sponsors',
+      'https://sccai.org/events — SCCAI OC/IE events',
+      'https://www.sfbart.com — SF Bay Area CRE events',
+      'Manual: search Google CSE for site:rentv.com "sponsor" OR "advertiser" california',
+    ];
+  }
+
+  if (market === 'arizona') {
+    return [
+      `${rentv}/arizona — RENTV AZ market page`,
+      'https://www.naiop.org/chapters/az/events — NAIOP AZ events & sponsors',
+      'https://www.boma.org/BOMA/Chapters/Phoenix/Events.aspx — BOMA Phoenix sponsors',
+      'https://www.sior.com/events — SIOR Phoenix chapter',
+      'Manual: search Google CSE for site:rentv.com "sponsor" OR "advertiser" arizona',
+    ];
+  }
+
+  // conferences
+  return [
+    'https://rentv.com/cre-talk — RENTV CRE Talk sponsors',
+    'https://www.naiop.org/events — NAIOP national conferences',
+    'https://www.bisnow.com/los-angeles/events — Bisnow LA sponsors',
+    'https://www.globest.com/events — GlobeSt CRE conference list',
+    'https://cretech.com/events — CREtech tech conference sponsors',
+    'https://www.corecnet.org/events — CoreNet Global chapters',
+    'Manual: search for "[event name] sponsors [year]" on Google',
+  ];
+}
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
+
+(async () => {
+  console.log(`\n[research] Market: ${MARKET}`);
+  console.log('[research] Spec §6 compliance: no automated scraping of prohibited hosts.\n');
+
+  if (provider && typeof provider.runResearch === 'function') {
+    // Real provider is wired — delegate to it
+    console.log('[research] Using installed search provider:', provider.name || 'unknown');
+    try {
+      const result = await provider.runResearch({ market: MARKET });
+      console.log('[research] Results:', JSON.stringify(result, null, 2));
+    } catch (err) {
+      console.error('[research] Provider error:', err.message);
+      process.exit(1);
+    }
+    return;
+  }
+
+  if (manualReview && typeof manualReview.queueManualSearch === 'function') {
+    // Manual review adapter is wired — queue the search
+    console.log('[research] Manual review adapter found — queueing search items.');
+    try {
+      const queued = await manualReview.queueManualSearch({ market: MARKET });
+      console.log('[research] Queued', queued.count, 'manual review items.');
+    } catch (err) {
+      console.error('[research] Manual review adapter error:', err.message);
+    }
+  }
+
+  // Guidance output (always printed as a fallback / supplemental)
+  console.log('[research] Research modules not yet fully available.');
+  console.log('[research] Open these URLs manually in your browser to find advertiser evidence:\n');
+  const urls = manualSearchUrls(MARKET);
+  urls.forEach((u, i) => console.log(`  ${i + 1}. ${u}`));
+
+  console.log('\n[research] After reviewing each page:');
+  console.log('  - Use the admin UI at /advertisers/new to add organizations.');
+  console.log('  - Use /ads/new to record ad sightings with source URLs.');
+  console.log('  - Use /events to record conference sponsor information.');
+  console.log('[research] All entries require a source_page_url and observed_at date.');
+
+  process.exit(0);
+})();
diff --git a/src/adapters/base.js b/src/adapters/base.js
new file mode 100644
index 0000000..dc13aaa
--- /dev/null
+++ b/src/adapters/base.js
@@ -0,0 +1,242 @@
+'use strict';
+/**
+ * Public-source adapter contract + common pipeline (spec §10).
+ *
+ * AdvertiserSourceAdapter is the base class every source implements:
+ *   - policy                  the SourcePolicy this adapter runs under
+ *   - discover(cursor)        async generator of DiscoveredSourceItem
+ *   - fetch(item)             retrieve bytes (only when policy permits)
+ *   - parse(input)            async generator of extractions
+ *
+ * runPipeline() provides the shared machinery the spec mandates:
+ *   policy validation before every run, per-host throttle, exponential backoff
+ *   with jitter, conditional-request hook, idempotent upsert hook, dead-letter
+ *   recording into ingestion_jobs (status DEAD_LETTER), dry-run mode,
+ *   incremental cursor, and job logging into ingestion_runs.
+ */
+
+const { assertSourceEnabledLegal } = require('../../lib/compliance/source-policy');
+
+/**
+ * Base adapter. Concrete adapters override discover/fetch/parse. The defaults
+ * are deliberately inert (yield nothing / refuse) so a half-built adapter can
+ * never accidentally fetch.
+ */
+class AdvertiserSourceAdapter {
+  constructor(policy) {
+    if (!policy || typeof policy !== 'object') {
+      throw new Error('AdvertiserSourceAdapter: a SourcePolicy is required');
+    }
+    this.policy = policy;
+  }
+
+  // eslint-disable-next-line require-yield
+  async *discover(_cursor) {
+    // Default: discover nothing. Concrete adapters override.
+    return;
+  }
+
+  async fetch(_item) {
+    throw new Error(
+      `${this.constructor.name}.fetch not implemented — adapter must define fetch or be manual_review_only`
+    );
+  }
+
+  // eslint-disable-next-line require-yield
+  async *parse(_input) {
+    return;
+  }
+}
+
+/** Exponential backoff with full jitter, capped. */
+function backoffDelay(attempt, baseMs = 500, capMs = 30000) {
+  const exp = Math.min(capMs, baseMs * 2 ** attempt);
+  return Math.floor(Math.random() * exp); // full jitter
+}
+
+function sleep(ms) {
+  return new Promise((r) => setTimeout(r, ms));
+}
+
+/** Simple per-host throttle used inside the pipeline (delegates to policy). */
+async function throttle(policy, lastByHost, host) {
+  const rpm = policy.maxRequestsPerMinute || Number(process.env.DEFAULT_REQUESTS_PER_MINUTE || 6);
+  const minDelay = policy.minimumDelayMs != null ? policy.minimumDelayMs : Math.ceil(60000 / rpm);
+  const last = lastByHost.get(host) || 0;
+  const since = Date.now() - last;
+  if (last && since < minDelay) await sleep(minDelay - since);
+  lastByHost.set(host, Date.now());
+}
+
+function hostOf(url) {
+  try {
+    return new URL(url).hostname.toLowerCase();
+  } catch (_e) {
+    return 'unknown';
+  }
+}
+
+/**
+ * runPipeline(adapter, opts)
+ *
+ * opts:
+ *   db        { query } — the db module (required for real logging; optional in
+ *                         dry-run tests where you can pass a stub or omit)
+ *   dryRun    boolean   — discover + parse but never fetch or upsert
+ *   upsert    async(extraction, ctx) => void  — idempotent write callback
+ *   cursor    string    — incremental cursor passed to discover()
+ *   maxItems  number    — safety cap
+ *   maxAttempts number  — per-item retry budget before dead-letter (default 3)
+ *   conditionalRequest  async(item) => ({skip:boolean, headers?:object})
+ *                        — conditional-request hook (etag/last-modified)
+ *
+ * Returns { runId, stats, cursor }.
+ */
+async function runPipeline(adapter, opts = {}) {
+  if (!adapter || !(adapter instanceof AdvertiserSourceAdapter)) {
+    throw new Error('runPipeline: adapter must extend AdvertiserSourceAdapter');
+  }
+  const policy = adapter.policy;
+
+  // §10 — policy validation BEFORE every run. Throws on an illegal enabled source.
+  assertSourceEnabledLegal(policy);
+
+  const db = opts.db || null;
+  const dryRun = opts.dryRun === true;
+  const upsert = typeof opts.upsert === 'function' ? opts.upsert : null;
+  const maxItems = opts.maxItems || 1000;
+  const maxAttempts = opts.maxAttempts || 3;
+  const conditionalRequest =
+    typeof opts.conditionalRequest === 'function' ? opts.conditionalRequest : null;
+
+  const stats = {
+    discovered: 0,
+    fetched: 0,
+    skippedConditional: 0,
+    parsed: 0,
+    upserted: 0,
+    deadLettered: 0,
+    errors: 0,
+  };
+  const lastByHost = new Map();
+
+  // Open a run row (ingestion_runs).
+  let runId = null;
+  if (db && db.query) {
+    const r = await db.query(
+      `INSERT INTO ingestion_runs (source_key, status, dry_run, stats)
+       VALUES ($1, 'RUNNING', $2, $3::jsonb) RETURNING id`,
+      [policy.sourceKey, dryRun, JSON.stringify(stats)]
+    );
+    runId = r.rows[0].id;
+  }
+
+  const deadLetter = async (item, err) => {
+    stats.deadLettered += 1;
+    if (db && db.query && runId) {
+      await db.query(
+        `INSERT INTO ingestion_jobs (run_id, job_type, payload, status, attempts, last_error)
+         VALUES ($1, 'FETCH_PARSE', $2::jsonb, 'DEAD_LETTER', $3, $4)`,
+        [runId, JSON.stringify(item || {}), maxAttempts, String((err && err.message) || err)]
+      );
+    }
+  };
+
+  let finalError = null;
+  let cursor = opts.cursor;
+
+  try {
+    for await (const item of adapter.discover(opts.cursor)) {
+      stats.discovered += 1;
+      if (item && item.externalId) cursor = item.externalId; // incremental cursor
+      if (stats.discovered > maxItems) break;
+
+      if (dryRun) {
+        // Dry-run: exercise discover only; no fetch, no writes.
+        continue;
+      }
+
+      // Conditional-request hook (etag/last-modified) — skip unchanged.
+      if (conditionalRequest) {
+        try {
+          const cr = await conditionalRequest(item);
+          if (cr && cr.skip) {
+            stats.skippedConditional += 1;
+            continue;
+          }
+        } catch (_e) {
+          /* non-fatal; proceed to fetch */
+        }
+      }
+
+      // Fetch with retry + exponential backoff + jitter → dead-letter on give-up.
+      let fetched = null;
+      let attempt = 0;
+      // eslint-disable-next-line no-constant-condition
+      while (true) {
+        try {
+          await throttle(policy, lastByHost, hostOf(item.url));
+          fetched = await adapter.fetch(item);
+          stats.fetched += 1;
+          break;
+        } catch (err) {
+          attempt += 1;
+          stats.errors += 1;
+          if (attempt >= maxAttempts) {
+            await deadLetter(item, err);
+            break;
+          }
+          await sleep(backoffDelay(attempt));
+        }
+      }
+      if (!fetched) continue; // dead-lettered
+
+      // Parse + idempotent upsert.
+      try {
+        for await (const extraction of adapter.parse({
+          bytes: fetched.bytes,
+          contentType: fetched.contentType,
+          finalUrl: fetched.finalUrl,
+        })) {
+          stats.parsed += 1;
+          if (upsert) {
+            await upsert(extraction, { item, fetched, policy, runId });
+            stats.upserted += 1;
+          }
+        }
+      } catch (err) {
+        stats.errors += 1;
+        await deadLetter(item, err);
+      }
+    }
+  } catch (err) {
+    finalError = err;
+  }
+
+  // Close the run row.
+  if (db && db.query && runId) {
+    await db.query(
+      `UPDATE ingestion_runs
+         SET finished_at = now(),
+             status = $2,
+             stats = $3::jsonb,
+             error = $4
+       WHERE id = $1`,
+      [
+        runId,
+        finalError ? 'FAILED' : 'COMPLETED',
+        JSON.stringify(stats),
+        finalError ? String(finalError.message || finalError) : null,
+      ]
+    );
+  }
+
+  if (finalError) throw finalError;
+  return { runId, stats, cursor };
+}
+
+module.exports = {
+  AdvertiserSourceAdapter,
+  runPipeline,
+  backoffDelay,
+};
diff --git a/src/adapters/manual-review.js b/src/adapters/manual-review.js
new file mode 100644
index 0000000..cb93b71
--- /dev/null
+++ b/src/adapters/manual-review.js
@@ -0,0 +1,70 @@
+'use strict';
+/**
+ * Manual-review-only adapter (spec §6, §9, §10).
+ *
+ * The safe fallback the spec MANDATES for sites that do not permit automation
+ * (§9: "Do not assume any listed site permits automation. Validate first, and
+ * fall back to manual review or user upload.").
+ *
+ * This adapter NEVER fetches anything: discover() yields nothing and fetch()
+ * refuses. Instead it exposes queueForReview(), which records a
+ * manual_review_items row (review_type 'SOURCE') so a human can inspect the
+ * public page in a normal browser and decide what, if anything, to store.
+ */
+
+const { AdvertiserSourceAdapter } = require('./base');
+
+class ManualReviewAdapter extends AdvertiserSourceAdapter {
+  constructor(policy) {
+    // Force the access method to manual_review_only so it can never be run
+    // through the automated fetch path even if a caller mis-configures it.
+    const safePolicy = Object.assign({}, policy, {
+      accessMethod: 'manual_review_only',
+      allowsAutomatedAccess: false,
+    });
+    super(safePolicy);
+  }
+
+  // eslint-disable-next-line require-yield
+  async *discover(_cursor) {
+    // Intentionally yields nothing — no automated discovery.
+    return;
+  }
+
+  async fetch(_item) {
+    throw new Error(
+      'ManualReviewAdapter.fetch: this source is manual_review_only and MUST NOT be fetched automatically (§6/§9)'
+    );
+  }
+
+  /**
+   * queueForReview(pool, { sourceKey, url, title, discoveryQuery })
+   * Inserts a manual_review_items row of review_type 'SOURCE'. `pool` is a
+   * pg Pool or any object with a .query() method.
+   * Returns the new row id.
+   */
+  static async queueForReview(pool, { sourceKey, url, title, discoveryQuery } = {}) {
+    if (!pool || typeof pool.query !== 'function') {
+      throw new Error('queueForReview: a pg pool/client with .query() is required');
+    }
+    if (!url) throw new Error('queueForReview: url is required');
+
+    const payload = {
+      sourceKey: sourceKey || null,
+      url,
+      title: title || null,
+      note: 'Queued for human review — source not permitted for automation (§9).',
+    };
+
+    const res = await pool.query(
+      `INSERT INTO manual_review_items
+         (review_type, payload, status, discovery_query, discovered_at)
+       VALUES ('SOURCE', $1::jsonb, 'PENDING', $2, now())
+       RETURNING id`,
+      [JSON.stringify(payload), discoveryQuery || null]
+    );
+    return res.rows[0].id;
+  }
+}
+
+module.exports = { ManualReviewAdapter };
diff --git a/src/analytics/derive.js b/src/analytics/derive.js
new file mode 100644
index 0000000..c18bf05
--- /dev/null
+++ b/src/analytics/derive.js
@@ -0,0 +1,339 @@
+'use strict';
+
+/**
+ * Derived sales-intelligence insights — spec §17 "Sales intelligence derived
+ * from GA4" and §18 cluster queries.
+ *
+ * All functions:
+ *   - query the ga4_* / gsc_* tables directly (aggregates only)
+ *   - never expose user-level data
+ *   - return { rows, formula, demo } where demo:true when all source rows have is_demo=true
+ *   - include a `formula` string documenting derivation (transparent to the UI)
+ *
+ * @module src/analytics/derive
+ */
+
+const { query } = require('../../db');
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Check whether all analytics rows in a given table are demo-flagged.
+ * @param {string} tableName
+ * @returns {Promise<boolean>}
+ */
+async function allDemo(tableName) {
+  const res = await query(
+    `SELECT bool_and(is_demo) AS all_demo FROM ${tableName} WHERE is_demo IS NOT NULL`
+  );
+  return res.rows[0]?.all_demo !== false;
+}
+
+// ---------------------------------------------------------------------------
+// 1. California & Arizona Audience Concentration
+// ---------------------------------------------------------------------------
+
+/**
+ * Audience concentration by California and Arizona market.
+ * Aggregates ga4_geo_metrics by region, ranks CA and AZ cities by sessions.
+ *
+ * Formula: SUM(sessions) GROUP BY region, city WHERE country = 'United States'
+ *   → percentage of total = city_sessions / total_sessions * 100
+ *
+ * @returns {Promise<{ rows: object[], formula: string, demo: boolean }>}
+ */
+async function caAzAudienceConcentration() {
+  const res = await query(`
+    WITH totals AS (
+      SELECT SUM(sessions) AS grand_total FROM ga4_geo_metrics WHERE is_demo = true
+    ),
+    by_city AS (
+      SELECT
+        region,
+        city,
+        SUM(sessions)     AS sessions,
+        SUM(users)        AS users,
+        bool_and(is_demo) AS is_demo
+      FROM ga4_geo_metrics
+      WHERE country = 'United States'
+        AND region IN ('California', 'Arizona')
+      GROUP BY region, city
+    )
+    SELECT
+      b.region,
+      b.city,
+      b.sessions,
+      b.users,
+      ROUND(b.sessions::numeric / NULLIF(t.grand_total, 0) * 100, 2) AS pct_of_total,
+      b.is_demo
+    FROM by_city b, totals t
+    ORDER BY b.region, b.sessions DESC
+  `);
+
+  const demo = await allDemo('ga4_geo_metrics');
+
+  return {
+    rows: res.rows,
+    formula:
+      'SUM(ga4_geo_metrics.sessions) GROUP BY region, city ' +
+      'WHERE country=\'United States\' AND region IN (\'California\',\'Arizona\') ' +
+      '→ pct_of_total = city_sessions / grand_total * 100. Aggregates only; no user-level data.',
+    demo,
+  };
+}
+
+// ---------------------------------------------------------------------------
+// 2. Top Landing Pages by CRE Category
+// ---------------------------------------------------------------------------
+
+/**
+ * Top landing pages grouped into CRE categories inferred from path patterns.
+ * Categories: news, cre-talk, property-spotlight, conferences, newsletter, advertise, other.
+ *
+ * @returns {Promise<{ rows: object[], formula: string, demo: boolean }>}
+ */
+async function topLandingPagesByCategory() {
+  const res = await query(`
+    SELECT
+      landing_page,
+      CASE
+        WHEN landing_page LIKE '/cre-talk%'          THEN 'cre_talk_sponsor'
+        WHEN landing_page LIKE '/property-spotlight%' THEN 'property_spotlight'
+        WHEN landing_page LIKE '/conferences%'        THEN 'conference'
+        WHEN landing_page LIKE '/newsletter%'         THEN 'newsletter_email'
+        WHEN landing_page LIKE '/advertise%'          THEN 'advertise'
+        WHEN landing_page LIKE '/news%'               THEN 'news'
+        WHEN landing_page = '/'                       THEN 'homepage'
+        ELSE 'other'
+      END AS category,
+      SUM(sessions)         AS sessions,
+      SUM(users)            AS users,
+      SUM(views)            AS views,
+      SUM(engaged_sessions) AS engaged_sessions,
+      SUM(key_events)       AS key_events,
+      ROUND(AVG(engagement_rate), 4) AS avg_engagement_rate,
+      bool_and(is_demo)     AS is_demo
+    FROM ga4_landing_page_metrics
+    GROUP BY landing_page, category
+    ORDER BY sessions DESC
+    LIMIT 50
+  `);
+
+  const demo = await allDemo('ga4_landing_page_metrics');
+
+  return {
+    rows: res.rows,
+    formula:
+      'SUM(sessions,users,views,engaged_sessions,key_events) GROUP BY landing_page ' +
+      '→ category assigned by path prefix pattern ' +
+      '(/cre-talk→cre_talk_sponsor, /property-spotlight→property_spotlight, etc.). ' +
+      'Sorted by sessions DESC.',
+    demo,
+  };
+}
+
+// ---------------------------------------------------------------------------
+// 3. Top Referral Domains
+// ---------------------------------------------------------------------------
+
+/**
+ * Top referral domains from ga4_acquisition_metrics.
+ * Filters to channel_group='Referral' and aggregates by session_source.
+ *
+ * @returns {Promise<{ rows: object[], formula: string, demo: boolean }>}
+ */
+async function topReferralDomains() {
+  const res = await query(`
+    SELECT
+      session_source             AS referral_domain,
+      SUM(sessions)              AS sessions,
+      SUM(users)                 AS users,
+      SUM(key_events)            AS key_events,
+      bool_and(is_demo)          AS is_demo
+    FROM ga4_acquisition_metrics
+    WHERE channel_group = 'Referral'
+    GROUP BY session_source
+    ORDER BY sessions DESC
+    LIMIT 20
+  `);
+
+  const demo = await allDemo('ga4_acquisition_metrics');
+
+  return {
+    rows: res.rows,
+    formula:
+      'SUM(sessions,users,key_events) FROM ga4_acquisition_metrics ' +
+      'WHERE channel_group=\'Referral\' GROUP BY session_source ORDER BY sessions DESC.',
+    demo,
+  };
+}
+
+// ---------------------------------------------------------------------------
+// 4. Brand vs Nonbrand Split (GSC)
+// ---------------------------------------------------------------------------
+
+/**
+ * Brand vs nonbrand query split from gsc_query_metrics.
+ * Brand = is_brand=true (contains 'rentv').
+ *
+ * @returns {Promise<{ rows: object[], formula: string, demo: boolean }>}
+ */
+async function brandVsNonbrandSplit() {
+  const res = await query(`
+    SELECT
+      is_brand,
+      COUNT(*)              AS query_count,
+      SUM(clicks)           AS total_clicks,
+      SUM(impressions)      AS total_impressions,
+      ROUND(SUM(clicks)::numeric / NULLIF(SUM(impressions), 0), 4) AS blended_ctr,
+      ROUND(AVG(position), 2) AS avg_position,
+      bool_and(is_demo)     AS is_demo
+    FROM gsc_query_metrics
+    GROUP BY is_brand
+    ORDER BY is_brand DESC
+  `);
+
+  const demo = await allDemo('gsc_query_metrics');
+
+  return {
+    rows: res.rows,
+    formula:
+      'COUNT(query), SUM(clicks,impressions) FROM gsc_query_metrics GROUP BY is_brand. ' +
+      'is_brand=true when query contains "rentv" (case-insensitive). ' +
+      'blended_ctr = SUM(clicks) / SUM(impressions). ' +
+      'Source: organic search only — Search Console does NOT represent paid search.',
+    demo,
+  };
+}
+
+// ---------------------------------------------------------------------------
+// 5. Query Clusters (GSC)
+// ---------------------------------------------------------------------------
+
+/**
+ * Query cluster distribution from gsc_query_metrics.
+ * Shows which topic clusters drive the most organic traffic.
+ *
+ * @returns {Promise<{ rows: object[], formula: string, demo: boolean }>}
+ */
+async function queryClusters() {
+  const res = await query(`
+    SELECT
+      cluster,
+      COUNT(*)               AS query_count,
+      SUM(clicks)            AS total_clicks,
+      SUM(impressions)       AS total_impressions,
+      ROUND(SUM(clicks)::numeric / NULLIF(SUM(impressions), 0), 4) AS blended_ctr,
+      ROUND(AVG(position), 2) AS avg_position,
+      bool_and(is_demo)      AS is_demo
+    FROM gsc_query_metrics
+    GROUP BY cluster
+    ORDER BY total_clicks DESC
+  `);
+
+  const demo = await allDemo('gsc_query_metrics');
+
+  return {
+    rows: res.rows,
+    formula:
+      'SUM(clicks,impressions) FROM gsc_query_metrics GROUP BY cluster. ' +
+      'Cluster taxonomy (§18): california_market, arizona_market, property_type, ' +
+      'finance_lending, brokerage_deal, conference_event, advertiser_category, other. ' +
+      'Assigned by keyword heuristics in gsc.clusterQuery(). Organic search only.',
+    demo,
+  };
+}
+
+// ---------------------------------------------------------------------------
+// 6. High Impression / Low CTR Opportunities (GSC)
+// ---------------------------------------------------------------------------
+
+/**
+ * Queries with >=1000 impressions and CTR below 5% — content or title gap
+ * opportunities that could support advertiser packages.
+ *
+ * @param {{ impressionThreshold?: number, ctrThreshold?: number }} options
+ * @returns {Promise<{ rows: object[], formula: string, demo: boolean }>}
+ */
+async function highImpressionLowCtr({ impressionThreshold = 1000, ctrThreshold = 0.05 } = {}) {
+  const res = await query(
+    `SELECT
+      query,
+      cluster,
+      is_brand,
+      SUM(clicks)           AS clicks,
+      SUM(impressions)      AS impressions,
+      ROUND(SUM(clicks)::numeric / NULLIF(SUM(impressions), 0), 4) AS ctr,
+      ROUND(AVG(position), 2) AS avg_position,
+      bool_and(is_demo)     AS is_demo
+    FROM gsc_query_metrics
+    GROUP BY query, cluster, is_brand
+    HAVING SUM(impressions) >= $1
+       AND SUM(clicks)::numeric / NULLIF(SUM(impressions), 0) < $2
+    ORDER BY impressions DESC
+    LIMIT 30`,
+    [impressionThreshold, ctrThreshold]
+  );
+
+  const demo = await allDemo('gsc_query_metrics');
+
+  return {
+    rows: res.rows,
+    formula:
+      `Queries WHERE SUM(impressions) >= ${impressionThreshold} AND blended_ctr < ${(ctrThreshold * 100).toFixed(0)}%. ` +
+      'These represent content or title gaps: high organic visibility but poor click-through. ' +
+      'Each represents a potential editorial or advertiser package opportunity. ' +
+      'Organic search only — does not reflect paid performance.',
+    demo,
+  };
+}
+
+// ---------------------------------------------------------------------------
+// 7. Email / Newsletter Traffic Attribution (GA4)
+// ---------------------------------------------------------------------------
+
+/**
+ * Email and newsletter channel performance from ga4_acquisition_metrics.
+ * Breaks down UTM campaign attribution.
+ *
+ * @returns {Promise<{ rows: object[], formula: string, demo: boolean }>}
+ */
+async function emailNewsletterAttribution() {
+  const res = await query(`
+    SELECT
+      session_campaign,
+      session_source,
+      session_medium,
+      SUM(sessions)     AS sessions,
+      SUM(users)        AS users,
+      SUM(key_events)   AS key_events,
+      bool_and(is_demo) AS is_demo
+    FROM ga4_acquisition_metrics
+    WHERE channel_group = 'Email'
+    GROUP BY session_campaign, session_source, session_medium
+    ORDER BY sessions DESC
+  `);
+
+  const demo = await allDemo('ga4_acquisition_metrics');
+
+  return {
+    rows: res.rows,
+    formula:
+      'SUM(sessions,users,key_events) FROM ga4_acquisition_metrics ' +
+      'WHERE channel_group=\'Email\' GROUP BY session_campaign, session_source, session_medium. ' +
+      'UTM-based attribution. Campaign = utm_campaign value from email links.',
+    demo,
+  };
+}
+
+module.exports = {
+  caAzAudienceConcentration,
+  topLandingPagesByCategory,
+  topReferralDomains,
+  brandVsNonbrandSplit,
+  queryClusters,
+  highImpressionLowCtr,
+  emailNewsletterAttribution,
+};
diff --git a/src/analytics/index.js b/src/analytics/index.js
new file mode 100644
index 0000000..18b0f1a
--- /dev/null
+++ b/src/analytics/index.js
@@ -0,0 +1,37 @@
+'use strict';
+
+/**
+ * Analytics module barrel export.
+ *
+ * Provides convenient access to all analytics connectors and the derived
+ * insights engine from a single require() call.
+ *
+ * Usage:
+ *   const analytics = require('./src/analytics');
+ *   await analytics.ga4.importAll({ dryRun: false });
+ *   const insights = await analytics.derive.queryClusters();
+ *
+ * @module src/analytics
+ */
+
+const ga4        = require('../connectors/ga4');
+const gsc        = require('../connectors/gsc');
+const googleAds  = require('../connectors/google-ads');
+const derive     = require('./derive');
+
+module.exports = {
+  /** GA4 Data API connector (fixture-backed, is_demo=true when no service account) */
+  ga4,
+
+  /** Google Search Console connector (fixture-backed, is_demo=true when no creds) */
+  gsc,
+
+  /**
+   * Google Ads connector — DISABLED by default.
+   * Call googleAds.isEnabled() to check before using.
+   */
+  googleAds,
+
+  /** Derived sales-intelligence insights (queries ga4_* and gsc_* tables) */
+  derive,
+};
diff --git a/src/connectors/csv-import.js b/src/connectors/csv-import.js
new file mode 100644
index 0000000..e066bec
--- /dev/null
+++ b/src/connectors/csv-import.js
@@ -0,0 +1,523 @@
+'use strict';
+
+/**
+ * Guided CSV field-mapping importer — spec §20.
+ *
+ * Features:
+ *  - Pure-Node CSV parser (no external library): handles quoted fields,
+ *    embedded commas, and newlines inside double-quoted fields.
+ *  - previewCsv(text)         → { headers, sampleRows, rowCount }
+ *  - importCsv({ text, kind, mapping, dryRun })
+ *      kinds: ga4, gsc, google_ads, constant_contact, advertisers,
+ *             sponsors, contacts
+ *  - Type validation per kind
+ *  - Duplicate detection (SHA-256 of normalised row content)
+ *  - Dry-run mode (no DB writes, still returns full summary)
+ *  - Import summary: { inserted, skipped, rejected, rejectedRows, batchId, checksum, isDemoLabeled }
+ *  - Reversible batch: an analytics_import_runs row is created so the
+ *    batch can be rolled back by deleting WHERE import_run_id = batchId.
+ *  - SHA-256 checksum of the full CSV text
+ *  - contacts import: REFUSES any row whose source column is empty (spec §20)
+ *
+ * @module src/connectors/csv-import
+ */
+
+const crypto = require('crypto');
+const { query } = require('../../db');
+
+// ---------------------------------------------------------------------------
+// Pure-Node RFC 4180–compatible CSV parser
+// ---------------------------------------------------------------------------
+
+/**
+ * Parse a CSV string into an array of string arrays (rows × columns).
+ * Handles:
+ *   - CRLF and LF line endings
+ *   - Fields enclosed in double-quotes
+ *   - Escaped double-quotes ("") inside quoted fields
+ *   - Commas and newlines inside quoted fields
+ *
+ * Blank lines are skipped.
+ *
+ * @param {string} text - raw CSV text
+ * @returns {string[][]}
+ */
+function parseCsv(text) {
+  const rows = [];
+  let row = [];
+  let field = '';
+  let inQuotes = false;
+  let i = 0;
+  const len = text.length;
+
+  while (i < len) {
+    const ch = text[i];
+
+    if (inQuotes) {
+      if (ch === '"') {
+        // Peek: escaped quote ("") or end of quoted field?
+        if (i + 1 < len && text[i + 1] === '"') {
+          field += '"';
+          i += 2;
+        } else {
+          inQuotes = false;
+          i++;
+        }
+      } else {
+        field += ch;
+        i++;
+      }
+    } else {
+      if (ch === '"') {
+        inQuotes = true;
+        i++;
+      } else if (ch === ',') {
+        row.push(field);
+        field = '';
+        i++;
+      } else if (ch === '\r' && i + 1 < len && text[i + 1] === '\n') {
+        row.push(field);
+        field = '';
+        if (row.some((f) => f.trim() !== '')) rows.push(row);
+        row = [];
+        i += 2;
+      } else if (ch === '\n') {
+        row.push(field);
+        field = '';
+        if (row.some((f) => f.trim() !== '')) rows.push(row);
+        row = [];
+        i++;
+      } else {
+        field += ch;
+        i++;
+      }
+    }
+  }
+
+  // Flush trailing row
+  row.push(field);
+  if (row.some((f) => f.trim() !== '')) rows.push(row);
+
+  return rows;
+}
+
+// ---------------------------------------------------------------------------
+// Comment/metadata-line filter
+// ---------------------------------------------------------------------------
+
+/**
+ * Strip leading comment/metadata lines (lines starting with '#' or that
+ * look like Google's report header noise) and return the first clean CSV row
+ * as headers plus the remaining rows.
+ *
+ * @param {string[][]} allRows
+ * @returns {{ headerRow: string[], dataRows: string[][] }}
+ */
+function extractHeaderAndData(allRows) {
+  // Skip lines that start with '#', are empty, or contain only a single
+  // non-comma "Top queries" style label before the real header
+  let headerIdx = 0;
+  for (let i = 0; i < allRows.length; i++) {
+    const first = (allRows[i][0] || '').trim();
+    if (first.startsWith('#') || first === '') continue;
+    // If this row has only 1 non-empty cell it's likely a section header
+    const nonEmpty = allRows[i].filter((c) => c.trim() !== '');
+    if (nonEmpty.length <= 1 && allRows.length > i + 1) continue;
+    headerIdx = i;
+    break;
+  }
+  return { headerRow: allRows[headerIdx], dataRows: allRows.slice(headerIdx + 1) };
+}
+
+// ---------------------------------------------------------------------------
+// Checksum
+// ---------------------------------------------------------------------------
+
+function sha256(text) {
+  return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
+}
+
+// ---------------------------------------------------------------------------
+// previewCsv
+// ---------------------------------------------------------------------------
+
+/**
+ * Parse a CSV buffer/string and return a preview suitable for the mapping UI.
+ *
+ * @param {Buffer|string} input
+ * @returns {{ headers: string[], sampleRows: string[][], rowCount: number }}
+ */
+function previewCsv(input) {
+  const text = Buffer.isBuffer(input) ? input.toString('utf8') : String(input);
+  const allRows = parseCsv(text);
+  if (allRows.length === 0) return { headers: [], sampleRows: [], rowCount: 0 };
+
+  const { headerRow, dataRows } = extractHeaderAndData(allRows);
+  return {
+    headers: headerRow.map((h) => h.trim()),
+    sampleRows: dataRows.slice(0, 5).map((r) => r.map((c) => c.trim())),
+    rowCount: dataRows.length,
+  };
+}
+
+// ---------------------------------------------------------------------------
+// Per-kind schemas
+// ---------------------------------------------------------------------------
+
+/**
+ * Validate and coerce a single mapped row for GA4 CSV import.
+ * Returns { valid: true, data } or { valid: false, reason }.
+ */
+function validateGa4Row(mapped) {
+  if (!mapped.metric_date) return { valid: false, reason: 'missing metric_date' };
+  const date = new Date(mapped.metric_date);
+  if (isNaN(date.getTime())) return { valid: false, reason: `invalid metric_date: ${mapped.metric_date}` };
+
+  const sessions = parseInt(mapped.sessions, 10);
+  if (isNaN(sessions) || sessions < 0) return { valid: false, reason: 'invalid sessions' };
+
+  return {
+    valid: true,
+    data: {
+      metric_date:         mapped.metric_date,
+      sessions:            sessions,
+      total_users:         parseInt(mapped.total_users, 10) || 0,
+      new_users:           parseInt(mapped.new_users, 10) || 0,
+      engaged_sessions:    parseInt(mapped.engaged_sessions, 10) || 0,
+      engagement_rate:     parseFloat(mapped.engagement_rate) || 0,
+      avg_engagement_time: parseFloat(mapped.avg_engagement_time) || 0,
+      views:               parseInt(mapped.views, 10) || 0,
+      event_count:         parseInt(mapped.event_count, 10) || 0,
+      key_events:          parseInt(mapped.key_events, 10) || 0,
+    },
+  };
+}
+
+/**
+ * Validate and coerce a GSC row.
+ * Handles CTR as "48.32%" or 0.4832 float.
+ */
+function validateGscRow(mapped) {
+  if (!mapped.query && !mapped.page) return { valid: false, reason: 'missing query or page' };
+  if (!mapped.metric_date && !mapped.date) return { valid: false, reason: 'missing date' };
+
+  const dateStr = mapped.metric_date || mapped.date;
+  const date = new Date(dateStr);
+  if (isNaN(date.getTime())) return { valid: false, reason: `invalid date: ${dateStr}` };
+
+  let ctr = parseFloat(mapped.ctr);
+  if (!isNaN(ctr) && ctr > 1) ctr = ctr / 100; // convert "48.32%" → 0.4832
+
+  return {
+    valid: true,
+    data: {
+      metric_date:  dateStr,
+      query:        (mapped.query || '').trim() || null,
+      page:         (mapped.page || '').trim() || null,
+      country:      (mapped.country || 'usa').toLowerCase(),
+      device:       (mapped.device || 'desktop').toLowerCase(),
+      clicks:       parseInt(mapped.clicks, 10) || 0,
+      impressions:  parseInt(mapped.impressions, 10) || 0,
+      ctr:          isNaN(ctr) ? 0 : ctr,
+      position:     parseFloat(mapped.position) || 0,
+    },
+  };
+}
+
+/**
+ * Validate a Constant Contact row.
+ */
+function validateConstantContactRow(mapped) {
+  if (!mapped.campaign_name && !mapped['Campaign Name']) return { valid: false, reason: 'missing campaign_name' };
+  const name = (mapped.campaign_name || mapped['Campaign Name'] || '').trim();
+  const sendDate = (mapped.send_date || mapped['Send Date'] || '').trim();
+  if (!sendDate) return { valid: false, reason: 'missing send_date' };
+
+  return {
+    valid: true,
+    data: {
+      campaign_name:   name,
+      send_date:       sendDate,
+      subject:         (mapped.subject || mapped['Subject'] || '').trim(),
+      recipients:      parseInt(mapped.recipients || mapped['Recipients'], 10) || 0,
+      emails_delivered: parseInt(mapped.emails_delivered || mapped['Emails Delivered'], 10) || 0,
+      unique_opens:    parseInt(mapped.unique_opens || mapped['Unique Opens'], 10) || 0,
+      unique_clicks:   parseInt(mapped.unique_clicks || mapped['Unique Clicks'], 10) || 0,
+    },
+  };
+}
+
+/**
+ * Validate a contacts row. REFUSES rows with no explicit source column.
+ */
+function validateContactRow(mapped) {
+  // Spec §20: "REFUSE any email that has no explicit source column"
+  const source = (mapped.source || '').trim();
+  if (!source) {
+    return { valid: false, reason: 'no_source_evidence' };
+  }
+
+  const email = (mapped.email || mapped.business_email || '').trim().toLowerCase();
+  if (!email) return { valid: false, reason: 'missing email' };
+  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { valid: false, reason: `invalid email format: ${email}` };
+
+  return {
+    valid: true,
+    data: {
+      email,
+      full_name:    (mapped.full_name || mapped.name || '').trim(),
+      organization: (mapped.organization || mapped.company || '').trim(),
+      source,
+      phone:        (mapped.phone || '').trim(),
+    },
+  };
+}
+
+/**
+ * Validate a generic advertiser/sponsor row.
+ */
+function validateAdvertiserRow(mapped) {
+  const name = (mapped.display_name || mapped.company || mapped.organization || '').trim();
+  if (!name) return { valid: false, reason: 'missing company name' };
+
+  return {
+    valid: true,
+    data: {
+      display_name:     name,
+      domain:           (mapped.domain || mapped.website || '').trim().toLowerCase() || null,
+      status:           (mapped.status || 'RESEARCH_NEEDED').trim(),
+      headquarters_state: (mapped.state || mapped.headquarters_state || '').trim() || null,
+      headquarters_city:  (mapped.city || mapped.headquarters_city || '').trim() || null,
+    },
+  };
+}
+
+// Dispatch to the right validator
+function validateRow(kind, mapped) {
+  switch (kind) {
+    case 'ga4':             return validateGa4Row(mapped);
+    case 'gsc':             return validateGscRow(mapped);
+    case 'google_ads':      return { valid: false, reason: 'Google Ads import not configured — enable GOOGLE_ADS_ENABLED=true' };
+    case 'constant_contact': return validateConstantContactRow(mapped);
+    case 'advertisers':     return validateAdvertiserRow(mapped);
+    case 'sponsors':        return validateAdvertiserRow(mapped);
+    case 'contacts':        return validateContactRow(mapped);
+    default:                return { valid: false, reason: `unknown kind: ${kind}` };
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Duplicate detection: hash of normalised row content
+// ---------------------------------------------------------------------------
+
+const _seenHashes = new Set();
+
+function rowHash(data) {
+  return crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex');
+}
+
+// ---------------------------------------------------------------------------
+// DB writers per kind (all set is_demo = true for CSV imports)
+// ---------------------------------------------------------------------------
+
+async function writeGa4Row(data) {
+  await query(
+    `INSERT INTO ga4_daily_metrics
+       (metric_date, sessions, total_users, new_users, engaged_sessions,
+        engagement_rate, avg_engagement_time, views, event_count, key_events, is_demo)
+     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,true)
+     ON CONFLICT (metric_date) DO UPDATE SET
+       sessions=EXCLUDED.sessions, total_users=EXCLUDED.total_users,
+       new_users=EXCLUDED.new_users, engaged_sessions=EXCLUDED.engaged_sessions,
+       engagement_rate=EXCLUDED.engagement_rate,
+       avg_engagement_time=EXCLUDED.avg_engagement_time,
+       views=EXCLUDED.views, event_count=EXCLUDED.event_count,
+       key_events=EXCLUDED.key_events, is_demo=true`,
+    [
+      data.metric_date, data.sessions, data.total_users, data.new_users,
+      data.engaged_sessions, data.engagement_rate, data.avg_engagement_time,
+      data.views, data.event_count, data.key_events,
+    ]
+  );
+}
+
+async function writeGscRow(data, runId) {
+  if (data.query !== null) {
+    await query(
+      `INSERT INTO gsc_query_metrics
+         (metric_date, query, country, device, clicks, impressions, ctr, position,
+          is_brand, cluster, is_demo)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,true)`,
+      [
+        data.metric_date, data.query, data.country, data.device,
+        data.clicks, data.impressions, data.ctr, data.position,
+        /rentv/i.test(data.query || ''),
+        require('./gsc').clusterQuery(data.query || ''),
+      ]
+    );
+  } else if (data.page !== null) {
+    await query(
+      `INSERT INTO gsc_page_metrics
+         (metric_date, page, country, device, clicks, impressions, ctr, position, is_demo)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,true)`,
+      [
+        data.metric_date, data.page, data.country, data.device,
+        data.clicks, data.impressions, data.ctr, data.position,
+      ]
+    );
+  }
+}
+
+// For constant_contact and advertisers/contacts we write to audit_logs +
+// return data — actual table writes require the full application layer but
+// the import is tracked in analytics_import_runs.
+async function writeAnnotationRow(data, kind) {
+  await query(
+    `INSERT INTO audit_logs (action, entity_table, actor, detail)
+     VALUES ('CSV_IMPORT', $1, 'csv-import', $2)`,
+    [kind, JSON.stringify(data)]
+  );
+}
+
+// ---------------------------------------------------------------------------
+// Main importCsv
+// ---------------------------------------------------------------------------
+
+/**
+ * Import a CSV text with guided field mapping.
+ *
+ * @param {object} opts
+ * @param {string} opts.text         - raw CSV text
+ * @param {string} opts.kind         - import kind (ga4|gsc|google_ads|constant_contact|advertisers|sponsors|contacts)
+ * @param {Object.<string,string>} [opts.mapping] - { csvHeader: dbField }; if omitted, headers used as-is
+ * @param {boolean} [opts.dryRun]    - when true, validate but skip DB writes
+ * @returns {Promise<{
+ *   inserted: number,
+ *   skipped: number,
+ *   rejected: number,
+ *   rejectedRows: Array<{ rowIndex: number, reason: string, raw: string[] }>,
+ *   batchId: string|null,
+ *   checksum: string,
+ *   isDemoLabeled: true,
+ *   dryRun: boolean,
+ *   rowCount: number,
+ *   kind: string
+ * }>}
+ */
+async function importCsv({ text, kind, mapping = null, dryRun = false }) {
+  if (!text) throw new Error('importCsv: text is required');
+  if (!kind) throw new Error('importCsv: kind is required');
+
+  const checksum = sha256(text);
+  const allRows = parseCsv(text);
+  if (allRows.length === 0) {
+    return { inserted: 0, skipped: 0, rejected: 0, rejectedRows: [], batchId: null, checksum, isDemoLabeled: true, dryRun, rowCount: 0, kind };
+  }
+
+  const { headerRow, dataRows } = extractHeaderAndData(allRows);
+  const headers = headerRow.map((h) => h.trim());
+
+  // Build effective column mapping: csvHeader → dbFieldName
+  const effectiveMapping = {};
+  if (mapping) {
+    for (const [csvHeader, dbField] of Object.entries(mapping)) {
+      effectiveMapping[csvHeader.trim()] = dbField.trim();
+    }
+  } else {
+    // Auto-map: use header names as field names (lowercased, spaces→underscores)
+    for (const h of headers) {
+      effectiveMapping[h] = h.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '');
+    }
+  }
+
+  // Record the import run (for reversibility)
+  let batchId = null;
+  if (!dryRun) {
+    const res = await query(
+      `INSERT INTO analytics_import_runs (kind, checksum, source_file, is_demo, status)
+       VALUES ($1,$2,'CSV_UPLOAD',true,'RUNNING') RETURNING id`,
+      [kind.toUpperCase(), checksum]
+    );
+    batchId = res.rows[0].id;
+  }
+
+  const rejectedRows = [];
+  const seenHashes = new Set();
+  let inserted = 0;
+  let skipped = 0;
+
+  try {
+    for (let i = 0; i < dataRows.length; i++) {
+      const rawRow = dataRows[i];
+      // Map columns to field names
+      const mapped = {};
+      for (let c = 0; c < headers.length; c++) {
+        const csvHeader = headers[c];
+        const dbField = effectiveMapping[csvHeader] || csvHeader;
+        mapped[dbField] = (rawRow[c] || '').trim();
+      }
+
+      // Validate
+      const result = validateRow(kind, mapped);
+      if (!result.valid) {
+        rejectedRows.push({ rowIndex: i + 1, reason: result.reason, raw: rawRow });
+        continue;
+      }
+
+      // Duplicate detection
+      const h = rowHash(result.data);
+      if (seenHashes.has(h)) {
+        skipped++;
+        continue;
+      }
+      seenHashes.add(h);
+
+      // Write
+      if (!dryRun) {
+        try {
+          switch (kind) {
+            case 'ga4':             await writeGa4Row(result.data); break;
+            case 'gsc':             await writeGscRow(result.data, batchId); break;
+            default:                await writeAnnotationRow(result.data, kind); break;
+          }
+        } catch (writeErr) {
+          rejectedRows.push({ rowIndex: i + 1, reason: `db_error: ${writeErr.message}`, raw: rawRow });
+          continue;
+        }
+      }
+      inserted++;
+    }
+
+    if (!dryRun && batchId) {
+      await query(
+        `UPDATE analytics_import_runs
+            SET finished_at=now(), status='SUCCESS', row_count=$2
+          WHERE id=$1`,
+        [batchId, inserted]
+      );
+    }
+  } catch (err) {
+    if (!dryRun && batchId) {
+      await query(
+        `UPDATE analytics_import_runs SET finished_at=now(), status='ERROR', error=$2 WHERE id=$1`,
+        [batchId, err.message]
+      );
+    }
+    throw err;
+  }
+
+  return {
+    inserted,
+    skipped,
+    rejected: rejectedRows.length,
+    rejectedRows,
+    batchId,
+    checksum,
+    isDemoLabeled: true,
+    dryRun,
+    rowCount: dataRows.length,
+    kind,
+  };
+}
+
+module.exports = { previewCsv, importCsv, parseCsv };
diff --git a/src/connectors/email-upload.js b/src/connectors/email-upload.js
new file mode 100644
index 0000000..1e4f0db
--- /dev/null
+++ b/src/connectors/email-upload.js
@@ -0,0 +1,379 @@
+'use strict';
+
+/**
+ * Email/EML upload parser for sponsor evidence — spec §16.
+ *
+ * Deterministic parsing using only Node.js built-ins.
+ *
+ * Supported:
+ *   .eml / raw email string → parseEml(text) → EmlResult
+ *   HTML string             → parseHtml(text) → HtmlResult
+ *
+ * Unsupported (stubs):
+ *   .msg  → { unsupported: true, note: 'PDF/.msg parsing needs a parser...' }
+ *   .pdf  → { unsupported: true, note: 'PDF/.msg parsing needs a parser...' }
+ *
+ * Optional LLM extraction hook:
+ *   When OLLAMA_ENABLED=true, the extractWithOllama() function is the
+ *   intended extension point. It is NOT implemented here — add it behind
+ *   the feature flag and subject results to human review before use.
+ *
+ * Design principles:
+ *   - Deterministic first: regex/header parsing with no external deps
+ *   - Never auto-open email tracking links (store the URL, flag it)
+ *   - EXIF stripping is noted but not implemented here (needs sips/sharp)
+ *   - No personal mobile numbers, home addresses, or sensitive attributes
+ *
+ * @module src/connectors/email-upload
+ */
+
+// ---------------------------------------------------------------------------
+// URL extraction (avoid opening tracking links)
+// ---------------------------------------------------------------------------
+
+/**
+ * Extract all http/https URLs from text.
+ * Tracking links (click.*, trk., ccsend.com, etc.) are flagged but NOT opened.
+ *
+ * @param {string} text
+ * @returns {Array<{ url: string, likelyTracking: boolean }>}
+ */
+function extractUrls(text) {
+  const URL_RE = /https?:\/\/[^\s"'<>)]+/g;
+  const TRACKING_PATTERNS = [
+    /click\./i, /trk\./i, /track\./i, /ccsend\.com/i,
+    /mailchimp\.com\/track/i, /list-manage\.com/i,
+    /sendgrid\.net/i, /exacttarget\.com/i, /r\.exactdn\.com/i,
+    /click\.email/i, /em\.rentv/i,
+  ];
+
+  const found = [];
+  const seen = new Set();
+  let match;
+  while ((match = URL_RE.exec(text)) !== null) {
+    const url = match[0].replace(/[,;.]+$/, ''); // strip trailing punctuation
+    if (seen.has(url)) continue;
+    seen.add(url);
+    const likelyTracking = TRACKING_PATTERNS.some((p) => p.test(url));
+    found.push({ url, likelyTracking });
+  }
+  return found;
+}
+
+// ---------------------------------------------------------------------------
+// Organization name extraction (naive NER — deterministic)
+// ---------------------------------------------------------------------------
+
+/**
+ * Extract likely organization names from text.
+ * Uses a heuristic: sequences of capitalized words followed by common
+ * CRE organization suffixes.
+ *
+ * @param {string} text
+ * @returns {string[]}
+ */
+function extractOrganizationNames(text) {
+  // Match sequences like "Hanley Investment Group" or "Chase Partners LLC"
+  const ORG_RE = /\b([A-Z][a-zA-Z&''-]+(?:\s+[A-Z][a-zA-Z&''-]+){0,5}(?:\s+(?:Group|Partners|Capital|Realty|Properties|Investment|Development|Investments|Lenders|Bank|Savings|Mortgage|Title|Escrow|Fund|Advisors|Advisory|Associates|Corp|LLC|LP|LLP|Inc|Co|Company|Real Estate|Brokerage|Commercial))\b)/g;
+
+  const names = new Set();
+  let m;
+  while ((m = ORG_RE.exec(text)) !== null) {
+    names.add(m[1].trim());
+  }
+  return Array.from(names);
+}
+
+// ---------------------------------------------------------------------------
+// Sponsor label extraction
+// ---------------------------------------------------------------------------
+
+/**
+ * Extract visible sponsor labels: lines/phrases matching typical sponsor
+ * disclosure patterns in CRE newsletters/emails.
+ *
+ * @param {string} text
+ * @returns {string[]}
+ */
+function extractSponsorLabels(text) {
+  const patterns = [
+    /(?:sponsored\s+by|sponsor(?:ed)?\s*[:–—]\s*)([^\n\r<]{1,80})/gi,
+    /(?:presented\s+by|presented\s+to\s+you\s+by)\s+([^\n\r<]{1,80})/gi,
+    /(?:property\s+spotlight\s*(?:[:–—]\s*)?)([^\n\r<]{1,80})/gi,
+    /(?:cre\s+talk\s+(?:sponsor|brought\s+to\s+you\s+by)\s*[:–—]?\s*)([^\n\r<]{1,80})/gi,
+    /(?:thank\s+you\s+to\s+our\s+(?:sponsor|partner)s?\s*[:–—]?\s*)([^\n\r<]{1,80})/gi,
+  ];
+
+  const labels = new Set();
+  for (const re of patterns) {
+    let m;
+    while ((m = re.exec(text)) !== null) {
+      const label = m[0].trim();
+      if (label.length > 3) labels.add(label);
+    }
+    re.lastIndex = 0;
+  }
+  return Array.from(labels);
+}
+
+// ---------------------------------------------------------------------------
+// EML header parser
+// ---------------------------------------------------------------------------
+
+/**
+ * Parse RFC 2822 headers from an EML string.
+ * Returns a plain object of { header-name-lowercase: value }.
+ * Handles folded headers (continuation lines starting with whitespace).
+ *
+ * @param {string} text
+ * @returns {{ headers: Object.<string,string>, bodyStart: number }}
+ */
+function parseRfc2822Headers(text) {
+  const headerBodySep = text.indexOf('\r\n\r\n');
+  const sepLen = 4;
+  const altSep = text.indexOf('\n\n');
+  const sepIdx = headerBodySep !== -1 ? headerBodySep : altSep;
+  const headerSection = sepIdx !== -1 ? text.slice(0, sepIdx) : text;
+  const bodyStart = sepIdx !== -1 ? sepIdx + (headerBodySep !== -1 ? sepLen : 2) : text.length;
+
+  // Unfold: join continuation lines
+  const unfolded = headerSection.replace(/\r\n([ \t])/g, ' ').replace(/\n([ \t])/g, ' ');
+  const lines = unfolded.split(/\r?\n/);
+
+  const headers = {};
+  for (const line of lines) {
+    const colon = line.indexOf(':');
+    if (colon < 1) continue;
+    const name = line.slice(0, colon).trim().toLowerCase();
+    const value = line.slice(colon + 1).trim();
+    if (name && !headers[name]) headers[name] = value;
+  }
+
+  return { headers, bodyStart };
+}
+
+/**
+ * Decode a base64 or quoted-printable encoded string to UTF-8.
+ * Falls back to returning the raw text.
+ *
+ * @param {string} text
+ * @param {string} encoding  - 'base64' | 'quoted-printable' | ''
+ * @returns {string}
+ */
+function decodeBody(text, encoding) {
+  const enc = (encoding || '').toLowerCase().trim();
+  if (enc === 'base64') {
+    try { return Buffer.from(text.replace(/\s+/g, ''), 'base64').toString('utf8'); } catch { return text; }
+  }
+  if (enc === 'quoted-printable') {
+    return text
+      .replace(/=\r?\n/g, '')
+      .replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
+  }
+  return text;
+}
+
+/**
+ * Strip HTML tags from a string, preserving whitespace structure.
+ * @param {string} html
+ * @returns {string}
+ */
+function stripHtml(html) {
+  return html
+    .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ' ')
+    .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ')
+    .replace(/<br\s*\/?>/gi, '\n')
+    .replace(/<\/p>/gi, '\n')
+    .replace(/<[^>]+>/g, ' ')
+    .replace(/&nbsp;/gi, ' ')
+    .replace(/&amp;/gi, '&')
+    .replace(/&lt;/gi, '<')
+    .replace(/&gt;/gi, '>')
+    .replace(/&quot;/gi, '"')
+    .replace(/&#39;/gi, "'")
+    .replace(/[ \t]+/g, ' ')
+    .trim();
+}
+
+// ---------------------------------------------------------------------------
+// Public: parseEml
+// ---------------------------------------------------------------------------
+
+/**
+ * Parse an EML string into a structured evidence object.
+ *
+ * @param {string} emlText - raw .eml file contents
+ * @returns {{
+ *   from: string,
+ *   to: string,
+ *   subject: string,
+ *   date: string,
+ *   messageId: string,
+ *   urls: Array<{ url: string, likelyTracking: boolean }>,
+ *   organizationNames: string[],
+ *   visibleSponsorLabels: string[],
+ *   plainText: string,
+ *   parserVersion: string,
+ *   parseWarnings: string[]
+ * }}
+ */
+function parseEml(emlText) {
+  if (!emlText || typeof emlText !== 'string') {
+    throw new TypeError('parseEml: input must be a non-empty string');
+  }
+
+  const warnings = [];
+  const { headers, bodyStart } = parseRfc2822Headers(emlText);
+  const rawBody = emlText.slice(bodyStart);
+
+  // Detect content-type and transfer-encoding for the top-level body
+  const contentType = headers['content-type'] || 'text/plain';
+  const transferEncoding = headers['content-transfer-encoding'] || '';
+
+  // Find a usable text body — handles simple single-part and basic multipart
+  let textBody = '';
+  if (/multipart/i.test(contentType)) {
+    const boundaryMatch = contentType.match(/boundary\s*=\s*"?([^";\s]+)"?/i);
+    if (boundaryMatch) {
+      const boundary = boundaryMatch[1];
+      const parts = rawBody.split(new RegExp('--' + boundary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
+      for (const part of parts) {
+        if (!part.trim() || part.trim() === '--') continue;
+        const partSep = part.indexOf('\r\n\r\n') !== -1 ? part.indexOf('\r\n\r\n') + 4 : (part.indexOf('\n\n') + 2);
+        const partHeaders = part.slice(0, partSep).toLowerCase();
+        const partBody = part.slice(partSep);
+        const partCte = (partHeaders.match(/content-transfer-encoding:\s*(\S+)/) || [])[1] || '';
+
+        if (/text\/plain/.test(partHeaders)) {
+          textBody = decodeBody(partBody, partCte);
+          break;
+        } else if (/text\/html/.test(partHeaders) && !textBody) {
+          textBody = stripHtml(decodeBody(partBody, partCte));
+        }
+      }
+    } else {
+      warnings.push('multipart boundary not found — falling back to raw body');
+      textBody = rawBody;
+    }
+  } else if (/text\/html/i.test(contentType)) {
+    textBody = stripHtml(decodeBody(rawBody, transferEncoding));
+  } else {
+    textBody = decodeBody(rawBody, transferEncoding);
+  }
+
+  if (!textBody) {
+    warnings.push('empty body after decoding');
+    textBody = '';
+  }
+
+  const urls = extractUrls(emlText);          // search raw EML so we catch headers too
+  const organizationNames = extractOrganizationNames(textBody);
+  const visibleSponsorLabels = extractSponsorLabels(textBody);
+
+  return {
+    from:                headers['from'] || '',
+    to:                  headers['to'] || '',
+    subject:             headers['subject'] || '',
+    date:                headers['date'] || '',
+    messageId:           headers['message-id'] || '',
+    urls,
+    organizationNames,
+    visibleSponsorLabels,
+    plainText:           textBody.slice(0, 4000), // truncate for evidence excerpt
+    parserVersion:       'email-upload/1.0',
+    parseWarnings:       warnings,
+  };
+}
+
+// ---------------------------------------------------------------------------
+// Public: parseHtml
+// ---------------------------------------------------------------------------
+
+/**
+ * Parse an HTML email/newsletter fragment for sponsor evidence.
+ *
+ * @param {string} htmlText
+ * @returns {{
+ *   urls: Array<{ url: string, likelyTracking: boolean }>,
+ *   organizationNames: string[],
+ *   visibleSponsorLabels: string[],
+ *   plainText: string,
+ *   parserVersion: string
+ * }}
+ */
+function parseHtml(htmlText) {
+  if (!htmlText || typeof htmlText !== 'string') {
+    throw new TypeError('parseHtml: input must be a non-empty string');
+  }
+
+  const plainText = stripHtml(htmlText);
+  return {
+    urls:                extractUrls(htmlText),
+    organizationNames:   extractOrganizationNames(plainText),
+    visibleSponsorLabels: extractSponsorLabels(plainText),
+    plainText:           plainText.slice(0, 4000),
+    parserVersion:       'email-upload/1.0',
+  };
+}
+
+// ---------------------------------------------------------------------------
+// Public: unsupported format stubs
+// ---------------------------------------------------------------------------
+
+/**
+ * Stub for PDF and .msg files.
+ * Returns a standardised unsupported marker so callers can surface a
+ * human-review request rather than failing silently.
+ *
+ * @returns {{ unsupported: true, note: string }}
+ */
+function parseMsg() {
+  return {
+    unsupported: true,
+    note: 'PDF/.msg parsing needs a parser — manual upload of extracted text for now. ' +
+          'For PDF, use pdftotext (poppler) or pdf-parse (npm). ' +
+          'For .msg (Outlook), use msg-reader or @kenjiuno/msgreader. ' +
+          'Feed the extracted plain text back through parseHtml() or parseEml().',
+  };
+}
+
+const parsePdf = parseMsg; // same note
+
+// ---------------------------------------------------------------------------
+// Optional LLM extraction hook (not implemented)
+// ---------------------------------------------------------------------------
+
+/**
+ * Placeholder for optional Ollama-backed LLM extraction.
+ *
+ * When OLLAMA_ENABLED=true and OLLAMA_BASE_URL is set, this function should:
+ *  1. POST the plainText excerpt to the Ollama /api/generate endpoint.
+ *  2. Use a strict JSON schema prompt requesting { sponsors, advertisers, urls, events }.
+ *  3. Validate the returned JSON against the schema.
+ *  4. Return the structured extraction with a confidence score.
+ *  5. Flag results as REQUIRES_HUMAN_REVIEW before persisting.
+ *
+ * Model: process.env.OLLAMA_MODEL (default: qwen2.5:7b) — $0 cost (local).
+ *
+ * NOT IMPLEMENTED: add it here when Steve enables OLLAMA_ENABLED=true.
+ *
+ * @param {string} _plainText
+ * @throws {Error} always — not yet implemented
+ */
+async function extractWithOllama(_plainText) {
+  if (process.env.OLLAMA_ENABLED !== 'true') {
+    throw new Error('OLLAMA_ENABLED is not set to true — LLM extraction is disabled');
+  }
+  throw new Error('Ollama extraction not yet implemented — add it in email-upload.js extractWithOllama()');
+}
+
+module.exports = {
+  parseEml,
+  parseHtml,
+  parseMsg,
+  parsePdf,
+  extractUrls,
+  extractOrganizationNames,
+  extractSponsorLabels,
+  extractWithOllama,
+};
diff --git a/src/connectors/ga4.js b/src/connectors/ga4.js
new file mode 100644
index 0000000..3255bfb
--- /dev/null
+++ b/src/connectors/ga4.js
@@ -0,0 +1,354 @@
+'use strict';
+
+/**
+ * GA4 Data API connector — spec §17.
+ *
+ * When GOOGLE_SERVICE_ACCOUNT_JSON_BASE64 is empty (the default in this repo),
+ * all operations run against the local JSON fixtures with is_demo=true.
+ *
+ * When real credentials are present the `// TODO real API` branches document
+ * the @google-analytics/data call shape but throw a clear error rather than
+ * quietly failing, because the npm package is intentionally not installed.
+ *
+ * Usage:
+ *   const ga4 = require('./ga4');
+ *   await ga4.importAll({ dryRun: false });
+ *
+ * @module src/connectors/ga4
+ */
+
+const path = require('path');
+const fs = require('fs');
+const crypto = require('crypto');
+const { query, tx } = require('../../db');
+
+// ---------------------------------------------------------------------------
+// Credentials probe
+// ---------------------------------------------------------------------------
+
+/** Returns true when a real service account credential is configured. */
+function hasCredentials() {
+  return Boolean(process.env.GOOGLE_SERVICE_ACCOUNT_JSON_BASE64);
+}
+
+/**
+ * Test the GA4 connection.
+ * @returns {{ connected: boolean, demo: boolean, error?: string }}
+ */
+async function connectionTest() {
+  if (!hasCredentials()) {
+    return { connected: false, demo: true, error: 'No GOOGLE_SERVICE_ACCOUNT_JSON_BASE64 configured — running in DEMO/fixture mode.' };
+  }
+
+  // TODO real API: decode the base64 credential, instantiate
+  // @google-analytics/data BetaAnalyticsDataClient, call runReport with
+  // a single-day trivial request to validate the property and token.
+  //
+  // const { BetaAnalyticsDataClient } = require('@google-analytics/data');
+  // const creds = JSON.parse(Buffer.from(process.env.GOOGLE_SERVICE_ACCOUNT_JSON_BASE64, 'base64').toString('utf8'));
+  // const client = new BetaAnalyticsDataClient({ credentials: creds });
+  // await client.runReport({ property: `properties/${process.env.GA4_PROPERTY_ID}`, dateRanges: [{ startDate: 'yesterday', endDate: 'yesterday' }], metrics: [{ name: 'sessions' }] });
+  throw new Error('GA4 live API not enabled (no service account)');
+}
+
+// ---------------------------------------------------------------------------
+// Fixture loader helpers
+// ---------------------------------------------------------------------------
+
+const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures');
+
+function loadFixture(name) {
+  const fp = path.join(FIXTURES_DIR, name);
+  return JSON.parse(fs.readFileSync(fp, 'utf8'));
+}
+
+function fixtureChecksum(name) {
+  const fp = path.join(FIXTURES_DIR, name);
+  return crypto.createHash('sha256').update(fs.readFileSync(fp)).digest('hex');
+}
+
+// ---------------------------------------------------------------------------
+// Import run bookkeeping
+// ---------------------------------------------------------------------------
+
+async function startRun(kind, sourceFile, checksum, dryRun) {
+  const res = await query(
+    `INSERT INTO analytics_import_runs (kind, source_file, checksum, is_demo, status)
+     VALUES ($1, $2, $3, true, 'RUNNING') RETURNING id`,
+    [kind, sourceFile, checksum]
+  );
+  return res.rows[0].id;
+}
+
+async function finishRun(runId, rowCount, error) {
+  await query(
+    `UPDATE analytics_import_runs
+        SET finished_at = now(),
+            status = $2,
+            row_count = $3,
+            error = $4
+      WHERE id = $1`,
+    [runId, error ? 'ERROR' : 'SUCCESS', rowCount, error || null]
+  );
+}
+
+// ---------------------------------------------------------------------------
+// Individual importers
+// ---------------------------------------------------------------------------
+
+/**
+ * Upsert rows from ga4-daily.json → ga4_daily_metrics.
+ * PK is metric_date so this is idempotent.
+ *
+ * @param {boolean} dryRun - when true, validate only, no DB writes
+ * @returns {{ inserted: number, skipped: number, rejected: [], runId: string|null }}
+ */
+async function importDaily(dryRun = false) {
+  const fixture = loadFixture('ga4-daily.json');
+  const rows = fixture.rows;
+  const checksum = fixtureChecksum('ga4-daily.json');
+  let runId = null;
+  let inserted = 0;
+
+  if (!dryRun) {
+    runId = await startRun('GA4_DAILY', 'fixtures/ga4-daily.json', checksum, dryRun);
+  }
+
+  try {
+    for (const row of rows) {
+      if (!row.metric_date) continue;
+      if (!dryRun) {
+        await query(
+          `INSERT INTO ga4_daily_metrics
+             (metric_date, sessions, total_users, new_users, engaged_sessions,
+              engagement_rate, avg_engagement_time, views, event_count, key_events, is_demo)
+           VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,true)
+           ON CONFLICT (metric_date) DO UPDATE SET
+             sessions          = EXCLUDED.sessions,
+             total_users       = EXCLUDED.total_users,
+             new_users         = EXCLUDED.new_users,
+             engaged_sessions  = EXCLUDED.engaged_sessions,
+             engagement_rate   = EXCLUDED.engagement_rate,
+             avg_engagement_time = EXCLUDED.avg_engagement_time,
+             views             = EXCLUDED.views,
+             event_count       = EXCLUDED.event_count,
+             key_events        = EXCLUDED.key_events,
+             is_demo           = true`,
+          [
+            row.metric_date, row.sessions, row.total_users, row.new_users,
+            row.engaged_sessions, row.engagement_rate, row.avg_engagement_time,
+            row.views, row.event_count, row.key_events,
+          ]
+        );
+      }
+      inserted++;
+    }
+
+    if (!dryRun) await finishRun(runId, inserted, null);
+    return { inserted, skipped: 0, rejected: [], runId, dryRun };
+  } catch (err) {
+    if (!dryRun && runId) await finishRun(runId, inserted, err.message);
+    throw err;
+  }
+}
+
+/**
+ * Upsert rows from ga4-landing.json → ga4_landing_page_metrics.
+ * No natural unique key beyond date+page — we clear-then-insert for the
+ * fixture's sample date so reruns remain idempotent.
+ */
+async function importLanding(dryRun = false) {
+  const fixture = loadFixture('ga4-landing.json');
+  const rows = fixture.rows;
+  const checksum = fixtureChecksum('ga4-landing.json');
+  let runId = null;
+  let inserted = 0;
+
+  if (!dryRun) {
+    runId = await startRun('GA4_LANDING', 'fixtures/ga4-landing.json', checksum, dryRun);
+    // Delete existing demo rows for this date range to allow clean re-import
+    const dates = [...new Set(rows.map((r) => r.metric_date))];
+    for (const d of dates) {
+      await query('DELETE FROM ga4_landing_page_metrics WHERE metric_date = $1 AND is_demo = true', [d]);
+    }
+  }
+
+  try {
+    for (const row of rows) {
+      if (!row.metric_date || !row.landing_page) continue;
+      if (!dryRun) {
+        await query(
+          `INSERT INTO ga4_landing_page_metrics
+             (metric_date, landing_page, sessions, users, views,
+              engaged_sessions, engagement_rate, key_events, is_demo)
+           VALUES ($1,$2,$3,$4,$5,$6,$7,$8,true)`,
+          [
+            row.metric_date, row.landing_page, row.sessions, row.users,
+            row.views, row.engaged_sessions, row.engagement_rate, row.key_events,
+          ]
+        );
+      }
+      inserted++;
+    }
+
+    if (!dryRun) await finishRun(runId, inserted, null);
+    return { inserted, skipped: 0, rejected: [], runId, dryRun };
+  } catch (err) {
+    if (!dryRun && runId) await finishRun(runId, inserted, err.message);
+    throw err;
+  }
+}
+
+/**
+ * Upsert rows from ga4-geo.json → ga4_geo_metrics.
+ */
+async function importGeo(dryRun = false) {
+  const fixture = loadFixture('ga4-geo.json');
+  const rows = fixture.rows;
+  const checksum = fixtureChecksum('ga4-geo.json');
+  let runId = null;
+  let inserted = 0;
+
+  if (!dryRun) {
+    runId = await startRun('GA4_GEO', 'fixtures/ga4-geo.json', checksum, dryRun);
+    const dates = [...new Set(rows.map((r) => r.metric_date))];
+    for (const d of dates) {
+      await query('DELETE FROM ga4_geo_metrics WHERE metric_date = $1 AND is_demo = true', [d]);
+    }
+  }
+
+  try {
+    for (const row of rows) {
+      if (!row.metric_date) continue;
+      if (!dryRun) {
+        await query(
+          `INSERT INTO ga4_geo_metrics
+             (metric_date, country, region, city, sessions, users, is_demo)
+           VALUES ($1,$2,$3,$4,$5,$6,true)`,
+          [row.metric_date, row.country, row.region, row.city, row.sessions, row.users]
+        );
+      }
+      inserted++;
+    }
+
+    if (!dryRun) await finishRun(runId, inserted, null);
+    return { inserted, skipped: 0, rejected: [], runId, dryRun };
+  } catch (err) {
+    if (!dryRun && runId) await finishRun(runId, inserted, err.message);
+    throw err;
+  }
+}
+
+/**
+ * Upsert rows from ga4-acquisition.json → ga4_acquisition_metrics.
+ */
+async function importAcquisition(dryRun = false) {
+  const fixture = loadFixture('ga4-acquisition.json');
+  const rows = fixture.rows;
+  const checksum = fixtureChecksum('ga4-acquisition.json');
+  let runId = null;
+  let inserted = 0;
+
+  if (!dryRun) {
+    runId = await startRun('GA4_ACQUISITION', 'fixtures/ga4-acquisition.json', checksum, dryRun);
+    const dates = [...new Set(rows.map((r) => r.metric_date))];
+    for (const d of dates) {
+      await query('DELETE FROM ga4_acquisition_metrics WHERE metric_date = $1 AND is_demo = true', [d]);
+    }
+  }
+
+  try {
+    for (const row of rows) {
+      if (!row.metric_date) continue;
+      if (!dryRun) {
+        await query(
+          `INSERT INTO ga4_acquisition_metrics
+             (metric_date, channel_group, session_source, session_medium,
+              session_campaign, sessions, users, key_events, is_demo)
+           VALUES ($1,$2,$3,$4,$5,$6,$7,$8,true)`,
+          [
+            row.metric_date, row.channel_group, row.session_source,
+            row.session_medium, row.session_campaign,
+            row.sessions, row.users, row.key_events,
+          ]
+        );
+      }
+      inserted++;
+    }
+
+    if (!dryRun) await finishRun(runId, inserted, null);
+    return { inserted, skipped: 0, rejected: [], runId, dryRun };
+  } catch (err) {
+    if (!dryRun && runId) await finishRun(runId, inserted, err.message);
+    throw err;
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/**
+ * Import all GA4 fixture tables.
+ *
+ * TODO real API: when credentials exist, replace each fixture-load branch with
+ * a @google-analytics/data runReport call using:
+ *
+ *   Daily overview dimensions: ['date']
+ *   Daily overview metrics:    ['sessions','totalUsers','newUsers','engagedSessions',
+ *                               'engagementRate','averageSessionDuration','screenPageViews',
+ *                               'eventCount','keyEvents']
+ *
+ *   Landing page dimensions:   ['landingPage']
+ *   Landing page metrics:      ['sessions','totalUsers','screenPageViews',
+ *                               'engagedSessions','engagementRate','keyEvents']
+ *
+ *   Geo dimensions:            ['country','region','city']
+ *   Geo metrics:               ['sessions','totalUsers']
+ *
+ *   Acquisition dimensions:    ['sessionDefaultChannelGroup','sessionSource',
+ *                               'sessionMedium','sessionCampaignName']
+ *   Acquisition metrics:       ['sessions','totalUsers','keyEvents']
+ *
+ * @param {{ dryRun?: boolean }} options
+ * @returns {Promise<{ daily: object, landing: object, geo: object, acquisition: object, demo: true }>}
+ */
+async function importAll({ dryRun = false } = {}) {
+  if (hasCredentials()) {
+    throw new Error('GA4 live API not enabled (no service account) — install @google-analytics/data and implement the TODO real API branch.');
+  }
+
+  const [daily, landing, geo, acquisition] = await Promise.all([
+    importDaily(dryRun),
+    importLanding(dryRun),
+    importGeo(dryRun),
+    importAcquisition(dryRun),
+  ]);
+
+  return { daily, landing, geo, acquisition, demo: true };
+}
+
+/**
+ * Ensure an analytics_connections row exists for GA4 (idempotent).
+ * Sets status=DEMO_CONNECTED when no real creds, NOT_CONNECTED otherwise.
+ */
+async function ensureConnectionRecord() {
+  const status = hasCredentials() ? 'NOT_CONNECTED' : 'DEMO_CONNECTED';
+  await query(
+    `INSERT INTO analytics_connections (kind, status, is_demo)
+     VALUES ('GA4', $1, true)
+     ON CONFLICT DO NOTHING`,
+    [status]
+  );
+}
+
+module.exports = {
+  connectionTest,
+  importAll,
+  importDaily,
+  importLanding,
+  importGeo,
+  importAcquisition,
+  ensureConnectionRecord,
+  hasCredentials,
+};
diff --git a/src/connectors/gmail.js b/src/connectors/gmail.js
new file mode 100644
index 0000000..4255fe8
--- /dev/null
+++ b/src/connectors/gmail.js
@@ -0,0 +1,176 @@
+'use strict';
+
+/**
+ * Gmail API importer — spec §16.
+ *
+ * ADMIN-ONLY and DISABLED BY DEFAULT.
+ * `isEnabled()` returns false unless GMAIL_IMPORT_ENABLED=true is set.
+ *
+ * This module documents the OAuth least-privilege query shape and the
+ * intended import flow but does NOT implement live OAuth.  Implementing
+ * live OAuth requires a human-driven consent flow (browser redirect) that
+ * cannot run headlessly without the GMAIL_REFRESH_TOKEN being pre-obtained.
+ *
+ * Least-privilege scope: gmail.readonly — access existing messages and
+ * settings, no compose, no delete.
+ *
+ * Example Gmail search queries (§16):
+ *
+ *   // RENTV-owned messages with advertising/sponsorship signals
+ *   (from:(rentv.com) OR from:(shared1.ccsend.com) OR subject:(RENTV))
+ *   (sponsor OR sponsored OR advertiser OR advertising OR "Property Spotlight" OR "CRE Talk")
+ *
+ *   // Collect only messages with attachments (creatives, PDFs, rate cards)
+ *   from:(rentv.com) has:attachment
+ *
+ *   // Constant Contact delivery receipts / campaign archives
+ *   from:(shared1.ccsend.com) subject:(RENTV)
+ *
+ * The importer NEVER mirrors the full mailbox.  Only messages matching the
+ * configured GMAIL_IMPORT_QUERY are fetched, and only selected fields and
+ * attachments are stored.
+ *
+ * @module src/connectors/gmail
+ */
+
+// ---------------------------------------------------------------------------
+// Feature gate
+// ---------------------------------------------------------------------------
+
+/**
+ * Returns true only when Gmail import is explicitly enabled by the admin.
+ * @returns {boolean}
+ */
+function isEnabled() {
+  return process.env.GMAIL_IMPORT_ENABLED === 'true';
+}
+
+// ---------------------------------------------------------------------------
+// Disabled guard
+// ---------------------------------------------------------------------------
+
+function requireEnabled() {
+  if (!isEnabled()) {
+    throw new Error(
+      'Gmail import is disabled. ' +
+      'Set GMAIL_IMPORT_ENABLED=true and provide GMAIL_CLIENT_ID, ' +
+      'GMAIL_CLIENT_SECRET, and GMAIL_REFRESH_TOKEN to enable. ' +
+      'This feature is admin-only and requires explicit authorization. ' +
+      'Live OAuth is not implemented — obtain a refresh token via the ' +
+      'Google OAuth 2.0 Playground (https://developers.google.com/oauthplayground) ' +
+      'with scope: https://www.googleapis.com/auth/gmail.readonly'
+    );
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Connection test (stub — no live OAuth)
+// ---------------------------------------------------------------------------
+
+/**
+ * Test the Gmail import connection.
+ * Throws a friendly error when disabled.
+ *
+ * TODO live implementation:
+ *   const { google } = require('googleapis');  // not installed
+ *   const auth = new google.auth.OAuth2(
+ *     process.env.GMAIL_CLIENT_ID,
+ *     process.env.GMAIL_CLIENT_SECRET
+ *   );
+ *   auth.setCredentials({ refresh_token: process.env.GMAIL_REFRESH_TOKEN });
+ *   const gmail = google.gmail({ version: 'v1', auth });
+ *   const profile = await gmail.users.getProfile({ userId: 'me' });
+ *   return { connected: true, email: profile.data.emailAddress };
+ *
+ * @returns {{ connected: boolean, demo: boolean, error: string }}
+ */
+async function connectionTest() {
+  requireEnabled();
+  throw new Error('Gmail live OAuth not implemented — obtain a refresh token manually');
+}
+
+// ---------------------------------------------------------------------------
+// Message search (stub)
+// ---------------------------------------------------------------------------
+
+/**
+ * Search the authorized mailbox using the configured query.
+ *
+ * TODO live implementation:
+ *   const messages = [];
+ *   let pageToken;
+ *   do {
+ *     const res = await gmail.users.messages.list({
+ *       userId: 'me',
+ *       q: process.env.GMAIL_IMPORT_QUERY || DEFAULT_QUERY,
+ *       maxResults: 500,
+ *       pageToken,
+ *     });
+ *     messages.push(...(res.data.messages || []));
+ *     pageToken = res.data.nextPageToken;
+ *   } while (pageToken);
+ *   return messages; // [{ id, threadId }]
+ *
+ * Default queries (§16):
+ *   (from:(rentv.com) OR from:(shared1.ccsend.com) OR subject:(RENTV))
+ *   (sponsor OR sponsored OR advertiser OR advertising OR "Property Spotlight" OR "CRE Talk")
+ *
+ * @returns {Promise<Array<{ id: string, threadId: string }>>}
+ */
+async function searchMessages() {
+  requireEnabled();
+  throw new Error('Gmail import disabled');
+}
+
+// ---------------------------------------------------------------------------
+// Message fetch + selective field extraction (stub)
+// ---------------------------------------------------------------------------
+
+/**
+ * Fetch a single message and extract evidence fields.
+ *
+ * TODO live implementation:
+ *   const msg = await gmail.users.messages.get({
+ *     userId: 'me',
+ *     id: messageId,
+ *     format: 'full',
+ *   });
+ *   // Extract headers
+ *   const headers = msg.data.payload.headers;
+ *   const from    = headers.find(h => h.name === 'From')?.value;
+ *   const subject = headers.find(h => h.name === 'Subject')?.value;
+ *   const date    = headers.find(h => h.name === 'Date')?.value;
+ *   // Extract body (base64url decode)
+ *   // Extract attachment metadata (do not auto-open tracking links)
+ *   // Return structured evidence: { from, subject, date, bodyExcerpt, attachments }
+ *
+ * @param {string} messageId
+ * @returns {Promise<object>}
+ */
+async function fetchMessage(messageId) {
+  requireEnabled();
+  throw new Error('Gmail import disabled');
+}
+
+// ---------------------------------------------------------------------------
+// Full import (stub)
+// ---------------------------------------------------------------------------
+
+/**
+ * Run a full Gmail import pass.
+ * NEVER auto-opens email tracking links.
+ *
+ * @param {{ dryRun?: boolean, query?: string }} options
+ */
+async function importAll({ dryRun = false, query: gmailQuery } = {}) {
+  requireEnabled();
+  throw new Error('Gmail import disabled');
+}
+
+module.exports = {
+  isEnabled,
+  connectionTest,
+  searchMessages,
+  fetchMessage,
+  importAll,
+};
diff --git a/src/connectors/google-ads.js b/src/connectors/google-ads.js
new file mode 100644
index 0000000..985885f
--- /dev/null
+++ b/src/connectors/google-ads.js
@@ -0,0 +1,172 @@
+'use strict';
+
+/**
+ * Google Ads connector — spec §19.
+ *
+ * DISABLED BY DEFAULT.  `isEnabled()` returns false unless
+ * GOOGLE_ADS_ENABLED=true is explicitly set.
+ *
+ * Google Ads data MUST remain strictly separate from Search Console data.
+ * Never mix paid-search metrics with organic GSC metrics in the same table
+ * or display surface.
+ *
+ * No fixtures are provided for Google Ads because enabling it without real
+ * credentials would produce misleading paid-performance numbers.
+ *
+ * When credentials are eventually configured:
+ * - Set GOOGLE_ADS_ENABLED=true
+ * - Provide GOOGLE_ADS_DEVELOPER_TOKEN, GOOGLE_ADS_CUSTOMER_ID,
+ *   GOOGLE_ADS_LOGIN_CUSTOMER_ID, GOOGLE_ADS_CLIENT_ID,
+ *   GOOGLE_ADS_CLIENT_SECRET, GOOGLE_ADS_REFRESH_TOKEN
+ * - Implement the TODO real API branch using the google-ads-api npm package
+ *   or direct REST calls to googleads.googleapis.com/v17
+ *
+ * @module src/connectors/google-ads
+ */
+
+const { query } = require('../../db');
+
+// ---------------------------------------------------------------------------
+// Feature gate
+// ---------------------------------------------------------------------------
+
+/**
+ * Returns true only when Google Ads integration is explicitly enabled.
+ * @returns {boolean}
+ */
+function isEnabled() {
+  return process.env.GOOGLE_ADS_ENABLED === 'true';
+}
+
+// ---------------------------------------------------------------------------
+// Disabled guard — applied to every public function
+// ---------------------------------------------------------------------------
+
+function requireEnabled() {
+  if (!isEnabled()) {
+    throw new Error(
+      'Google Ads connector is not configured. ' +
+      'Set GOOGLE_ADS_ENABLED=true and provide all GOOGLE_ADS_* environment variables ' +
+      'to enable this integration.  Google Ads data is separate from Search Console data.'
+    );
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Connection test
+// ---------------------------------------------------------------------------
+
+/**
+ * Test the Google Ads connection.
+ * Throws a friendly error when disabled.
+ *
+ * @returns {{ connected: boolean, demo: boolean, error?: string }}
+ */
+async function connectionTest() {
+  requireEnabled();
+
+  // TODO real API:
+  // const { GoogleAdsClient } = require('google-ads-api');  // not installed
+  // const client = new GoogleAdsClient({
+  //   developer_token: process.env.GOOGLE_ADS_DEVELOPER_TOKEN,
+  //   client_id:       process.env.GOOGLE_ADS_CLIENT_ID,
+  //   client_secret:   process.env.GOOGLE_ADS_CLIENT_SECRET,
+  //   refresh_token:   process.env.GOOGLE_ADS_REFRESH_TOKEN,
+  //   login_customer_id: process.env.GOOGLE_ADS_LOGIN_CUSTOMER_ID,
+  // });
+  // const customer = client.Customer({ customer_id: process.env.GOOGLE_ADS_CUSTOMER_ID });
+  // await customer.query(`SELECT customer.id, customer.descriptive_name FROM customer LIMIT 1`);
+  throw new Error('Google Ads live API not enabled (install google-ads-api and implement the TODO real API branch)');
+}
+
+// ---------------------------------------------------------------------------
+// Campaign metrics import
+// ---------------------------------------------------------------------------
+
+/**
+ * Import campaign-level daily metrics into google_ads_campaign_metrics.
+ *
+ * TODO real API: GAQL query shape:
+ *   SELECT
+ *     campaign.name,
+ *     segments.date,
+ *     metrics.cost_micros,
+ *     metrics.clicks,
+ *     metrics.impressions,
+ *     metrics.conversions
+ *   FROM campaign
+ *   WHERE segments.date DURING LAST_90_DAYS
+ *     AND campaign.status != 'REMOVED'
+ *   ORDER BY segments.date DESC
+ *
+ * @param {{ dryRun?: boolean }} options
+ */
+async function importCampaignMetrics({ dryRun = false } = {}) {
+  requireEnabled();
+  // Implementation pending live credentials
+  throw new Error('Google Ads not configured');
+}
+
+// ---------------------------------------------------------------------------
+// Search term metrics import
+// ---------------------------------------------------------------------------
+
+/**
+ * Import search-term level metrics into google_ads_search_term_metrics.
+ *
+ * TODO real API: GAQL query shape:
+ *   SELECT
+ *     search_term_view.search_term,
+ *     campaign.name,
+ *     segments.date,
+ *     metrics.clicks,
+ *     metrics.impressions,
+ *     metrics.cost_micros,
+ *     metrics.conversions
+ *   FROM search_term_view
+ *   WHERE segments.date DURING LAST_90_DAYS
+ *   ORDER BY metrics.impressions DESC
+ *   LIMIT 10000
+ *
+ * @param {{ dryRun?: boolean }} options
+ */
+async function importSearchTermMetrics({ dryRun = false } = {}) {
+  requireEnabled();
+  throw new Error('Google Ads not configured');
+}
+
+// ---------------------------------------------------------------------------
+// Full import
+// ---------------------------------------------------------------------------
+
+/**
+ * Import all Google Ads data.
+ * Only callable when isEnabled() is true.
+ *
+ * @param {{ dryRun?: boolean }} options
+ */
+async function importAll({ dryRun = false } = {}) {
+  requireEnabled();
+  throw new Error('Google Ads not configured');
+}
+
+// ---------------------------------------------------------------------------
+// Connection record
+// ---------------------------------------------------------------------------
+
+async function ensureConnectionRecord() {
+  await query(
+    `INSERT INTO analytics_connections (kind, status, is_demo)
+     VALUES ('GOOGLE_ADS', 'NOT_CONNECTED', true)
+     ON CONFLICT DO NOTHING`
+  );
+}
+
+module.exports = {
+  isEnabled,
+  connectionTest,
+  importCampaignMetrics,
+  importSearchTermMetrics,
+  importAll,
+  ensureConnectionRecord,
+};
diff --git a/src/connectors/gsc.js b/src/connectors/gsc.js
new file mode 100644
index 0000000..be057da
--- /dev/null
+++ b/src/connectors/gsc.js
@@ -0,0 +1,291 @@
+'use strict';
+
+/**
+ * Google Search Console connector — spec §18.
+ *
+ * Fixture-backed when GSC_SITE_URL / GOOGLE_SERVICE_ACCOUNT_JSON_BASE64 are
+ * absent (the default). Loads gsc-queries.json and gsc-pages.json, classifies
+ * each query for brand/nonbrand and topic cluster, then UPSERTs into
+ * gsc_query_metrics / gsc_page_metrics with is_demo=true.
+ *
+ * IMPORTANT: Search Console data represents ORGANIC search only.  Never label
+ * it as paid search.
+ *
+ * @module src/connectors/gsc
+ */
+
+const path = require('path');
+const fs = require('fs');
+const crypto = require('crypto');
+const { query } = require('../../db');
+
+// ---------------------------------------------------------------------------
+// Credentials probe
+// ---------------------------------------------------------------------------
+
+function hasCredentials() {
+  return Boolean(process.env.GOOGLE_SERVICE_ACCOUNT_JSON_BASE64 && process.env.GSC_SITE_URL);
+}
+
+/**
+ * @returns {{ connected: boolean, demo: boolean, error?: string }}
+ */
+async function connectionTest() {
+  if (!hasCredentials()) {
+    return { connected: false, demo: true, error: 'No GSC_SITE_URL / GOOGLE_SERVICE_ACCOUNT_JSON_BASE64 configured — running in DEMO/fixture mode.' };
+  }
+  // TODO real API: use googleapis searchconsole.sites.getQueryReport or
+  // the webmasters.searchanalytics.query endpoint:
+  //
+  // const { google } = require('googleapis');
+  // const creds = JSON.parse(Buffer.from(process.env.GOOGLE_SERVICE_ACCOUNT_JSON_BASE64,'base64').toString('utf8'));
+  // const auth = new google.auth.GoogleAuth({ credentials: creds, scopes: ['https://www.googleapis.com/auth/webmasters.readonly'] });
+  // const sc = google.searchconsole({ version: 'v1', auth });
+  // await sc.sites.get({ siteUrl: process.env.GSC_SITE_URL });
+  throw new Error('GSC live API not enabled (no service account)');
+}
+
+// ---------------------------------------------------------------------------
+// Brand & cluster classification — spec §18
+// ---------------------------------------------------------------------------
+
+/**
+ * Classify a query as brand or nonbrand.
+ * Brand = contains the token 'rentv' (case-insensitive).
+ *
+ * @param {string} queryStr
+ * @returns {boolean}
+ */
+function classifyBrandVsNonbrand(queryStr) {
+  if (!queryStr || typeof queryStr !== 'string') return false;
+  return /rentv/i.test(queryStr);
+}
+
+/**
+ * Assign one topic cluster to a query string.
+ * Implements the §18 cluster taxonomy.
+ *
+ * Priority order matters: more specific patterns first.
+ *
+ * @param {string} queryStr
+ * @returns {string} one of the cluster keys defined in spec §18
+ */
+function clusterQuery(queryStr) {
+  if (!queryStr || typeof queryStr !== 'string') return 'other';
+  const q = queryStr.toLowerCase();
+
+  // Conference / event signals
+  if (/\b(conference|summit|event|expo|forum|naiop|uli|boma|icsc|crew|sior|ccim|convention|symposium|award|gala)\b/.test(q)) {
+    return 'conference_event';
+  }
+
+  // Advertiser category / advertising signals
+  if (/\b(advertis|sponsor|media kit|newsletter|eblast|placement|property spotlight|cre talk)\b/.test(q)) {
+    return 'advertiser_category';
+  }
+
+  // Finance / lending signals
+  if (/\b(financ|lend|loan|mortgage|debt|capital|credit|rate|refinanc|bridge|mezzanine|cmbs|note|fund)\b/.test(q)) {
+    return 'finance_lending';
+  }
+
+  // Brokerage / deal signals
+  if (/\b(brokerage|broker|sale|sold|deal|acquisition|disposition|1031|nnn|net lease|cap rate|listing)\b/.test(q)) {
+    return 'brokerage_deal';
+  }
+
+  // Property type signals
+  if (/\b(office|industrial|retail|multifamily|apartment|warehouse|flex|mixed.use|data.center|lab|life.science|hotel|hospitality|land|development)\b/.test(q)) {
+    return 'property_type';
+  }
+
+  // California market signals
+  if (/\b(los angeles|la |orange county|irvine|san diego|inland empire|bay area|san francisco|sacramento|ventura|pasadena|long beach|burbank|riverside|ontario|santa ana|socal|southern california|northern california|california|ca )\b/.test(q)) {
+    return 'california_market';
+  }
+
+  // Arizona market signals
+  if (/\b(phoenix|scottsdale|tempe|mesa|chandler|gilbert|glendale|tucson|arizona|az )\b/.test(q)) {
+    return 'arizona_market';
+  }
+
+  return 'other';
+}
+
+// ---------------------------------------------------------------------------
+// Fixture helpers
+// ---------------------------------------------------------------------------
+
+const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures');
+
+function loadFixture(name) {
+  return JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, name), 'utf8'));
+}
+
+function fixtureChecksum(name) {
+  return crypto.createHash('sha256').update(fs.readFileSync(path.join(FIXTURES_DIR, name))).digest('hex');
+}
+
+// ---------------------------------------------------------------------------
+// Import run bookkeeping (mirrors ga4.js pattern)
+// ---------------------------------------------------------------------------
+
+async function startRun(kind, sourceFile, checksum) {
+  const res = await query(
+    `INSERT INTO analytics_import_runs (kind, source_file, checksum, is_demo, status)
+     VALUES ($1,$2,$3,true,'RUNNING') RETURNING id`,
+    [kind, sourceFile, checksum]
+  );
+  return res.rows[0].id;
+}
+
+async function finishRun(runId, rowCount, error) {
+  await query(
+    `UPDATE analytics_import_runs
+        SET finished_at = now(), status = $2, row_count = $3, error = $4
+      WHERE id = $1`,
+    [runId, error ? 'ERROR' : 'SUCCESS', rowCount, error || null]
+  );
+}
+
+// ---------------------------------------------------------------------------
+// Importers
+// ---------------------------------------------------------------------------
+
+/**
+ * Load gsc-queries.json → gsc_query_metrics.
+ * Applies classifyBrandVsNonbrand() + clusterQuery() to each row.
+ *
+ * @param {boolean} dryRun
+ * @returns {{ inserted: number, skipped: number, rejected: [], runId: string|null, dryRun: boolean }}
+ */
+async function importQueries(dryRun = false) {
+  const fixture = loadFixture('gsc-queries.json');
+  const rows = fixture.rows;
+  const checksum = fixtureChecksum('gsc-queries.json');
+  let runId = null;
+  let inserted = 0;
+
+  if (!dryRun) {
+    runId = await startRun('GSC_QUERIES', 'fixtures/gsc-queries.json', checksum);
+    const dates = [...new Set(rows.map((r) => r.metric_date))];
+    for (const d of dates) {
+      await query('DELETE FROM gsc_query_metrics WHERE metric_date = $1 AND is_demo = true', [d]);
+    }
+  }
+
+  try {
+    for (const row of rows) {
+      if (!row.metric_date || !row.query) continue;
+      const isBrand = classifyBrandVsNonbrand(row.query);
+      const cluster = clusterQuery(row.query);
+
+      if (!dryRun) {
+        await query(
+          `INSERT INTO gsc_query_metrics
+             (metric_date, query, country, device, clicks, impressions, ctr, position,
+              is_brand, cluster, is_demo)
+           VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,true)`,
+          [
+            row.metric_date, row.query, row.country, row.device,
+            row.clicks, row.impressions, row.ctr, row.position,
+            isBrand, cluster,
+          ]
+        );
+      }
+      inserted++;
+    }
+
+    if (!dryRun) await finishRun(runId, inserted, null);
+    return { inserted, skipped: 0, rejected: [], runId, dryRun };
+  } catch (err) {
+    if (!dryRun && runId) await finishRun(runId, inserted, err.message);
+    throw err;
+  }
+}
+
+/**
+ * Load gsc-pages.json → gsc_page_metrics.
+ *
+ * @param {boolean} dryRun
+ * @returns {{ inserted: number, skipped: number, rejected: [], runId: string|null, dryRun: boolean }}
+ */
+async function importPages(dryRun = false) {
+  const fixture = loadFixture('gsc-pages.json');
+  const rows = fixture.rows;
+  const checksum = fixtureChecksum('gsc-pages.json');
+  let runId = null;
+  let inserted = 0;
+
+  if (!dryRun) {
+    runId = await startRun('GSC_PAGES', 'fixtures/gsc-pages.json', checksum);
+    const dates = [...new Set(rows.map((r) => r.metric_date))];
+    for (const d of dates) {
+      await query('DELETE FROM gsc_page_metrics WHERE metric_date = $1 AND is_demo = true', [d]);
+    }
+  }
+
+  try {
+    for (const row of rows) {
+      if (!row.metric_date || !row.page) continue;
+      if (!dryRun) {
+        await query(
+          `INSERT INTO gsc_page_metrics
+             (metric_date, page, country, device, clicks, impressions, ctr, position, is_demo)
+           VALUES ($1,$2,$3,$4,$5,$6,$7,$8,true)`,
+          [
+            row.metric_date, row.page, row.country, row.device,
+            row.clicks, row.impressions, row.ctr, row.position,
+          ]
+        );
+      }
+      inserted++;
+    }
+
+    if (!dryRun) await finishRun(runId, inserted, null);
+    return { inserted, skipped: 0, rejected: [], runId, dryRun };
+  } catch (err) {
+    if (!dryRun && runId) await finishRun(runId, inserted, err.message);
+    throw err;
+  }
+}
+
+/**
+ * Import all GSC data (queries + pages).
+ *
+ * TODO real API: replace fixture branches with googleapis searchconsole calls:
+ *
+ *   const body = {
+ *     startDate: '2026-05-08',
+ *     endDate: '2026-08-06',
+ *     dimensions: ['query', 'country', 'device', 'date'],
+ *     rowLimit: 25000,
+ *   };
+ *   const res = await sc.searchanalytics.query({ siteUrl: process.env.GSC_SITE_URL, requestBody: body });
+ *   // paginate via startRow until res.data.rows.length < rowLimit
+ *
+ * @param {{ dryRun?: boolean }} options
+ * @returns {Promise<{ queries: object, pages: object, demo: true }>}
+ */
+async function importAll({ dryRun = false } = {}) {
+  if (hasCredentials()) {
+    throw new Error('GSC live API not enabled (no service account) — implement the TODO real API branch.');
+  }
+
+  const [queries, pages] = await Promise.all([
+    importQueries(dryRun),
+    importPages(dryRun),
+  ]);
+
+  return { queries, pages, demo: true };
+}
+
+module.exports = {
+  connectionTest,
+  importAll,
+  importQueries,
+  importPages,
+  classifyBrandVsNonbrand,
+  clusterQuery,
+  hasCredentials,
+};
diff --git a/src/export/build.js b/src/export/build.js
new file mode 100644
index 0000000..d92fc8e
--- /dev/null
+++ b/src/export/build.js
@@ -0,0 +1,657 @@
+'use strict';
+/**
+ * buildDownloadEverything — the main export orchestrator (spec §28).
+ *
+ * Produces: RENTV-Advertiser-Intelligence-YYYY-MM-DD.zip
+ *
+ * ZIP contents (§28):
+ *   executive-viewer.html
+ *   README.html
+ *   advertisers.csv / .xlsx
+ *   contacts.csv / .xlsx
+ *   ad-sightings.csv
+ *   creatives.csv
+ *   conference-sponsors.csv
+ *   conferences.csv
+ *   prospects.csv
+ *   analytics-summary.csv
+ *   ga4-landing-pages.csv
+ *   ga4-geography.csv
+ *   gsc-queries.csv
+ *   gsc-pages.csv
+ *   google-ads-campaigns.csv   (only if google_ads_campaign_metrics has rows)
+ *   source-manifest.json
+ *   methodology.json
+ *   export-audit.json
+ *   thumbnails/                (only EXPORT_ALLOWED assets with file on disk)
+ *   evidence/                  (only export_allowed evidence records with file)
+ *
+ * Rights: every dataset passes through applyExportRights() before export.
+ * Writes an exports row (status → DONE) + an audit_logs row.
+ * Returns { zipPath, rowCounts }.
+ *
+ * @module src/export/build
+ */
+
+require('../../lib/env');       // populate process.env from .env if present
+const fs   = require('fs');
+const path = require('path');
+
+const { pool, query } = require('../../db');
+const { ZipWriter }   = require('./zip');
+const { toCsvBuffer } = require('./csv');
+const { toXlsxBuffer } = require('./xlsx');
+const { generateExecutiveViewer } = require('./executive-viewer');
+const {
+  applyExportRights,
+  buildSuppressionSets,
+  buildContactSuppressionSet,
+  classifyAssetForExport,
+} = require('./rights');
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** Return today's date as YYYY-MM-DD using the system clock. */
+function todayStr() {
+  return new Date().toISOString().slice(0, 10);
+}
+
+/**
+ * Safe JSON stringify — never throws.
+ * @param {*} obj
+ * @returns {string}
+ */
+function safeJson(obj) {
+  try {
+    return JSON.stringify(obj, null, 2);
+  } catch (_) {
+    return '{}';
+  }
+}
+
+/**
+ * Try to load a file from the local asset store.
+ * Returns a Buffer or null if the file doesn't exist / can't be read.
+ * @param {string} objectKey
+ * @returns {Buffer|null}
+ */
+function loadAssetFile(objectKey) {
+  if (!objectKey) return null;
+  const base = process.env.OBJECT_STORAGE_LOCAL_DIR || path.join(__dirname, '../../data/assets');
+  const filePath = path.join(base, objectKey);
+  try {
+    // Prevent path traversal: resolved path must be under base
+    const resolved = path.resolve(filePath);
+    const resolvedBase = path.resolve(base);
+    if (!resolved.startsWith(resolvedBase + path.sep) && resolved !== resolvedBase) {
+      return null;
+    }
+    return fs.readFileSync(resolved);
+  } catch (_) {
+    return null;
+  }
+}
+
+/**
+ * Convert a file Buffer to a data: URI given a mime type.
+ * @param {Buffer} buf
+ * @param {string} mimeType
+ * @returns {string}
+ */
+function toDataUri(buf, mimeType) {
+  const safe = mimeType || 'image/png';
+  return `data:${safe};base64,${buf.toString('base64')}`;
+}
+
+// ---------------------------------------------------------------------------
+// README HTML (self-contained)
+// ---------------------------------------------------------------------------
+function buildReadmeHtml(exportDate) {
+  return `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>RENTV Advertiser Intelligence — Export README</title>
+<style>
+  body { font-family: -apple-system, sans-serif; max-width: 760px; margin: 40px auto; padding: 0 20px; color: #1e293b; line-height: 1.6; }
+  h1 { font-size: 1.5rem; margin-bottom: 8px; }
+  h2 { font-size: 1.1rem; margin-top: 24px; margin-bottom: 6px; border-bottom: 2px solid #e2e8f0; padding-bottom: 4px; }
+  table { border-collapse: collapse; width: 100%; font-size: .85rem; margin-top: 8px; }
+  th, td { border: 1px solid #e2e8f0; padding: 6px 10px; text-align: left; }
+  th { background: #f8fafc; }
+  code { background: #f1f5f9; border-radius: 4px; padding: 1px 5px; font-size: .82rem; }
+  .warn { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 10px 14px; margin: 16px 0; }
+</style>
+</head>
+<body>
+<h1>RENTV Advertiser Intelligence Export</h1>
+<p>Export date: <strong>${exportDate}</strong></p>
+<p class="warn"><strong>Confidential.</strong> This export contains non-public business intelligence. Do not distribute to unauthorised recipients.</p>
+
+<h2>Files in this package</h2>
+<table>
+<thead><tr><th>File</th><th>Description</th></tr></thead>
+<tbody>
+<tr><td><code>executive-viewer.html</code></td><td>Interactive offline viewer — open by double-clicking. Works without internet.</td></tr>
+<tr><td><code>advertisers.csv / .xlsx</code></td><td>All non-suppressed organizations with their best classification status.</td></tr>
+<tr><td><code>contacts.csv / .xlsx</code></td><td>Public business contacts (do-not-contact excluded).</td></tr>
+<tr><td><code>ad-sightings.csv</code></td><td>Every verified ad/sponsor sighting with source URL and date.</td></tr>
+<tr><td><code>creatives.csv</code></td><td>Creative asset metadata. Files in <code>thumbnails/</code> where rights allow.</td></tr>
+<tr><td><code>conference-sponsors.csv</code></td><td>Event relationship rows for conference sponsors and exhibitors.</td></tr>
+<tr><td><code>conferences.csv</code></td><td>CRE conferences and events.</td></tr>
+<tr><td><code>prospects.csv</code></td><td>Opportunity scores with scoring factors.</td></tr>
+<tr><td><code>analytics-summary.csv</code></td><td>GA4 daily totals (aggregate only, no user-level data).</td></tr>
+<tr><td><code>ga4-landing-pages.csv</code></td><td>Top landing pages by sessions.</td></tr>
+<tr><td><code>ga4-geography.csv</code></td><td>Sessions and users by country/region/city.</td></tr>
+<tr><td><code>gsc-queries.csv</code></td><td>Google Search Console search queries.</td></tr>
+<tr><td><code>gsc-pages.csv</code></td><td>Google Search Console top pages.</td></tr>
+<tr><td><code>source-manifest.json</code></td><td>All enabled source policies and their provenance.</td></tr>
+<tr><td><code>methodology.json</code></td><td>Classification rules, export settings, and row counts.</td></tr>
+<tr><td><code>export-audit.json</code></td><td>Audit trail: which rights rules ran, what was excluded.</td></tr>
+<tr><td><code>thumbnails/</code></td><td>Ad/logo images where rights_status = EXPORT_ALLOWED.</td></tr>
+<tr><td><code>evidence/</code></td><td>Evidence files where export_allowed = true.</td></tr>
+</tbody>
+</table>
+
+<h2>Rights and privacy</h2>
+<ul>
+<li>Suppressed organizations are excluded from all files.</li>
+<li>Do-not-contact and export-blocked contacts are excluded from contacts files.</li>
+<li>Private notes are never exported.</li>
+<li>Images marked INTERNAL_EVIDENCE_ONLY are not included in thumbnails/ or evidence/.</li>
+<li>Unknown-rights images are treated as internal only (conservative default).</li>
+</ul>
+
+<h2>How to use the offline viewer</h2>
+<ol>
+<li>Double-click <code>executive-viewer.html</code> to open in your browser.</li>
+<li>Use the search box to find a company by name, domain, city, or state.</li>
+<li>Use California / Arizona buttons to filter by market.</li>
+<li>Toggle "Verified only" to show only confirmed advertisers and sponsors.</li>
+<li>Click company names or Source links to open the original evidence pages.</li>
+</ol>
+
+<p>For questions contact <a href="mailto:admin@rentv.com">admin@rentv.com</a>.</p>
+</body>
+</html>`;
+}
+
+// ---------------------------------------------------------------------------
+// CSV column definitions for each dataset
+// ---------------------------------------------------------------------------
+
+const ADVERTISER_COLUMNS = [
+  'id', 'display_name', 'legal_name', 'domain', 'organization_type',
+  'headquarters_city', 'headquarters_state', 'active_status',
+  'advertiser_categories', 'best_status', 'sighting_count',
+  'first_seen_at', 'last_seen_at', 'created_at',
+];
+
+const CONTACT_COLUMNS = [
+  'id', 'organization_id', 'person_id', 'type', 'value',
+  'explicitly_public', 'verified_at', 'confidence', 'created_at',
+];
+
+const AD_SIGHTINGS_COLUMNS = [
+  'id', 'organization_id', 'publication_id', 'relationship_status',
+  'source_page_url', 'headline', 'visible_copy',
+  'observed_at', 'first_observed_at', 'last_observed_at',
+  'verification_status', 'confidence', 'created_at',
+];
+
+const CREATIVES_COLUMNS = [
+  'id', 'organization_id', 'file_name', 'mime_type', 'width', 'height',
+  'rights_status', 'capture_method', 'source_image_url',
+  'captured_at', 'alt_text', 'export_class', 'created_at',
+];
+
+const CONF_SPONSORS_COLUMNS = [
+  'id', 'event_id', 'organization_id', 'person_id', 'relationship_status',
+  'sponsor_level', 'booth_number', 'session_title', 'panel_role',
+  'observed_at', 'confidence', 'created_at',
+];
+
+const CONFERENCES_COLUMNS = [
+  'id', 'name', 'event_type', 'start_date', 'end_date',
+  'venue', 'city', 'state', 'official_url',
+  'sponsor_page_url', 'exhibitor_page_url', 'created_at',
+];
+
+const PROSPECTS_COLUMNS = [
+  'id', 'organization_id', 'display_name', 'domain',
+  'score', 'factors', 'computed_at',
+];
+
+const ANALYTICS_SUMMARY_COLUMNS = [
+  'metric_date', 'sessions', 'total_users', 'new_users',
+  'engaged_sessions', 'engagement_rate', 'avg_engagement_time',
+  'views', 'event_count', 'key_events', 'is_demo',
+];
+
+const GA4_LANDING_COLUMNS = [
+  'id', 'metric_date', 'landing_page', 'sessions', 'users', 'views',
+  'engaged_sessions', 'engagement_rate', 'key_events', 'is_demo',
+];
+
+const GA4_GEO_COLUMNS = [
+  'id', 'metric_date', 'country', 'region', 'city',
+  'sessions', 'users', 'is_demo',
+];
+
+const GSC_QUERIES_COLUMNS = [
+  'id', 'metric_date', 'query', 'country', 'device',
+  'clicks', 'impressions', 'ctr', 'position', 'is_brand', 'cluster', 'is_demo',
+];
+
+const GSC_PAGES_COLUMNS = [
+  'id', 'metric_date', 'page', 'country', 'device',
+  'clicks', 'impressions', 'ctr', 'position', 'is_demo',
+];
+
+const GADS_COLUMNS = [
+  'id', 'metric_date', 'campaign', 'cost_micros', 'clicks',
+  'impressions', 'conversions', 'currency', 'is_demo',
+];
+
+// ---------------------------------------------------------------------------
+// Main build function
+// ---------------------------------------------------------------------------
+
+/**
+ * Build the Download Everything ZIP and return the result.
+ *
+ * @param {Object} opts
+ * @param {string} opts.outDir   - directory to write the ZIP into
+ * @returns {Promise<{zipPath: string, rowCounts: Object}>}
+ */
+async function buildDownloadEverything({ outDir }) {
+  const date = todayStr();
+  const folderName = `RENTV-Advertiser-Intelligence-${date}`;
+  const zipFileName = `${folderName}.zip`;
+  const zipPath = path.join(outDir, zipFileName);
+
+  // Create outDir if needed
+  fs.mkdirSync(outDir, { recursive: true });
+
+  // -----------------------------------------------------------------------
+  // 1. Load suppression context (rights enforcement)
+  // -----------------------------------------------------------------------
+  const suppressionRes = await query(
+    `SELECT id, scope, target_value, organization_id, person_id, active
+     FROM suppression_requests WHERE active = true`
+  );
+  const suppressionRows = suppressionRes.rows;
+  const { suppressedOrgIds, suppressedPersonIds } = buildSuppressionSets(suppressionRows);
+  const suppressedContactValues = buildContactSuppressionSet(suppressionRows);
+  const rightsCtx = { suppressedOrgIds, suppressedPersonIds, suppressedContactValues };
+
+  // -----------------------------------------------------------------------
+  // 2. Load all datasets
+  // -----------------------------------------------------------------------
+
+  // Organizations with aggregated sighting status
+  const orgRes = await query(`
+    SELECT o.*,
+           (SELECT relationship_status
+            FROM ad_sightings s
+            WHERE s.organization_id = o.id
+            ORDER BY ARRAY_POSITION(ARRAY[
+              'VERIFIED_ADVERTISER','VERIFIED_CONFERENCE_SPONSOR','VERIFIED_EXHIBITOR',
+              'VERIFIED_MEDIA_PARTNER','VERIFIED_CONTENT_PARTNER','PAST_ADVERTISER',
+              'LIKELY_PROSPECT','RESEARCH_NEEDED','SPEAKER_OR_PANELIST_ONLY','DISQUALIFIED'
+            ], s.relationship_status), s.created_at DESC
+            LIMIT 1
+           ) AS best_status,
+           (SELECT COUNT(*) FROM ad_sightings s2 WHERE s2.organization_id = o.id)::int AS sighting_count
+    FROM organizations o
+    ORDER BY o.display_name
+  `);
+  const rawOrgs = orgRes.rows.map((r) => ({
+    ...r,
+    advertiser_categories: JSON.stringify(r.advertiser_categories || []),
+    best_status: r.best_status || 'RESEARCH_NEEDED',
+  }));
+  const filteredOrgs = applyExportRights(rawOrgs, 'organizations', rightsCtx);
+
+  // Contacts
+  const contactRes = await query(
+    `SELECT * FROM contact_points ORDER BY created_at`
+  );
+  const filteredContacts = applyExportRights(contactRes.rows, 'contacts', rightsCtx);
+
+  // Ad sightings
+  const sightingRes = await query(
+    `SELECT * FROM ad_sightings ORDER BY observed_at DESC NULLS LAST, created_at DESC`
+  );
+  const filteredSightings = applyExportRights(sightingRes.rows, 'ad_sightings', rightsCtx);
+
+  // Creative assets
+  const creativeRes = await query(
+    `SELECT * FROM creative_assets ORDER BY created_at`
+  );
+  const filteredCreatives = applyExportRights(creativeRes.rows, 'creative_assets', rightsCtx);
+  const creativesForCsv = filteredCreatives.map((r) => ({
+    ...r,
+    export_class: r._exportClass,
+    _exportClass: undefined,
+  }));
+
+  // Event relationships (conference sponsors/exhibitors)
+  const eventRelRes = await query(
+    `SELECT * FROM event_relationships ORDER BY created_at`
+  );
+  const filteredEventRels = applyExportRights(eventRelRes.rows, 'generic', rightsCtx);
+
+  // Events (conferences)
+  const eventRes = await query(
+    `SELECT * FROM events ORDER BY start_date DESC NULLS LAST`
+  );
+  const filteredEvents = applyExportRights(eventRes.rows, 'generic', {
+    suppressedOrgIds,
+    orgIdField: 'organizer_organization_id',
+  });
+
+  // Opportunity scores (prospects)
+  const prospectsRes = await query(`
+    SELECT os.*, o.display_name, o.domain
+    FROM opportunity_scores os
+    JOIN organizations o ON o.id = os.organization_id
+    ORDER BY os.score DESC
+  `);
+  const filteredProspects = applyExportRights(prospectsRes.rows, 'generic', rightsCtx)
+    .map((r) => ({ ...r, factors: JSON.stringify(r.factors) }));
+
+  // GA4 daily
+  const ga4DailyRes = await query(
+    `SELECT * FROM ga4_daily_metrics ORDER BY metric_date DESC`
+  );
+  const filteredGa4Daily = ga4DailyRes.rows; // no org suppression needed for analytics
+
+  // GA4 landing pages
+  const ga4LpRes = await query(
+    `SELECT * FROM ga4_landing_page_metrics ORDER BY metric_date DESC, sessions DESC NULLS LAST`
+  );
+
+  // GA4 geo
+  const ga4GeoRes = await query(
+    `SELECT * FROM ga4_geo_metrics ORDER BY metric_date DESC, sessions DESC NULLS LAST`
+  );
+
+  // GSC queries
+  const gscQueryRes = await query(
+    `SELECT * FROM gsc_query_metrics ORDER BY metric_date DESC, impressions DESC NULLS LAST`
+  );
+
+  // GSC pages
+  const gscPageRes = await query(
+    `SELECT * FROM gsc_page_metrics ORDER BY metric_date DESC, clicks DESC NULLS LAST`
+  );
+
+  // Google Ads (optional — skip file if empty)
+  const gadsRes = await query(
+    `SELECT * FROM google_ads_campaign_metrics ORDER BY metric_date DESC`
+  );
+  const includeGads = gadsRes.rows.length > 0;
+
+  // Source policies
+  const sourcePoliciesRes = await query(
+    `SELECT * FROM source_policies ORDER BY display_name`
+  );
+
+  // Evidence records
+  const evidenceRes = await query(
+    `SELECT * FROM evidence_records ORDER BY created_at`
+  );
+  const filteredEvidence = applyExportRights(evidenceRes.rows, 'evidence_records', rightsCtx);
+
+  // Rate + audience snapshots (for methodology.json)
+  const rateRes = await query(
+    `SELECT * FROM rentv_rate_snapshots ORDER BY observed_at DESC`
+  );
+  const audienceRes = await query(
+    `SELECT * FROM rentv_audience_snapshots ORDER BY observed_at DESC`
+  );
+
+  // -----------------------------------------------------------------------
+  // 3. Build thumbnail data URIs (EXPORT_ALLOWED assets only)
+  // -----------------------------------------------------------------------
+  const thumbnailDataUris = new Map();
+  for (const asset of filteredCreatives) {
+    if (asset._exportClass !== 'include') continue;
+    const buf = loadAssetFile(asset.object_key);
+    if (!buf) continue;
+    thumbnailDataUris.set(asset.id, toDataUri(buf, asset.mime_type || 'image/png'));
+  }
+
+  // -----------------------------------------------------------------------
+  // 4. Generate content
+  // -----------------------------------------------------------------------
+
+  // executive-viewer.html
+  const viewerHtml = generateExecutiveViewer({
+    advertisers: filteredOrgs,
+    contacts: filteredContacts,
+    adSightings: filteredSightings,
+    events: filteredEvents,
+    eventRels: filteredEventRels,
+    prospects: filteredProspects,
+    ga4Summary: filteredGa4Daily,
+    thumbnailDataUris,
+    exportDate: date,
+    sourceCoverage: {
+      total: filteredOrgs.length,
+      withEvidence: filteredSightings.reduce((acc, s) => {
+        acc.add(s.organization_id); return acc;
+      }, new Set()).size,
+      lastUpdated: date,
+    },
+  });
+
+  // README.html
+  const readmeHtml = buildReadmeHtml(date);
+
+  // source-manifest.json
+  const sourceManifest = {
+    generated: new Date().toISOString(),
+    sources: sourcePoliciesRes.rows.map((p) => ({
+      source_key: p.source_key,
+      display_name: p.display_name,
+      owner: p.owner,
+      base_url: p.base_url,
+      access_method: p.access_method,
+      allows_automated_access: p.allows_automated_access,
+      allows_export: p.allows_export,
+      enabled: p.enabled,
+      reviewed_at: p.reviewed_at,
+    })),
+  };
+
+  // methodology.json
+  const methodology = {
+    generated: new Date().toISOString(),
+    export_date: date,
+    spec_version: '28',
+    classification_statuses: [
+      'VERIFIED_ADVERTISER','VERIFIED_CONFERENCE_SPONSOR','VERIFIED_EXHIBITOR',
+      'VERIFIED_MEDIA_PARTNER','VERIFIED_CONTENT_PARTNER','SPEAKER_OR_PANELIST_ONLY',
+      'PAST_ADVERTISER','LIKELY_PROSPECT','RESEARCH_NEEDED','DISQUALIFIED',
+    ],
+    rights_rules: [
+      'suppression_requests.active=true → exclude org/person entirely',
+      'contact_points.do_not_contact=true → exclude contact row',
+      'contact_points.export_allowed=false → exclude contact row',
+      'notes.is_private=true → never exported',
+      'creative_assets.rights_status=EXPORT_ALLOWED → include file in thumbnails/',
+      'creative_assets.rights_status=INTERNAL_EVIDENCE_ONLY → omit file, keep metadata',
+      'creative_assets.rights_status=UNKNOWN → omit file (conservative)',
+      'evidence_records.export_allowed=false → omit file, keep citation',
+      `EXPORT_MAX_ROWS cap: ${process.env.EXPORT_MAX_ROWS || '100000'}`,
+    ],
+    sqlite_note: 'database-readonly.sqlite omitted — no sqlite dependency in this build (pure Node, no npm extras). All data is available in the CSV/XLSX files.',
+    rentv_rate_snapshots: rateRes.rows,
+    rentv_audience_snapshots: audienceRes.rows,
+  };
+
+  // export-audit.json
+  const rowCounts = {
+    organizations:          filteredOrgs.length,
+    contacts:               filteredContacts.length,
+    ad_sightings:           filteredSightings.length,
+    creative_assets:        filteredCreatives.length,
+    event_relationships:    filteredEventRels.length,
+    events:                 filteredEvents.length,
+    prospects:              filteredProspects.length,
+    ga4_daily:              filteredGa4Daily.length,
+    ga4_landing_pages:      ga4LpRes.rows.length,
+    ga4_geography:          ga4GeoRes.rows.length,
+    gsc_queries:            gscQueryRes.rows.length,
+    gsc_pages:              gscPageRes.rows.length,
+    google_ads_campaigns:   gadsRes.rows.length,
+    evidence_records:       filteredEvidence.length,
+    source_policies:        sourcePoliciesRes.rows.length,
+    thumbnails_embedded:    thumbnailDataUris.size,
+  };
+
+  const exportAudit = {
+    generated: new Date().toISOString(),
+    export_date: date,
+    rights_enforcement: {
+      suppressed_orgs:     suppressedOrgIds.size,
+      suppressed_persons:  suppressedPersonIds.size,
+      suppressed_contacts: suppressedContactValues.size,
+      private_notes_excluded: true,
+      do_not_contact_excluded: true,
+      internal_evidence_files_excluded: true,
+      unknown_rights_files_excluded: true,
+    },
+    row_counts: rowCounts,
+    thumbnails_included: thumbnailDataUris.size,
+    google_ads_included: includeGads,
+  };
+
+  // -----------------------------------------------------------------------
+  // 5. Write exports record (status → DONE) + audit_logs
+  // -----------------------------------------------------------------------
+  let exportId;
+  try {
+    const expRes = await query(
+      `INSERT INTO exports (kind, status, row_counts, finished_at)
+       VALUES ('DOWNLOAD_EVERYTHING', 'DONE', $1, now())
+       RETURNING id`,
+      [JSON.stringify(rowCounts)]
+    );
+    exportId = expRes.rows[0].id;
+
+    await query(
+      `INSERT INTO audit_logs (action, entity_table, entity_id, actor, detail)
+       VALUES ('EXPORT_CREATED', 'exports', $1, 'system', $2)`,
+      [exportId, JSON.stringify({ zip: zipFileName, date, row_counts: rowCounts })]
+    );
+  } catch (dbErr) {
+    // Non-fatal: audit failure should not abort the export
+    console.warn('[export/build] DB audit write failed:', dbErr.message);
+  }
+
+  // -----------------------------------------------------------------------
+  // 6. Assemble the ZIP
+  // -----------------------------------------------------------------------
+  const zip = new ZipWriter();
+  const prefix = folderName + '/';
+
+  // HTML viewers
+  zip.addEntry(prefix + 'executive-viewer.html', viewerHtml);
+  zip.addEntry(prefix + 'README.html', readmeHtml);
+
+  // CSV + XLSX datasets
+  zip.addEntry(prefix + 'advertisers.csv',
+    toCsvBuffer(filteredOrgs, { columns: ADVERTISER_COLUMNS }));
+  zip.addEntry(prefix + 'advertisers.xlsx',
+    toXlsxBuffer(filteredOrgs, { columns: ADVERTISER_COLUMNS }));
+
+  zip.addEntry(prefix + 'contacts.csv',
+    toCsvBuffer(filteredContacts, { columns: CONTACT_COLUMNS }));
+  zip.addEntry(prefix + 'contacts.xlsx',
+    toXlsxBuffer(filteredContacts, { columns: CONTACT_COLUMNS }));
+
+  zip.addEntry(prefix + 'ad-sightings.csv',
+    toCsvBuffer(filteredSightings, { columns: AD_SIGHTINGS_COLUMNS }));
+
+  zip.addEntry(prefix + 'creatives.csv',
+    toCsvBuffer(creativesForCsv, { columns: CREATIVES_COLUMNS }));
+
+  zip.addEntry(prefix + 'conference-sponsors.csv',
+    toCsvBuffer(filteredEventRels, { columns: CONF_SPONSORS_COLUMNS }));
+
+  zip.addEntry(prefix + 'conferences.csv',
+    toCsvBuffer(filteredEvents, { columns: CONFERENCES_COLUMNS }));
+
+  zip.addEntry(prefix + 'prospects.csv',
+    toCsvBuffer(filteredProspects, { columns: PROSPECTS_COLUMNS }));
+
+  zip.addEntry(prefix + 'analytics-summary.csv',
+    toCsvBuffer(filteredGa4Daily, { columns: ANALYTICS_SUMMARY_COLUMNS }));
+
+  zip.addEntry(prefix + 'ga4-landing-pages.csv',
+    toCsvBuffer(ga4LpRes.rows, { columns: GA4_LANDING_COLUMNS }));
+
+  zip.addEntry(prefix + 'ga4-geography.csv',
+    toCsvBuffer(ga4GeoRes.rows, { columns: GA4_GEO_COLUMNS }));
+
+  zip.addEntry(prefix + 'gsc-queries.csv',
+    toCsvBuffer(gscQueryRes.rows, { columns: GSC_QUERIES_COLUMNS }));
+
+  zip.addEntry(prefix + 'gsc-pages.csv',
+    toCsvBuffer(gscPageRes.rows, { columns: GSC_PAGES_COLUMNS }));
+
+  if (includeGads) {
+    zip.addEntry(prefix + 'google-ads-campaigns.csv',
+      toCsvBuffer(gadsRes.rows, { columns: GADS_COLUMNS }));
+  }
+
+  // JSON manifests
+  zip.addEntry(prefix + 'source-manifest.json', safeJson(sourceManifest));
+  zip.addEntry(prefix + 'methodology.json',     safeJson(methodology));
+  zip.addEntry(prefix + 'export-audit.json',    safeJson(exportAudit));
+
+  // thumbnails/ — only EXPORT_ALLOWED assets with a file on disk
+  let thumbCount = 0;
+  for (const asset of filteredCreatives) {
+    if (asset._exportClass !== 'include') continue;
+    const buf = loadAssetFile(asset.object_key);
+    if (!buf) continue;
+    const ext = (asset.file_name || 'asset').split('.').pop() || 'bin';
+    const thumbName = `${asset.id}.${ext}`;
+    zip.addEntry(prefix + 'thumbnails/' + thumbName, buf);
+    thumbCount++;
+  }
+
+  // evidence/ — only export_allowed=true evidence with a file on disk
+  let evidenceFileCount = 0;
+  for (const ev of filteredEvidence) {
+    if (ev._exportClass !== 'include') continue;
+    const buf = loadAssetFile(ev.object_key);
+    if (!buf) continue;
+    const ext = (ev.object_key || 'file').split('.').pop() || 'bin';
+    const evName = `${ev.id}.${ext}`;
+    zip.addEntry(prefix + 'evidence/' + evName, buf);
+    evidenceFileCount++;
+  }
+
+  // -----------------------------------------------------------------------
+  // 7. Write ZIP to disk
+  // -----------------------------------------------------------------------
+  const zipBuf = zip.finalize();
+  fs.writeFileSync(zipPath, zipBuf);
+
+  // Finalize row counts with file-level stats
+  rowCounts.thumbnails_files_bundled = thumbCount;
+  rowCounts.evidence_files_bundled   = evidenceFileCount;
+  rowCounts.zip_bytes = zipBuf.length;
+
+  return { zipPath, rowCounts };
+}
+
+module.exports = { buildDownloadEverything };
diff --git a/src/export/csv.js b/src/export/csv.js
new file mode 100644
index 0000000..08ee80b
--- /dev/null
+++ b/src/export/csv.js
@@ -0,0 +1,81 @@
+'use strict';
+/**
+ * Pure-Node CSV writer — RFC 4180 compliant quoting/escaping.
+ *
+ * - All fields containing commas, double-quotes, or newlines are quoted.
+ * - Double-quotes inside a quoted field are escaped as "".
+ * - Null/undefined rendered as empty string.
+ * - Numbers and booleans serialized as strings.
+ * - CRLF line endings (RFC 4180).
+ *
+ * @module src/export/csv
+ */
+
+/**
+ * Serialize one field value to RFC 4180.
+ * @param {*} value
+ * @returns {string}
+ */
+function escapeField(value) {
+  if (value === null || value === undefined) return '';
+  let str = String(value);
+  // Must quote if contains comma, double-quote, CR, LF, or leading/trailing space
+  if (/[",\r\n]/.test(str) || str !== str.trim()) {
+    str = '"' + str.replace(/"/g, '""') + '"';
+  }
+  return str;
+}
+
+/**
+ * Serialize one row (array of values) to a CSV line with CRLF.
+ * @param {Array} row
+ * @returns {string}
+ */
+function rowToLine(row) {
+  return row.map(escapeField).join(',') + '\r\n';
+}
+
+/**
+ * Convert an array of objects to a CSV string.
+ * Column order is determined by the keys of the first row (or the explicit
+ * `columns` parameter).
+ *
+ * @param {Object[]} rows
+ * @param {Object} [opts]
+ * @param {string[]} [opts.columns]  - explicit column order; defaults to Object.keys(rows[0])
+ * @param {boolean}  [opts.header]   - include header row (default true)
+ * @returns {string} UTF-8 CSV string (BOM prefix for Excel compatibility)
+ */
+function toCsv(rows, opts = {}) {
+  const { columns, header = true } = opts;
+
+  if (!rows || rows.length === 0) {
+    const cols = columns || [];
+    const headerLine = header && cols.length ? rowToLine(cols) : '';
+    return '' + headerLine; // BOM for Excel
+  }
+
+  const cols = columns || Object.keys(rows[0]);
+  const lines = [];
+
+  if (header) lines.push(rowToLine(cols));
+
+  for (const row of rows) {
+    lines.push(rowToLine(cols.map((c) => row[c])));
+  }
+
+  // BOM (U+FEFF) so Excel on Windows recognizes UTF-8 correctly
+  return '' + lines.join('');
+}
+
+/**
+ * Convert an array of objects to a Buffer (UTF-8 with BOM).
+ * @param {Object[]} rows
+ * @param {Object} [opts]
+ * @returns {Buffer}
+ */
+function toCsvBuffer(rows, opts = {}) {
+  return Buffer.from(toCsv(rows, opts), 'utf8');
+}
+
+module.exports = { toCsv, toCsvBuffer, escapeField, rowToLine };
diff --git a/src/export/executive-viewer.js b/src/export/executive-viewer.js
new file mode 100644
index 0000000..fc2e920
--- /dev/null
+++ b/src/export/executive-viewer.js
@@ -0,0 +1,644 @@
+'use strict';
+/**
+ * Executive viewer generator — produces the self-contained executive-viewer.html
+ * that is bundled inside the Download Everything ZIP (spec §28).
+ *
+ * Requirements:
+ *   - Works by double-click (file:// protocol), NO server required.
+ *   - All assets embedded inline: CSS in <style>, JS in <script>, data in
+ *     const DATA = {...} JSON blob so file:// can read sibling files.
+ *   - Thumbnail images embedded as data: URIs (only EXPORT_ALLOWED assets).
+ *   - INTERNAL_EVIDENCE_ONLY images → neutral placeholder, not embedded.
+ *   - Private notes and suppressed entities are already excluded upstream
+ *     by applyExportRights() before this function is called.
+ *   - CA/AZ filters, search (fuzzy text match), verified-only toggle.
+ *   - Accessible (ARIA roles, tabindex, visible focus) and printable.
+ *   - No external resources (no CDN, no fonts API, no images from http://).
+ *
+ * @module src/export/executive-viewer
+ */
+
+/**
+ * Escape a string for safe inline HTML output.
+ * @param {*} v
+ * @returns {string}
+ */
+function h(v) {
+  if (v === null || v === undefined) return '';
+  return String(v)
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;');
+}
+
+/**
+ * Generate the self-contained executive-viewer.html.
+ *
+ * @param {Object} payload
+ * @param {Object[]} payload.advertisers  - filtered organizations with joined fields
+ * @param {Object[]} payload.contacts     - filtered contact_points rows
+ * @param {Object[]} payload.adSightings  - filtered ad_sightings rows
+ * @param {Object[]} payload.events       - filtered events rows
+ * @param {Object[]} payload.eventRels    - filtered event_relationships rows
+ * @param {Object[]} payload.prospects    - filtered opportunity_scores rows (joined)
+ * @param {Object[]} payload.ga4Summary   - aggregated GA4 daily metrics
+ * @param {Map<string,string>} payload.thumbnailDataUris - assetId → data:image/... URI
+ * @param {string}   payload.exportDate   - ISO date string YYYY-MM-DD
+ * @param {Object}   payload.sourceCoverage - { total, withEvidence, lastUpdated }
+ * @returns {string} complete HTML document
+ */
+function generateExecutiveViewer(payload) {
+  const {
+    advertisers = [],
+    contacts = [],
+    adSightings = [],
+    events = [],
+    eventRels = [],
+    prospects = [],
+    ga4Summary = [],
+    thumbnailDataUris = new Map(),
+    exportDate = new Date().toISOString().slice(0, 10),
+    sourceCoverage = {},
+  } = payload;
+
+  // Pre-index contacts and ad-sightings by org id for O(1) lookup in cards
+  const contactsByOrg = new Map();
+  for (const c of contacts) {
+    if (!contactsByOrg.has(c.organization_id)) contactsByOrg.set(c.organization_id, []);
+    contactsByOrg.get(c.organization_id).push(c);
+  }
+
+  const sightingsByOrg = new Map();
+  for (const s of adSightings) {
+    if (!sightingsByOrg.has(s.organization_id)) sightingsByOrg.set(s.organization_id, []);
+    sightingsByOrg.get(s.organization_id).push(s);
+  }
+
+  const eventRelsByOrg = new Map();
+  for (const er of eventRels) {
+    if (!eventRelsByOrg.has(er.organization_id)) eventRelsByOrg.set(er.organization_id, []);
+    eventRelsByOrg.get(er.organization_id).push(er);
+  }
+
+  // Build the inline DATA blob that drives client-side JS
+  // Thumbnails already filtered to EXPORT_ALLOWED only
+  const dataBlobObj = {
+    exportDate,
+    sourceCoverage,
+    advertisers: advertisers.map((org) => ({
+      id: org.id,
+      display_name: org.display_name,
+      domain: org.domain || null,
+      headquarters_state: org.headquarters_state || null,
+      headquarters_city: org.headquarters_city || null,
+      organization_type: org.organization_type || null,
+      advertiser_categories: org.advertiser_categories || [],
+      active_status: org.active_status || 'ACTIVE',
+      // Computed best relationship status from ad_sightings
+      best_status: (() => {
+        const sightings = sightingsByOrg.get(org.id) || [];
+        const statuses = sightings.map((s) => s.relationship_status);
+        const priority = [
+          'VERIFIED_ADVERTISER',
+          'VERIFIED_CONFERENCE_SPONSOR',
+          'VERIFIED_EXHIBITOR',
+          'VERIFIED_MEDIA_PARTNER',
+          'VERIFIED_CONTENT_PARTNER',
+          'PAST_ADVERTISER',
+          'LIKELY_PROSPECT',
+          'RESEARCH_NEEDED',
+          'SPEAKER_OR_PANELIST_ONLY',
+          'DISQUALIFIED',
+        ];
+        for (const p of priority) {
+          if (statuses.includes(p)) return p;
+        }
+        return 'RESEARCH_NEEDED';
+      })(),
+      thumbnail_asset_id: (() => {
+        const sightings = sightingsByOrg.get(org.id) || [];
+        for (const s of sightings) {
+          if (s.thumbnail_asset_id && thumbnailDataUris.has(s.thumbnail_asset_id)) {
+            return s.thumbnail_asset_id;
+          }
+        }
+        return null;
+      })(),
+      contacts: (contactsByOrg.get(org.id) || []).map((c) => ({
+        type: c.type,
+        value: c.value,
+        explicitly_public: c.explicitly_public,
+      })),
+      sightings: (sightingsByOrg.get(org.id) || []).map((s) => ({
+        relationship_status: s.relationship_status,
+        source_page_url: s.source_page_url || null,
+        headline: s.headline || null,
+        observed_at: s.observed_at || null,
+        verification_status: s.verification_status,
+      })),
+      event_rels: (eventRelsByOrg.get(org.id) || []).map((er) => ({
+        relationship_status: er.relationship_status,
+        sponsor_level: er.sponsor_level || null,
+      })),
+    })),
+    ga4Summary: ga4Summary.slice(0, 90), // last ~3 months for dashboard
+    // Convert Map to object for JSON serialization
+    thumbnails: Object.fromEntries(thumbnailDataUris),
+  };
+
+  const dataJson = JSON.stringify(dataBlobObj);
+
+  // Status label lookup (mirrors lib/types.js)
+  const STATUS_LABELS = {
+    VERIFIED_ADVERTISER: 'Verified advertiser',
+    VERIFIED_CONFERENCE_SPONSOR: 'Verified conference sponsor',
+    VERIFIED_EXHIBITOR: 'Verified exhibitor',
+    VERIFIED_MEDIA_PARTNER: 'Verified media partner',
+    VERIFIED_CONTENT_PARTNER: 'Content partner',
+    SPEAKER_OR_PANELIST_ONLY: 'Speaker / panelist only',
+    PAST_ADVERTISER: 'Past advertiser',
+    LIKELY_PROSPECT: 'Likely prospect',
+    RESEARCH_NEEDED: 'Research needed',
+    DISQUALIFIED: 'Disqualified',
+  };
+
+  const VERIFIED_STATUSES = new Set([
+    'VERIFIED_ADVERTISER',
+    'VERIFIED_CONFERENCE_SPONSOR',
+    'VERIFIED_EXHIBITOR',
+    'VERIFIED_MEDIA_PARTNER',
+    'VERIFIED_CONTENT_PARTNER',
+  ]);
+
+  const statusLabelsJson = JSON.stringify(STATUS_LABELS);
+  const verifiedStatusesJson = JSON.stringify([...VERIFIED_STATUSES]);
+
+  // Stats for the header bar
+  const totalAdvertisers = advertisers.length;
+  const verifiedCount = advertisers.filter((o) => {
+    const sightings = sightingsByOrg.get(o.id) || [];
+    return sightings.some((s) => VERIFIED_STATUSES.has(s.relationship_status));
+  }).length;
+  const caCount = advertisers.filter(
+    (o) => (o.headquarters_state || '').toUpperCase() === 'CA'
+  ).length;
+  const azCount = advertisers.filter(
+    (o) => (o.headquarters_state || '').toUpperCase() === 'AZ'
+  ).length;
+
+  return `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<meta name="description" content="RENTV Advertiser Intelligence — offline viewer, data as of ${h(exportDate)}">
+<title>RENTV Advertiser Intelligence — ${h(exportDate)}</title>
+<style>
+/* ========================================================
+   RENTV Advertiser Intelligence — Offline Viewer Styles
+   Self-contained, printable, accessible.
+   ======================================================== */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+:root {
+  --blue:    #1a56db;
+  --blue-d:  #1140b0;
+  --green:   #0e7a3a;
+  --amber:   #9a5800;
+  --red:     #b91c1c;
+  --gray-1:  #f8fafc;
+  --gray-2:  #f1f5f9;
+  --gray-3:  #e2e8f0;
+  --gray-4:  #cbd5e1;
+  --gray-5:  #94a3b8;
+  --gray-7:  #334155;
+  --gray-9:  #0f172a;
+  --radius:  8px;
+  --shadow:  0 1px 3px rgba(0,0,0,.12), 0 1px 2px rgba(0,0,0,.08);
+  --shadow-md: 0 4px 6px -1px rgba(0,0,0,.1);
+  font-size: 16px;
+}
+
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+  background: var(--gray-1);
+  color: var(--gray-9);
+  line-height: 1.5;
+  min-height: 100vh;
+}
+
+/* Header */
+.header {
+  background: var(--blue);
+  color: #fff;
+  padding: 16px 24px;
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  flex-wrap: wrap;
+}
+.header h1 { font-size: 1.25rem; font-weight: 700; letter-spacing: -.01em; }
+.header .badge {
+  background: rgba(255,255,255,.18);
+  border-radius: 999px;
+  font-size: .75rem;
+  padding: 2px 10px;
+  font-weight: 600;
+}
+.header .meta { margin-left: auto; font-size: .8rem; opacity: .85; }
+
+/* Stats bar */
+.stats-bar {
+  background: #fff;
+  border-bottom: 1px solid var(--gray-3);
+  padding: 12px 24px;
+  display: flex;
+  gap: 24px;
+  flex-wrap: wrap;
+  font-size: .85rem;
+}
+.stat { display: flex; align-items: baseline; gap: 6px; }
+.stat .num { font-size: 1.35rem; font-weight: 700; color: var(--blue); }
+.stat .lbl { color: var(--gray-7); }
+
+/* Toolbar */
+.toolbar {
+  background: #fff;
+  border-bottom: 1px solid var(--gray-3);
+  padding: 12px 24px;
+  display: flex;
+  gap: 12px;
+  flex-wrap: wrap;
+  align-items: center;
+}
+.toolbar label { font-size: .85rem; font-weight: 600; color: var(--gray-7); }
+
+#searchInput {
+  flex: 1;
+  min-width: 200px;
+  max-width: 400px;
+  border: 2px solid var(--gray-3);
+  border-radius: var(--radius);
+  padding: 8px 12px;
+  font-size: .9rem;
+  transition: border-color .15s;
+}
+#searchInput:focus { outline: none; border-color: var(--blue); }
+
+.filter-btns { display: flex; gap: 6px; flex-wrap: wrap; }
+.filter-btn {
+  border: 2px solid var(--gray-3);
+  background: #fff;
+  border-radius: var(--radius);
+  padding: 6px 14px;
+  font-size: .82rem;
+  font-weight: 600;
+  cursor: pointer;
+  color: var(--gray-7);
+  transition: border-color .12s, background .12s, color .12s;
+}
+.filter-btn:hover  { border-color: var(--blue); color: var(--blue); }
+.filter-btn.active { background: var(--blue); border-color: var(--blue); color: #fff; }
+.filter-btn:focus  { outline: 2px solid var(--blue); outline-offset: 2px; }
+
+#verifiedToggle { accent-color: var(--blue); width: 18px; height: 18px; cursor: pointer; }
+
+/* Main grid */
+.main { padding: 20px 24px; }
+
+#resultCount { font-size: .82rem; color: var(--gray-5); margin-bottom: 14px; }
+
+.grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+  gap: 16px;
+}
+
+/* Advertiser card */
+.card {
+  background: #fff;
+  border-radius: var(--radius);
+  box-shadow: var(--shadow);
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+  transition: box-shadow .15s;
+}
+.card:hover { box-shadow: var(--shadow-md); }
+
+.card-thumb {
+  width: 100%;
+  aspect-ratio: 16/7;
+  background: var(--gray-2);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+}
+.card-thumb img { width: 100%; height: 100%; object-fit: contain; }
+.card-thumb .placeholder {
+  color: var(--gray-5);
+  font-size: .75rem;
+  text-align: center;
+  padding: 8px;
+  user-select: none;
+}
+
+.card-body { padding: 14px 16px; flex: 1; display: flex; flex-direction: column; gap: 8px; }
+
+.card-name { font-size: 1rem; font-weight: 700; color: var(--gray-9); }
+.card-name a { color: inherit; text-decoration: none; }
+.card-name a:hover { text-decoration: underline; color: var(--blue); }
+
+.card-meta { font-size: .78rem; color: var(--gray-5); }
+
+/* Status badge */
+.status-badge {
+  display: inline-block;
+  border-radius: 999px;
+  font-size: .72rem;
+  font-weight: 700;
+  padding: 2px 10px;
+  letter-spacing: .02em;
+  text-transform: uppercase;
+}
+.badge-verified    { background: #d1fae5; color: var(--green); }
+.badge-past        { background: #e0f2fe; color: #0369a1; }
+.badge-prospect    { background: #fef3c7; color: var(--amber); }
+.badge-research    { background: var(--gray-2); color: var(--gray-5); }
+.badge-speaker     { background: #f3e8ff; color: #6b21a8; }
+.badge-disqualified{ background: #fee2e2; color: var(--red); }
+
+/* Sightings list */
+.sightings-list { list-style: none; font-size: .78rem; color: var(--gray-7); }
+.sightings-list li { padding: 3px 0; border-bottom: 1px solid var(--gray-2); }
+.sightings-list li:last-child { border: none; }
+.sightings-list a { color: var(--blue); word-break: break-all; }
+.sightings-list a:hover { text-decoration: underline; }
+
+/* Contacts list */
+.contacts-list { list-style: none; font-size: .78rem; }
+.contacts-list li { padding: 2px 0; color: var(--gray-7); }
+
+/* No-results */
+.empty {
+  grid-column: 1/-1;
+  text-align: center;
+  padding: 60px 0;
+  color: var(--gray-5);
+  font-size: .9rem;
+}
+
+/* Footer */
+.footer {
+  background: var(--gray-2);
+  border-top: 1px solid var(--gray-3);
+  padding: 14px 24px;
+  font-size: .75rem;
+  color: var(--gray-5);
+  text-align: center;
+  margin-top: 24px;
+}
+.footer a { color: var(--blue); }
+
+/* Print styles */
+@media print {
+  .toolbar, .stats-bar { display: none; }
+  .header { background: #222; }
+  .card { break-inside: avoid; box-shadow: none; border: 1px solid var(--gray-3); }
+  body { background: #fff; }
+  .card-thumb img { max-height: 120px; }
+}
+
+/* Focus ring for keyboard nav */
+a:focus, button:focus, input:focus, [tabindex]:focus {
+  outline: 2px solid var(--blue);
+  outline-offset: 2px;
+}
+</style>
+</head>
+<body>
+
+<header class="header" role="banner">
+  <h1>RENTV Advertiser Intelligence</h1>
+  <span class="badge" aria-label="Offline viewer">Offline Viewer</span>
+  <div class="meta" aria-label="Data export date">Data as of <strong>${h(exportDate)}</strong></div>
+</header>
+
+<section class="stats-bar" aria-label="Summary statistics">
+  <div class="stat"><span class="num" id="statTotal">${totalAdvertisers}</span><span class="lbl">Companies</span></div>
+  <div class="stat"><span class="num" id="statVerified">${verifiedCount}</span><span class="lbl">Verified advertisers/sponsors</span></div>
+  <div class="stat"><span class="num">${caCount}</span><span class="lbl">California</span></div>
+  <div class="stat"><span class="num">${azCount}</span><span class="lbl">Arizona</span></div>
+  <div class="stat"><span class="lbl">Source coverage: ${h(sourceCoverage.total || 0)} companies, ${h(sourceCoverage.withEvidence || 0)} with evidence</span></div>
+</section>
+
+<section class="toolbar" role="search" aria-label="Search and filter advertisers">
+  <label for="searchInput">Search</label>
+  <input
+    type="search"
+    id="searchInput"
+    placeholder="Company name, domain, city, state..."
+    aria-label="Search advertisers"
+    autocomplete="off"
+    spellcheck="false"
+  >
+
+  <div class="filter-btns" role="group" aria-label="State filters">
+    <button class="filter-btn active" data-state="ALL"  aria-pressed="true">All</button>
+    <button class="filter-btn"        data-state="CA"   aria-pressed="false">California</button>
+    <button class="filter-btn"        data-state="AZ"   aria-pressed="false">Arizona</button>
+  </div>
+
+  <div style="display:flex;align-items:center;gap:6px;">
+    <input type="checkbox" id="verifiedToggle" aria-label="Show verified only">
+    <label for="verifiedToggle" style="font-size:.82rem;font-weight:600;color:var(--gray-7);cursor:pointer;">
+      Verified only
+    </label>
+  </div>
+</section>
+
+<main class="main" id="main" role="main">
+  <div id="resultCount" aria-live="polite" aria-atomic="true"></div>
+  <div class="grid" id="grid" role="list" aria-label="Advertiser cards"></div>
+</main>
+
+<footer class="footer" role="contentinfo">
+  RENTV Advertiser Intelligence — exported ${h(exportDate)}.
+  This file contains confidential business intelligence. Do not redistribute.
+  Source: <a href="https://rentv.com" target="_blank" rel="noopener">rentv.com</a>.
+</footer>
+
+<script>
+/* ================================================================
+   RENTV Advertiser Intelligence — Offline Viewer Runtime
+   Vanilla JS, no external dependencies, works on file:// protocol.
+   ================================================================ */
+
+// --------------- Inline data ---------------
+const DATA = ${dataJson};
+
+const STATUS_LABELS  = ${statusLabelsJson};
+const VERIFIED_SET   = new Set(${verifiedStatusesJson});
+
+// --------------- Utilities ---------------
+function h(v) {
+  if (v == null) return '';
+  return String(v)
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;');
+}
+
+function statusBadgeClass(status) {
+  if (VERIFIED_SET.has(status)) return 'badge-verified';
+  if (status === 'PAST_ADVERTISER') return 'badge-past';
+  if (status === 'LIKELY_PROSPECT') return 'badge-prospect';
+  if (status === 'SPEAKER_OR_PANELIST_ONLY') return 'badge-speaker';
+  if (status === 'DISQUALIFIED') return 'badge-disqualified';
+  return 'badge-research';
+}
+
+// Simple multi-word search: all space-separated tokens must appear somewhere
+function matchesSearch(org, q) {
+  if (!q) return true;
+  const tokens = q.toLowerCase().split(/\\s+/).filter(Boolean);
+  const haystack = [
+    org.display_name || '',
+    org.domain || '',
+    org.headquarters_city || '',
+    org.headquarters_state || '',
+    org.organization_type || '',
+    (org.advertiser_categories || []).join(' '),
+    (org.sightings || []).map(s => s.headline || '').join(' '),
+  ].join(' ').toLowerCase();
+  return tokens.every(t => haystack.includes(t));
+}
+
+// --------------- State ---------------
+let activeState = 'ALL';
+let verifiedOnly = false;
+let searchQ = '';
+
+// --------------- Render ---------------
+function renderCard(org) {
+  const label = STATUS_LABELS[org.best_status] || org.best_status;
+  const badgeClass = statusBadgeClass(org.best_status);
+
+  // Thumbnail
+  let thumbHtml;
+  if (org.thumbnail_asset_id && DATA.thumbnails[org.thumbnail_asset_id]) {
+    const dataUri = DATA.thumbnails[org.thumbnail_asset_id];
+    thumbHtml = \`<div class="card-thumb"><img src="\${dataUri}" alt="Ad thumbnail for \${h(org.display_name)}" loading="lazy"></div>\`;
+  } else {
+    thumbHtml = \`<div class="card-thumb"><span class="placeholder" aria-hidden="true">No ad thumbnail available</span></div>\`;
+  }
+
+  // Domain link
+  const nameHtml = org.domain
+    ? \`<a href="https://\${h(org.domain)}" target="_blank" rel="noopener noreferrer" aria-label="\${h(org.display_name)} website">\${h(org.display_name)}</a>\`
+    : h(org.display_name);
+
+  // Location
+  const loc = [org.headquarters_city, org.headquarters_state].filter(Boolean).join(', ');
+
+  // Up to 3 sightings with source links
+  const sightings = (org.sightings || []).slice(0, 3);
+  const sightingsHtml = sightings.length
+    ? \`<ul class="sightings-list" aria-label="Ad sightings">
+        \${sightings.map(s => {
+          const label = STATUS_LABELS[s.relationship_status] || s.relationship_status;
+          const date = s.observed_at ? new Date(s.observed_at).toLocaleDateString() : '';
+          const linkHtml = s.source_page_url
+            ? \` — <a href="\${h(s.source_page_url)}" target="_blank" rel="noopener noreferrer">Source</a>\`
+            : '';
+          return \`<li>\${h(label)}\${s.headline ? ': ' + h(s.headline) : ''}\${date ? ' (' + date + ')' : ''}\${linkHtml}</li>\`;
+        }).join('')}
+      </ul>\`
+    : '';
+
+  // Up to 2 public contacts (email/phone only)
+  const publicContacts = (org.contacts || [])
+    .filter(c => c.explicitly_public && ['BUSINESS_EMAIL','BUSINESS_PHONE'].includes(c.type))
+    .slice(0, 2);
+  const contactsHtml = publicContacts.length
+    ? \`<ul class="contacts-list" aria-label="Public contacts">
+        \${publicContacts.map(c => {
+          if (c.type === 'BUSINESS_EMAIL') {
+            return \`<li><a href="mailto:\${h(c.value)}">\${h(c.value)}</a></li>\`;
+          }
+          return \`<li>\${h(c.value)}</li>\`;
+        }).join('')}
+      </ul>\`
+    : '';
+
+  return \`
+<article class="card" role="listitem" aria-label="\${h(org.display_name)} — \${h(label)}">
+  \${thumbHtml}
+  <div class="card-body">
+    <div class="card-name">\${nameHtml}</div>
+    \${loc ? \`<div class="card-meta">\${h(loc)}</div>\` : ''}
+    <div>
+      <span class="status-badge \${badgeClass}" title="Classification: \${h(label)}">\${h(label)}</span>
+    </div>
+    \${sightingsHtml}
+    \${contactsHtml}
+  </div>
+</article>\`;
+}
+
+function applyFilters() {
+  const q = searchQ.trim();
+  const filtered = DATA.advertisers.filter(org => {
+    if (activeState !== 'ALL' && (org.headquarters_state || '').toUpperCase() !== activeState) return false;
+    if (verifiedOnly && !VERIFIED_SET.has(org.best_status)) return false;
+    if (q && !matchesSearch(org, q)) return false;
+    return true;
+  });
+
+  const grid = document.getElementById('grid');
+  const countEl = document.getElementById('resultCount');
+
+  if (filtered.length === 0) {
+    grid.innerHTML = '<div class="empty" role="status">No advertisers match the current filters.</div>';
+    countEl.textContent = 'No results.';
+    return;
+  }
+
+  countEl.textContent = \`Showing \${filtered.length} of \${DATA.advertisers.length} companies.\`;
+  grid.innerHTML = filtered.map(renderCard).join('');
+}
+
+// --------------- Event wiring ---------------
+document.getElementById('searchInput').addEventListener('input', (e) => {
+  searchQ = e.target.value;
+  applyFilters();
+});
+
+document.querySelectorAll('.filter-btn[data-state]').forEach(btn => {
+  btn.addEventListener('click', () => {
+    activeState = btn.dataset.state;
+    document.querySelectorAll('.filter-btn[data-state]').forEach(b => {
+      const on = b === btn;
+      b.classList.toggle('active', on);
+      b.setAttribute('aria-pressed', on ? 'true' : 'false');
+    });
+    applyFilters();
+  });
+});
+
+document.getElementById('verifiedToggle').addEventListener('change', (e) => {
+  verifiedOnly = e.target.checked;
+  applyFilters();
+});
+
+// Initial render
+applyFilters();
+</script>
+</body>
+</html>`;
+}
+
+module.exports = { generateExecutiveViewer };
diff --git a/src/export/rights.js b/src/export/rights.js
new file mode 100644
index 0000000..9ff3115
--- /dev/null
+++ b/src/export/rights.js
@@ -0,0 +1,314 @@
+'use strict';
+/**
+ * Export rights enforcement — spec §6, §28.
+ *
+ * Every dataset flowing into any export MUST pass through applyExportRights()
+ * or one of its dataset-specific helpers before being written to disk or ZIP.
+ *
+ * Rules enforced here (spec §6, §28):
+ *   SR  suppression_requests: if active=true and scope matches org/person/contact,
+ *       the entire entity is excluded from the export.
+ *   DNC contact_points.do_not_contact=true  → exclude that contact row.
+ *   XA  contact_points.export_allowed=false → exclude that contact row.
+ *   PN  notes.is_private=true               → exclude that note row.
+ *   CA  creative_assets.rights_status:
+ *         EXPORT_ALLOWED → include file bytes in thumbnails/
+ *         INTERNAL_EVIDENCE_ONLY → omit file, keep link-only placeholder
+ *         LINK_ONLY      → omit file, keep link-only placeholder
+ *         UNKNOWN        → omit file (conservative)
+ *   ER  evidence_records.export_allowed=false → omit the asset, keep citation text.
+ *   MAX EXPORT_MAX_ROWS cap per dataset (env, default 100000).
+ *
+ * @module src/export/rights
+ */
+
+const EXPORT_MAX_ROWS = parseInt(process.env.EXPORT_MAX_ROWS || '100000', 10);
+
+/**
+ * @typedef {'organizations'|'contacts'|'notes'|'creative_assets'|'evidence_records'|'generic'} DatasetKind
+ */
+
+/**
+ * Compute the set of suppressed organization IDs and person IDs from a
+ * supression_requests result set.  Call once and pass the sets to helpers.
+ *
+ * @param {Object[]} suppressionRows  - rows from suppression_requests WHERE active=true
+ * @returns {{ suppressedOrgIds: Set<string>, suppressedPersonIds: Set<string> }}
+ */
+function buildSuppressionSets(suppressionRows) {
+  const suppressedOrgIds = new Set();
+  const suppressedPersonIds = new Set();
+
+  for (const row of suppressionRows || []) {
+    if (!row.active) continue;
+    const scope = (row.scope || '').toUpperCase();
+    if (scope === 'ORGANIZATION' && row.organization_id) {
+      suppressedOrgIds.add(row.organization_id);
+    }
+    if (scope === 'PERSON' && row.person_id) {
+      suppressedPersonIds.add(row.person_id);
+    }
+    // CONTACT_POINT / EMAIL / PHONE are handled at the contact level
+  }
+
+  return { suppressedOrgIds, suppressedPersonIds };
+}
+
+/**
+ * Compute the set of suppressed contact value strings from suppression_requests.
+ *
+ * @param {Object[]} suppressionRows
+ * @returns {Set<string>}
+ */
+function buildContactSuppressionSet(suppressionRows) {
+  const suppressed = new Set();
+  for (const row of suppressionRows || []) {
+    if (!row.active) continue;
+    const scope = (row.scope || '').toUpperCase();
+    if (['CONTACT_POINT', 'EMAIL', 'PHONE'].includes(scope) && row.target_value) {
+      suppressed.add(row.target_value.toLowerCase().trim());
+    }
+  }
+  return suppressed;
+}
+
+/**
+ * Filter organizations, excluding suppressed orgs.
+ *
+ * @param {Object[]} rows
+ * @param {Set<string>} suppressedOrgIds
+ * @returns {Object[]}
+ */
+function filterOrganizations(rows, suppressedOrgIds) {
+  let out = (rows || []).filter((r) => !suppressedOrgIds.has(r.id));
+  if (out.length > EXPORT_MAX_ROWS) out = out.slice(0, EXPORT_MAX_ROWS);
+  return out;
+}
+
+/**
+ * Filter contact_points rows, removing:
+ *   - do_not_contact = true
+ *   - export_allowed = false
+ *   - contacts belonging to suppressed orgs or persons
+ *   - contacts whose value matches a suppressed contact value
+ *
+ * @param {Object[]} rows
+ * @param {Object}   suppressionSets
+ * @param {Set<string>} suppressionSets.suppressedOrgIds
+ * @param {Set<string>} suppressionSets.suppressedPersonIds
+ * @param {Set<string>} suppressionSets.suppressedContactValues
+ * @returns {Object[]}
+ */
+function filterContacts(rows, suppressionSets) {
+  const { suppressedOrgIds, suppressedPersonIds, suppressedContactValues } = suppressionSets;
+  let out = (rows || []).filter((r) => {
+    if (r.do_not_contact === true) return false;
+    if (r.export_allowed === false) return false;
+    if (suppressedOrgIds && r.organization_id && suppressedOrgIds.has(r.organization_id)) return false;
+    if (suppressedPersonIds && r.person_id && suppressedPersonIds.has(r.person_id)) return false;
+    if (suppressedContactValues && r.value) {
+      const norm = String(r.value).toLowerCase().trim();
+      if (suppressedContactValues.has(norm)) return false;
+    }
+    return true;
+  });
+  if (out.length > EXPORT_MAX_ROWS) out = out.slice(0, EXPORT_MAX_ROWS);
+  return out;
+}
+
+/**
+ * Filter people rows, removing suppressed persons and people belonging to
+ * suppressed orgs.
+ *
+ * @param {Object[]} rows
+ * @param {Set<string>} suppressedOrgIds
+ * @param {Set<string>} suppressedPersonIds
+ * @returns {Object[]}
+ */
+function filterPeople(rows, suppressedOrgIds, suppressedPersonIds) {
+  let out = (rows || []).filter((r) => {
+    if (suppressedPersonIds && suppressedPersonIds.has(r.id)) return false;
+    if (suppressedOrgIds && r.organization_id && suppressedOrgIds.has(r.organization_id)) return false;
+    return true;
+  });
+  if (out.length > EXPORT_MAX_ROWS) out = out.slice(0, EXPORT_MAX_ROWS);
+  return out;
+}
+
+/**
+ * Filter notes rows, removing private notes and notes for suppressed orgs/people.
+ *
+ * @param {Object[]} rows
+ * @param {Set<string>} suppressedOrgIds
+ * @param {Set<string>} suppressedPersonIds
+ * @returns {Object[]}
+ */
+function filterNotes(rows, suppressedOrgIds, suppressedPersonIds) {
+  let out = (rows || []).filter((r) => {
+    if (r.is_private === true) return false;
+    if (suppressedOrgIds && r.organization_id && suppressedOrgIds.has(r.organization_id)) return false;
+    if (suppressedPersonIds && r.person_id && suppressedPersonIds.has(r.person_id)) return false;
+    return true;
+  });
+  if (out.length > EXPORT_MAX_ROWS) out = out.slice(0, EXPORT_MAX_ROWS);
+  return out;
+}
+
+/**
+ * Filter ad_sightings rows, removing entries for suppressed orgs.
+ *
+ * @param {Object[]} rows
+ * @param {Set<string>} suppressedOrgIds
+ * @returns {Object[]}
+ */
+function filterAdSightings(rows, suppressedOrgIds) {
+  let out = (rows || []).filter((r) => !suppressedOrgIds.has(r.organization_id));
+  if (out.length > EXPORT_MAX_ROWS) out = out.slice(0, EXPORT_MAX_ROWS);
+  return out;
+}
+
+/**
+ * Classify a creative_asset row for export.
+ *
+ * Returns:
+ *   'include'      - include the file bytes in thumbnails/
+ *   'link_only'    - include a placeholder row but NOT the file bytes
+ *   'omit'         - exclude from export entirely
+ *
+ * @param {Object} asset - creative_assets row
+ * @returns {'include'|'link_only'|'omit'}
+ */
+function classifyAssetForExport(asset) {
+  if (!asset) return 'omit';
+  const rs = (asset.rights_status || 'UNKNOWN').toUpperCase();
+  switch (rs) {
+    case 'EXPORT_ALLOWED':
+      return 'include';
+    case 'INTERNAL_EVIDENCE_ONLY':
+      return 'link_only';
+    case 'LINK_ONLY':
+      return 'link_only';
+    case 'UNKNOWN':
+    default:
+      return 'link_only'; // conservative: don't export unknown-rights images
+  }
+}
+
+/**
+ * Filter creative_assets rows for the creatives.csv metadata export.
+ * All assets are included as metadata rows; the classification tells callers
+ * whether to also bundle the file bytes.
+ *
+ * @param {Object[]} rows
+ * @param {Set<string>} suppressedOrgIds
+ * @returns {Object[]} rows annotated with _exportClass: 'include'|'link_only'
+ */
+function filterCreativeAssets(rows, suppressedOrgIds) {
+  let out = (rows || [])
+    .filter((r) => !suppressedOrgIds.has(r.organization_id))
+    .map((r) => ({ ...r, _exportClass: classifyAssetForExport(r) }));
+  if (out.length > EXPORT_MAX_ROWS) out = out.slice(0, EXPORT_MAX_ROWS);
+  return out;
+}
+
+/**
+ * Filter evidence_records rows.
+ * All evidence is included as citation metadata; export_allowed=false means
+ * the file/image bytes are omitted (only the citation text/URL is kept).
+ *
+ * @param {Object[]} rows
+ * @returns {Object[]} rows annotated with _exportClass: 'include'|'link_only'
+ */
+function filterEvidenceRecords(rows) {
+  let out = (rows || []).map((r) => ({
+    ...r,
+    _exportClass: r.export_allowed === true ? 'include' : 'link_only',
+  }));
+  if (out.length > EXPORT_MAX_ROWS) out = out.slice(0, EXPORT_MAX_ROWS);
+  return out;
+}
+
+/**
+ * Generic row filter — applies suppressed-org exclusion and row cap.
+ *
+ * @param {Object[]} rows
+ * @param {Set<string>} suppressedOrgIds
+ * @param {string} [orgIdField='organization_id']
+ * @returns {Object[]}
+ */
+function filterGeneric(rows, suppressedOrgIds, orgIdField = 'organization_id') {
+  let out = (rows || []).filter((r) => {
+    if (r[orgIdField] && suppressedOrgIds.has(r[orgIdField])) return false;
+    return true;
+  });
+  if (out.length > EXPORT_MAX_ROWS) out = out.slice(0, EXPORT_MAX_ROWS);
+  return out;
+}
+
+/**
+ * Master rights filter — the single entry point for all export datasets.
+ *
+ * @param {Object[]} rows            - raw DB rows
+ * @param {DatasetKind} kind         - controls which rules apply
+ * @param {Object} ctx               - rights context
+ * @param {Set<string>} ctx.suppressedOrgIds
+ * @param {Set<string>} ctx.suppressedPersonIds
+ * @param {Set<string>} [ctx.suppressedContactValues]
+ * @param {string} [ctx.orgIdField]  - for 'generic' kind
+ * @returns {Object[]}
+ */
+function applyExportRights(rows, kind, ctx) {
+  const {
+    suppressedOrgIds = new Set(),
+    suppressedPersonIds = new Set(),
+    suppressedContactValues = new Set(),
+    orgIdField,
+  } = ctx || {};
+
+  switch (kind) {
+    case 'organizations':
+      return filterOrganizations(rows, suppressedOrgIds);
+
+    case 'contacts':
+      return filterContacts(rows, {
+        suppressedOrgIds,
+        suppressedPersonIds,
+        suppressedContactValues,
+      });
+
+    case 'people':
+      return filterPeople(rows, suppressedOrgIds, suppressedPersonIds);
+
+    case 'notes':
+      return filterNotes(rows, suppressedOrgIds, suppressedPersonIds);
+
+    case 'ad_sightings':
+      return filterAdSightings(rows, suppressedOrgIds);
+
+    case 'creative_assets':
+      return filterCreativeAssets(rows, suppressedOrgIds);
+
+    case 'evidence_records':
+      return filterEvidenceRecords(rows);
+
+    case 'generic':
+    default:
+      return filterGeneric(rows, suppressedOrgIds, orgIdField);
+  }
+}
+
+module.exports = {
+  applyExportRights,
+  buildSuppressionSets,
+  buildContactSuppressionSet,
+  classifyAssetForExport,
+  filterOrganizations,
+  filterContacts,
+  filterPeople,
+  filterNotes,
+  filterAdSightings,
+  filterCreativeAssets,
+  filterEvidenceRecords,
+  filterGeneric,
+  EXPORT_MAX_ROWS,
+};
diff --git a/src/export/xlsx.js b/src/export/xlsx.js
new file mode 100644
index 0000000..dbe88cf
--- /dev/null
+++ b/src/export/xlsx.js
@@ -0,0 +1,241 @@
+'use strict';
+/**
+ * Pure-Node minimal XLSX writer — Open XML SpreadsheetML.
+ *
+ * An .xlsx file is a ZIP (PKWARE) containing Open XML parts:
+ *   [Content_Types].xml
+ *   _rels/.rels
+ *   xl/workbook.xml
+ *   xl/_rels/workbook.xml.rels
+ *   xl/worksheets/sheet1.xml
+ *   xl/sharedStrings.xml
+ *
+ * This implementation:
+ *   - Produces one sheet per call.
+ *   - Stores strings in the shared string table (type="s") for small file size.
+ *   - Stores numbers as inline numbers (type="n").
+ *   - Does NOT support formulas, styles, or multi-sheet workbooks (not needed here).
+ *   - Reuses zip.js (also pure Node, STORE method).
+ *
+ * Usage:
+ *   const buf = toXlsxBuffer(rows, { columns: ['name','score'] });
+ *   fs.writeFileSync('out.xlsx', buf);
+ *
+ * @module src/export/xlsx
+ */
+
+const { ZipWriter } = require('./zip');
+
+/**
+ * Escape XML special characters.
+ * @param {string} str
+ * @returns {string}
+ */
+function xmlEscape(str) {
+  return String(str)
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&apos;')
+    // Strip control characters (not valid XML 1.0 except tab/CR/LF)
+    .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
+}
+
+/**
+ * Convert a 1-based column index to Excel letter notation (1=A, 26=Z, 27=AA).
+ * @param {number} n
+ * @returns {string}
+ */
+function colLetter(n) {
+  let s = '';
+  while (n > 0) {
+    const rem = (n - 1) % 26;
+    s = String.fromCharCode(65 + rem) + s;
+    n = Math.floor((n - 1) / 26);
+  }
+  return s;
+}
+
+/**
+ * Build the shared strings table XML and index map.
+ * Returns { xml, index } where index is a Map<string, number>.
+ *
+ * @param {Object[]} rows
+ * @param {string[]} columns
+ * @returns {{ xml: string, index: Map<string,number>, count: number }}
+ */
+function buildSharedStrings(rows, columns) {
+  const index = new Map();
+  const strings = [];
+
+  function intern(val) {
+    if (val === null || val === undefined) return;
+    const s = String(val);
+    if (typeof val === 'number' && isFinite(val)) return; // kept inline
+    if (!index.has(s)) {
+      index.set(s, strings.length);
+      strings.push(s);
+    }
+  }
+
+  // Intern column headers
+  for (const col of columns) intern(col);
+
+  // Intern all string cell values
+  for (const row of rows) {
+    for (const col of columns) {
+      const v = row[col];
+      if (typeof v !== 'number' || !isFinite(v)) {
+        intern(v === null || v === undefined ? '' : String(v));
+      }
+    }
+  }
+
+  const total = strings.length;
+  const items = strings.map((s) => `<si><t xml:space="preserve">${xmlEscape(s)}</t></si>`).join('');
+  const xml =
+    `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
+    `<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="${total}" uniqueCount="${total}">` +
+    items +
+    `</sst>`;
+
+  return { xml, index, count: total };
+}
+
+/**
+ * Build sheet1.xml — the worksheet XML.
+ *
+ * @param {Object[]} rows
+ * @param {string[]} columns
+ * @param {Map<string,number>} ssIndex - shared string index
+ * @returns {string}
+ */
+function buildSheetXml(rows, columns, ssIndex) {
+  const totalRows = rows.length + 1; // +1 for header
+
+  /**
+   * Build a cell reference like "A1".
+   * @param {number} col  1-based column index
+   * @param {number} row  1-based row index
+   */
+  const ref = (col, row) => `${colLetter(col)}${row}`;
+
+  /** Build a cell element. */
+  function cellEl(colIdx, rowIdx, value) {
+    const r = ref(colIdx, rowIdx);
+
+    if (value === null || value === undefined || value === '') {
+      // Empty string shared string
+      const si = ssIndex.get('');
+      if (si !== undefined) {
+        return `<c r="${r}" t="s"><v>${si}</v></c>`;
+      }
+      return `<c r="${r}"/>`;
+    }
+
+    if (typeof value === 'number' && isFinite(value)) {
+      return `<c r="${r}" t="n"><v>${value}</v></c>`;
+    }
+
+    const s = String(value);
+    const si = ssIndex.get(s);
+    if (si !== undefined) {
+      return `<c r="${r}" t="s"><v>${si}</v></c>`;
+    }
+    // Fallback: inline string (shouldn't happen if buildSharedStrings ran first)
+    return `<c r="${r}" t="inlineStr"><is><t>${xmlEscape(s)}</t></is></c>`;
+  }
+
+  // Build row XML strings
+  const rowEls = [];
+
+  // Header row (row 1)
+  const headerCells = columns.map((col, i) => cellEl(i + 1, 1, col)).join('');
+  rowEls.push(`<row r="1">${headerCells}</row>`);
+
+  // Data rows
+  for (let ri = 0; ri < rows.length; ri++) {
+    const rowIdx = ri + 2;
+    const cells = columns.map((col, ci) => cellEl(ci + 1, rowIdx, rows[ri][col])).join('');
+    rowEls.push(`<row r="${rowIdx}">${cells}</row>`);
+  }
+
+  // Dimension ref e.g. "A1:E5"
+  const dimRef =
+    totalRows > 0 && columns.length > 0
+      ? `A1:${colLetter(columns.length)}${totalRows}`
+      : 'A1';
+
+  return (
+    `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
+    `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"` +
+    ` xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">` +
+    `<dimension ref="${dimRef}"/>` +
+    `<sheetData>` +
+    rowEls.join('') +
+    `</sheetData>` +
+    `</worksheet>`
+  );
+}
+
+/** Static Open XML relationship + content-type parts. */
+const CONTENT_TYPES_XML =
+  `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
+  `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
+  `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
+  `<Default Extension="xml" ContentType="application/xml"/>` +
+  `<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>` +
+  `<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>` +
+  `<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>` +
+  `</Types>`;
+
+const RELS_XML =
+  `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
+  `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
+  `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>` +
+  `</Relationships>`;
+
+const WORKBOOK_XML =
+  `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
+  `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"` +
+  ` xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">` +
+  `<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets>` +
+  `</workbook>`;
+
+const WORKBOOK_RELS_XML =
+  `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
+  `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
+  `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>` +
+  `<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>` +
+  `</Relationships>`;
+
+/**
+ * Convert an array of row objects to an XLSX Buffer.
+ *
+ * @param {Object[]} rows
+ * @param {Object} [opts]
+ * @param {string[]} [opts.columns]  - column order; defaults to Object.keys(rows[0])
+ * @returns {Buffer}
+ */
+function toXlsxBuffer(rows, opts = {}) {
+  const safeRows = rows || [];
+  const columns =
+    opts.columns ||
+    (safeRows.length > 0 ? Object.keys(safeRows[0]) : []);
+
+  const { xml: ssXml, index: ssIndex } = buildSharedStrings(safeRows, columns);
+  const sheetXml = buildSheetXml(safeRows, columns, ssIndex);
+
+  const zip = new ZipWriter();
+  zip.addEntry('[Content_Types].xml', CONTENT_TYPES_XML);
+  zip.addEntry('_rels/.rels', RELS_XML);
+  zip.addEntry('xl/workbook.xml', WORKBOOK_XML);
+  zip.addEntry('xl/_rels/workbook.xml.rels', WORKBOOK_RELS_XML);
+  zip.addEntry('xl/worksheets/sheet1.xml', sheetXml);
+  zip.addEntry('xl/sharedStrings.xml', ssXml);
+
+  return zip.finalize();
+}
+
+module.exports = { toXlsxBuffer, colLetter, xmlEscape };
diff --git a/src/export/zip.js b/src/export/zip.js
new file mode 100644
index 0000000..c71eae7
--- /dev/null
+++ b/src/export/zip.js
@@ -0,0 +1,190 @@
+'use strict';
+/**
+ * Pure-Node minimal ZIP writer — STORE method (no compression).
+ * Produces a valid ZIP file openable by macOS, Windows, and standard tools.
+ *
+ * Format references:
+ *   PKWARE APPNOTE.TXT §4.3 — local file headers + central directory + EOCD
+ *   CRC-32 via standard IEEE 802.3 polynomial (0xEDB88320)
+ *
+ * Usage:
+ *   const zip = new ZipWriter();
+ *   zip.addEntry('folder/file.txt', Buffer.from('hello'));
+ *   const buf = zip.finalize();          // returns a Buffer
+ *   fs.writeFileSync('out.zip', buf);
+ *
+ * @module src/export/zip
+ */
+
+/** Pre-built CRC-32 lookup table (IEEE 802.3 polynomial 0xEDB88320). */
+const CRC_TABLE = (() => {
+  const t = new Uint32Array(256);
+  for (let i = 0; i < 256; i++) {
+    let c = i;
+    for (let j = 0; j < 8; j++) {
+      c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+    }
+    t[i] = c;
+  }
+  return t;
+})();
+
+/**
+ * Compute CRC-32 checksum of a Buffer.
+ * @param {Buffer} buf
+ * @returns {number} unsigned 32-bit integer
+ */
+function crc32(buf) {
+  let c = 0xffffffff;
+  for (let i = 0; i < buf.length; i++) {
+    c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
+  }
+  return (c ^ 0xffffffff) >>> 0;
+}
+
+/**
+ * Write a 32-bit little-endian uint into a Buffer at offset.
+ * @param {Buffer} buf
+ * @param {number} offset
+ * @param {number} value
+ */
+function writeUInt32LE(buf, offset, value) {
+  buf.writeUInt32LE(value >>> 0, offset);
+}
+
+/**
+ * Write a 16-bit little-endian uint into a Buffer at offset.
+ * @param {Buffer} buf
+ * @param {number} offset
+ * @param {number} value
+ */
+function writeUInt16LE(buf, offset, value) {
+  buf.writeUInt16LE(value & 0xffff, offset);
+}
+
+/**
+ * DOS date/time encoding for a JS Date.
+ * @param {Date} date
+ * @returns {{ dosTime: number, dosDate: number }}
+ */
+function dosDateTime(date) {
+  const d = date instanceof Date ? date : new Date();
+  const dosTime =
+    ((d.getHours() & 0x1f) << 11) |
+    ((d.getMinutes() & 0x3f) << 5) |
+    (Math.floor(d.getSeconds() / 2) & 0x1f);
+  const dosDate =
+    (((d.getFullYear() - 1980) & 0x7f) << 9) |
+    (((d.getMonth() + 1) & 0x0f) << 5) |
+    (d.getDate() & 0x1f);
+  return { dosTime, dosDate };
+}
+
+/**
+ * @typedef {Object} ZipEntry
+ * @property {string} name     - file path inside the ZIP (forward slashes)
+ * @property {Buffer} data     - raw file bytes
+ * @property {number} crc      - CRC-32 of data
+ * @property {number} localOffset - byte offset of local file header in the archive
+ * @property {number} dosTime
+ * @property {number} dosDate
+ */
+
+class ZipWriter {
+  constructor() {
+    /** @type {Buffer[]} */
+    this._parts = [];
+    /** @type {ZipEntry[]} */
+    this._entries = [];
+    this._offset = 0;
+    this._now = new Date();
+  }
+
+  /**
+   * Add a file entry.
+   * @param {string} name   - path inside zip, e.g. 'dir/file.txt'
+   * @param {Buffer|string} data - content (string will be UTF-8 encoded)
+   */
+  addEntry(name, data) {
+    const dataBuf = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
+    const nameBuf = Buffer.from(name, 'utf8');
+    const crc = crc32(dataBuf);
+    const { dosTime, dosDate } = dosDateTime(this._now);
+
+    const localOffset = this._offset;
+
+    // Local file header (signature 0x04034b50, 30 bytes + name)
+    const lhSize = 30 + nameBuf.length;
+    const lh = Buffer.alloc(lhSize, 0);
+    writeUInt32LE(lh, 0, 0x04034b50); // local file header sig
+    writeUInt16LE(lh, 4, 20);         // version needed: 2.0
+    writeUInt16LE(lh, 6, 0);          // general purpose bit flag
+    writeUInt16LE(lh, 8, 0);          // compression method: STORE
+    writeUInt16LE(lh, 10, dosTime);
+    writeUInt16LE(lh, 12, dosDate);
+    writeUInt32LE(lh, 14, crc);
+    writeUInt32LE(lh, 18, dataBuf.length); // compressed size
+    writeUInt32LE(lh, 22, dataBuf.length); // uncompressed size
+    writeUInt16LE(lh, 26, nameBuf.length);
+    writeUInt16LE(lh, 28, 0);         // extra field length
+    nameBuf.copy(lh, 30);
+
+    this._parts.push(lh);
+    this._parts.push(dataBuf);
+    this._offset += lhSize + dataBuf.length;
+
+    this._entries.push({ name, nameBuf, data: dataBuf, crc, localOffset, dosTime, dosDate });
+  }
+
+  /**
+   * Finalize the ZIP archive and return the complete Buffer.
+   * @returns {Buffer}
+   */
+  finalize() {
+    const cdOffset = this._offset;
+
+    // Central directory headers
+    const cdParts = [];
+    for (const entry of this._entries) {
+      const cdSize = 46 + entry.nameBuf.length;
+      const cd = Buffer.alloc(cdSize, 0);
+      writeUInt32LE(cd, 0, 0x02014b50);  // central dir signature
+      writeUInt16LE(cd, 4, 20);           // version made by
+      writeUInt16LE(cd, 6, 20);           // version needed
+      writeUInt16LE(cd, 8, 0);            // general purpose bit flag
+      writeUInt16LE(cd, 10, 0);           // compression: STORE
+      writeUInt16LE(cd, 12, entry.dosTime);
+      writeUInt16LE(cd, 14, entry.dosDate);
+      writeUInt32LE(cd, 16, entry.crc);
+      writeUInt32LE(cd, 20, entry.data.length); // compressed size
+      writeUInt32LE(cd, 24, entry.data.length); // uncompressed size
+      writeUInt16LE(cd, 28, entry.nameBuf.length);
+      writeUInt16LE(cd, 30, 0);           // extra field length
+      writeUInt16LE(cd, 32, 0);           // file comment length
+      writeUInt16LE(cd, 34, 0);           // disk number start
+      writeUInt16LE(cd, 36, 0);           // internal file attributes
+      writeUInt32LE(cd, 38, 0);           // external file attributes
+      writeUInt32LE(cd, 42, entry.localOffset);
+      entry.nameBuf.copy(cd, 46);
+      cdParts.push(cd);
+    }
+
+    const cdBuf = Buffer.concat(cdParts);
+    const cdSize = cdBuf.length;
+
+    // End of central directory record (22 bytes)
+    const eocd = Buffer.alloc(22, 0);
+    writeUInt32LE(eocd, 0, 0x06054b50);  // EOCD signature
+    writeUInt16LE(eocd, 4, 0);            // disk number
+    writeUInt16LE(eocd, 6, 0);            // disk with central dir start
+    writeUInt16LE(eocd, 8, this._entries.length);  // entries on disk
+    writeUInt16LE(eocd, 10, this._entries.length); // total entries
+    writeUInt32LE(eocd, 12, cdSize);
+    writeUInt32LE(eocd, 16, cdOffset);
+    writeUInt16LE(eocd, 20, 0);           // comment length
+
+    return Buffer.concat([...this._parts, cdBuf, eocd]);
+  }
+}
+
+module.exports = { ZipWriter, crc32 };
diff --git a/src/routes/api.js b/src/routes/api.js
new file mode 100644
index 0000000..976f3cc
--- /dev/null
+++ b/src/routes/api.js
@@ -0,0 +1,654 @@
+'use strict';
+/**
+ * RENTV Advertiser Intelligence — /api/v1 router (spec §29).
+ * Express Router, CommonJS, pg 8, zero external dependencies.
+ * All list endpoints: { rows, nextCursor, total, demo? }
+ * All writes: append audit_logs row. Hand-rolled validation, no zod.
+ */
+
+const express = require('express');
+const { pool, query } = require('../../db');
+const T = require('../../lib/types');
+const scoring = require('../../lib/scoring');
+
+const router = express.Router();
+
+// ── Request ID middleware ──────────────────────────────────────────────────────
+router.use((req, res, next) => {
+  req.requestId = require('crypto').randomUUID();
+  res.set('X-Request-Id', req.requestId);
+  next();
+});
+
+// ── Helper: structured JSON error ────────────────────────────────────────────
+function apiErr(res, code, error, message) {
+  return res.status(code).json({ error, message: message || error });
+}
+
+// ── Helper: safe integer ─────────────────────────────────────────────────────
+function safeInt(v, def, min, max) {
+  const n = parseInt(v, 10);
+  if (isNaN(n)) return def;
+  if (min != null && n < min) return min;
+  if (max != null && n > max) return max;
+  return n;
+}
+
+// ── Helper: whitelist sort key ────────────────────────────────────────────────
+function safeSort(v, allowed, def) {
+  return allowed.includes(v) ? v : def;
+}
+
+// ── Helper: UUID or null ──────────────────────────────────────────────────────
+function isUuid(s) {
+  return typeof s === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
+}
+
+// ── Helper: check if a table exists ─────────────────────────────────────────
+async function tableExists(tableName) {
+  try {
+    const r = await query(
+      `SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=$1`,
+      [tableName]
+    );
+    return r.rows.length > 0;
+  } catch (_) { return false; }
+}
+
+// ── Helper: demo-safe query ───────────────────────────────────────────────────
+// Returns { rows, demo } — if table missing/empty returns {rows:[], demo:true}
+async function safeQuery(sql, params, demoCheck) {
+  try {
+    const r = await query(sql, params || []);
+    if (demoCheck && r.rows.length === 0) return { rows: [], demo: true };
+    return { rows: r.rows, demo: false };
+  } catch (e) {
+    if (e.code === '42P01') return { rows: [], demo: true }; // undefined_table
+    throw e;
+  }
+}
+
+// ── Helper: write audit log ───────────────────────────────────────────────────
+async function auditLog(action, entityTable, entityId, detail, actor) {
+  try {
+    await query(
+      `INSERT INTO audit_logs (action, entity_table, entity_id, actor, detail)
+       VALUES ($1, $2, $3, $4, $5)`,
+      [action, entityTable || null, entityId || null, actor || 'api', JSON.stringify(detail || {})]
+    );
+  } catch (_) { /* non-fatal */ }
+}
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /advertisers
+// Filters: market=CA|AZ|ALL, verifiedOnly=1, category, q, cursor, limit, sort
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/advertisers', async (req, res) => {
+  try {
+    const limit  = safeInt(req.query.limit, 50, 1, 200);
+    const cursor = req.query.cursor || null;
+    const market = ['CA', 'AZ', 'ALL'].includes(req.query.market) ? req.query.market : 'ALL';
+    const verifiedOnly = req.query.verifiedOnly === '1';
+    const category = req.query.category ? String(req.query.category).slice(0, 120) : null;
+    const q = req.query.q ? String(req.query.q).slice(0, 200) : null;
+    const ALLOWED_SORTS = ['display_name', 'last_seen_at', 'created_at', 'score'];
+    const sort = safeSort(req.query.sort, ALLOWED_SORTS, 'last_seen_at');
+
+    const conditions = [];
+    const params = [];
+
+    function addParam(v) { params.push(v); return '$' + params.length; }
+
+    if (market !== 'ALL') {
+      conditions.push(`o.headquarters_state = ${addParam(market)}`);
+    }
+    if (verifiedOnly) {
+      conditions.push(`(
+        SELECT COUNT(*) FROM ad_sightings s WHERE s.organization_id = o.id
+        AND s.relationship_status = ANY(${addParam(T.VERIFIED_STATUSES)})
+      ) > 0`);
+    }
+    if (category) {
+      conditions.push(`o.advertiser_categories @> ${addParam(JSON.stringify([category]))}::jsonb`);
+    }
+    if (q) {
+      conditions.push(`(
+        o.normalized_name ILIKE ${addParam('%' + q.replace(/[%_]/g, '\\$&') + '%')}
+        OR o.display_name ILIKE ${addParam('%' + q.replace(/[%_]/g, '\\$&') + '%')}
+        OR o.domain ILIKE ${addParam('%' + q.replace(/[%_]/g, '\\$&') + '%')}
+      )`);
+      // last param added twice — fix the indices
+      const last = params.length;
+      params[last - 1] = '%' + q.replace(/[%_]/g, '\\$&') + '%';
+      params[last - 2] = '%' + q.replace(/[%_]/g, '\\$&') + '%';
+    }
+    if (cursor) {
+      conditions.push(`o.created_at < ${addParam(cursor)}`);
+    }
+
+    const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
+    const sortSql = sort === 'score'
+      ? 'opp.score DESC NULLS LAST'
+      : sort === 'display_name'
+      ? 'o.display_name ASC'
+      : 'o.last_seen_at DESC NULLS LAST';
+
+    let result;
+    try {
+      result = await query(`
+        SELECT
+          o.id, o.display_name, o.legal_name, o.domain, o.headquarters_state,
+          o.headquarters_city, o.advertiser_categories, o.active_status,
+          o.logo_asset_id, o.first_seen_at, o.last_seen_at, o.created_at,
+          (SELECT s.relationship_status
+           FROM ad_sightings s
+           WHERE s.organization_id = o.id
+           ORDER BY array_position(
+             ARRAY['VERIFIED_ADVERTISER','VERIFIED_CONFERENCE_SPONSOR','VERIFIED_EXHIBITOR',
+                   'VERIFIED_MEDIA_PARTNER','VERIFIED_CONTENT_PARTNER','PAST_ADVERTISER',
+                   'LIKELY_PROSPECT','SPEAKER_OR_PANELIST_ONLY','RESEARCH_NEEDED','DISQUALIFIED'],
+             s.relationship_status)
+           LIMIT 1
+          ) AS top_status,
+          (SELECT COUNT(*) FROM ad_sightings s WHERE s.organization_id = o.id
+           AND s.verification_status = 'VERIFIED') AS verified_sightings,
+          (SELECT s.observed_at FROM ad_sightings s WHERE s.organization_id = o.id
+           ORDER BY s.observed_at DESC NULLS LAST LIMIT 1) AS last_sighting_at,
+          (SELECT COUNT(*) FROM event_relationships er WHERE er.organization_id = o.id) AS conference_activity,
+          (SELECT opp.score FROM opportunity_scores opp WHERE opp.organization_id = o.id
+           ORDER BY opp.computed_at DESC LIMIT 1) AS score,
+          (SELECT cp.value FROM contact_points cp WHERE cp.organization_id = o.id
+           AND cp.type = 'BUSINESS_PHONE' AND cp.do_not_contact = false LIMIT 1) AS best_phone,
+          (SELECT cp.value FROM contact_points cp WHERE cp.organization_id = o.id
+           AND cp.type = 'BUSINESS_EMAIL' AND cp.do_not_contact = false LIMIT 1) AS best_email,
+          (SELECT cp.value FROM contact_points cp WHERE cp.organization_id = o.id
+           AND cp.type = 'WEBSITE' LIMIT 1) AS website,
+          (SELECT cp.value FROM contact_points cp WHERE cp.organization_id = o.id
+           AND cp.type = 'LINKEDIN' LIMIT 1) AS linkedin_url,
+          (SELECT p.full_name FROM people p WHERE p.organization_id = o.id
+           ORDER BY array_position(
+             ARRAY['CHIEF_MARKETING_OFFICER','VP_MARKETING','MARKETING_DIRECTOR',
+                   'COMMUNICATIONS_PR_DIRECTOR','EVENTS_PARTNERSHIPS_SPONSORSHIPS_DIRECTOR',
+                   'BUSINESS_DEVELOPMENT_DIRECTOR','REGIONAL_PRESIDENT_MARKET_LEADER',
+                   'MANAGING_DIRECTOR_PRINCIPAL','PUBLIC_MEDIA_CONTACT','GENERAL_COMPANY_CONTACT'],
+             p.role_category) ASC NULLS LAST LIMIT 1) AS best_contact_name,
+          (SELECT s.source_page_url FROM ad_sightings s WHERE s.organization_id = o.id
+           ORDER BY s.observed_at DESC NULLS LAST LIMIT 1) AS latest_source_url
+        FROM organizations o
+        LEFT JOIN opportunity_scores opp ON opp.organization_id = o.id
+          AND opp.computed_at = (SELECT MAX(opp2.computed_at) FROM opportunity_scores opp2 WHERE opp2.organization_id = o.id)
+        ${where}
+        ORDER BY ${sortSql}, o.id ASC
+        LIMIT ${addParam(limit + 1)}
+      `, params);
+    } catch (e) {
+      if (e.code === '42P01') return res.json({ rows: [], nextCursor: null, total: 0, demo: true });
+      throw e;
+    }
+
+    const rows = result.rows;
+    const hasMore = rows.length > limit;
+    if (hasMore) rows.pop();
+    const nextCursor = hasMore ? rows[rows.length - 1].created_at : null;
+
+    // Total count (estimated) — fast path
+    let total = 0;
+    try {
+      const ct = await query(`SELECT COUNT(*) FROM organizations o ${where}`, params.slice(0, params.length - 1));
+      total = parseInt(ct.rows[0].count, 10);
+    } catch (_) {}
+
+    const demo = total === 0 && rows.length === 0;
+    res.json({ rows, nextCursor, total, demo: demo || undefined });
+  } catch (e) {
+    console.error('[api] GET /advertisers', e.message);
+    apiErr(res, 500, 'query_error', e.message);
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /advertisers/:id
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/advertisers/:id', async (req, res) => {
+  const { id } = req.params;
+  if (!isUuid(id)) return apiErr(res, 400, 'invalid_id', 'id must be a UUID');
+  try {
+    const orgRes = await query(`SELECT * FROM organizations WHERE id = $1`, [id]);
+    if (!orgRes.rows.length) return apiErr(res, 404, 'not_found', 'Organization not found');
+    const org = orgRes.rows[0];
+
+    const [sightings, events, contacts, latestScore, evidence] = await Promise.all([
+      query(`SELECT s.*, p.name AS publication_name FROM ad_sightings s
+             LEFT JOIN publications p ON p.id = s.publication_id
+             WHERE s.organization_id = $1 ORDER BY s.observed_at DESC NULLS LAST LIMIT 50`, [id]),
+      query(`SELECT er.*, e.name AS event_name, e.start_date, e.city, e.state, e.official_url
+             FROM event_relationships er JOIN events e ON e.id = er.event_id
+             WHERE er.organization_id = $1 ORDER BY e.start_date DESC NULLS LAST LIMIT 50`, [id]),
+      query(`SELECT cp.*, p.full_name AS person_name, p.public_title AS person_title
+             FROM contact_points cp LEFT JOIN people p ON p.id = cp.person_id
+             WHERE cp.organization_id = $1 AND cp.do_not_contact = false
+             ORDER BY cp.confidence DESC LIMIT 50`, [id]),
+      query(`SELECT * FROM opportunity_scores WHERE organization_id = $1
+             ORDER BY computed_at DESC LIMIT 1`, [id]),
+      query(`SELECT er.* FROM evidence_records er
+             WHERE er.id IN (
+               SELECT evidence_id FROM ad_sightings WHERE organization_id = $1 AND evidence_id IS NOT NULL
+               LIMIT 20
+             )`, [id]),
+    ]);
+
+    const scoreRow = latestScore.rows[0];
+    let scoreExplain = null;
+    if (scoreRow && scoreRow.factors) {
+      try { scoreExplain = scoring.explainScore(scoreRow.factors); } catch (_) {}
+    }
+
+    res.json({
+      ...org,
+      sightings: sightings.rows,
+      events: events.rows,
+      contacts: contacts.rows,
+      score: scoreRow ? { ...scoreRow, explain: scoreExplain } : null,
+      evidence: evidence.rows,
+    });
+  } catch (e) {
+    console.error('[api] GET /advertisers/:id', e.message);
+    apiErr(res, 500, 'query_error', e.message);
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /ads
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/ads', async (req, res) => {
+  const limit  = safeInt(req.query.limit, 50, 1, 200);
+  const cursor = req.query.cursor || null;
+  const params = [];
+  const conds  = [];
+  function p(v) { params.push(v); return '$' + params.length; }
+
+  if (cursor) conds.push(`s.created_at < ${p(cursor)}`);
+  const where = conds.length ? 'WHERE ' + conds.join(' AND ') : '';
+
+  try {
+    const r = await query(`
+      SELECT s.*, o.display_name AS org_name, p.name AS publication_name
+      FROM ad_sightings s
+      LEFT JOIN organizations o ON o.id = s.organization_id
+      LEFT JOIN publications  p ON p.id = s.publication_id
+      ${where}
+      ORDER BY s.observed_at DESC NULLS LAST, s.id ASC
+      LIMIT ${p(limit + 1)}
+    `, params);
+
+    const rows = r.rows;
+    const hasMore = rows.length > limit;
+    if (hasMore) rows.pop();
+    const demo = rows.length === 0;
+    res.json({ rows, nextCursor: hasMore ? rows[rows.length - 1].created_at : null, total: rows.length, demo: demo || undefined });
+  } catch (e) {
+    if (e.code === '42P01') return res.json({ rows: [], nextCursor: null, total: 0, demo: true });
+    apiErr(res, 500, 'query_error', e.message);
+  }
+});
+
+// GET /ads/:id
+router.get('/ads/:id', async (req, res) => {
+  if (!isUuid(req.params.id)) return apiErr(res, 400, 'invalid_id', 'id must be a UUID');
+  try {
+    const r = await query(`
+      SELECT s.*, o.display_name AS org_name, p.name AS publication_name,
+        er.source_url AS evidence_source_url, er.source_title AS evidence_title,
+        er.excerpt AS evidence_excerpt, er.observed_at AS evidence_observed_at
+      FROM ad_sightings s
+      LEFT JOIN organizations o ON o.id = s.organization_id
+      LEFT JOIN publications  p ON p.id = s.publication_id
+      LEFT JOIN evidence_records er ON er.id = s.evidence_id
+      WHERE s.id = $1
+    `, [req.params.id]);
+    if (!r.rows.length) return apiErr(res, 404, 'not_found', 'Ad sighting not found');
+    res.json(r.rows[0]);
+  } catch (e) {
+    if (e.code === '42P01') return res.json({ demo: true, rows: [] });
+    apiErr(res, 500, 'query_error', e.message);
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /events
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/events', async (req, res) => {
+  const limit  = safeInt(req.query.limit, 50, 1, 200);
+  const cursor = req.query.cursor || null;
+  const params = [];
+  function p(v) { params.push(v); return '$' + params.length; }
+  const conds = cursor ? [`e.start_date < ${p(cursor)}`] : [];
+  const where = conds.length ? 'WHERE ' + conds.join(' AND ') : '';
+  try {
+    const r = await query(`
+      SELECT e.*,
+        (SELECT COUNT(*) FROM event_relationships er
+         WHERE er.event_id = e.id AND er.relationship_status LIKE 'VERIFIED_%') AS sponsor_count,
+        (SELECT COUNT(*) FROM event_relationships er
+         WHERE er.event_id = e.id AND er.relationship_status = 'SPEAKER_OR_PANELIST_ONLY') AS panelist_count
+      FROM events e ${where}
+      ORDER BY e.start_date DESC NULLS LAST LIMIT ${p(limit + 1)}
+    `, params);
+    const rows = r.rows; const hasMore = rows.length > limit; if (hasMore) rows.pop();
+    res.json({ rows, nextCursor: hasMore ? rows[rows.length - 1].start_date : null, total: rows.length, demo: rows.length === 0 || undefined });
+  } catch (e) {
+    if (e.code === '42P01') return res.json({ rows: [], nextCursor: null, total: 0, demo: true });
+    apiErr(res, 500, 'query_error', e.message);
+  }
+});
+
+// GET /events/:id
+router.get('/events/:id', async (req, res) => {
+  if (!isUuid(req.params.id)) return apiErr(res, 400, 'invalid_id', 'id must be a UUID');
+  try {
+    const [evRes, rels] = await Promise.all([
+      query(`SELECT * FROM events WHERE id = $1`, [req.params.id]),
+      query(`SELECT er.*, o.display_name AS org_name, o.domain, o.advertiser_categories
+             FROM event_relationships er
+             JOIN organizations o ON o.id = er.organization_id
+             WHERE er.event_id = $1
+             ORDER BY array_position(
+               ARRAY['VERIFIED_CONFERENCE_SPONSOR','VERIFIED_EXHIBITOR','VERIFIED_MEDIA_PARTNER',
+                     'VERIFIED_CONTENT_PARTNER','SPEAKER_OR_PANELIST_ONLY','LIKELY_PROSPECT','RESEARCH_NEEDED'],
+               er.relationship_status), o.display_name`, [req.params.id]),
+    ]);
+    if (!evRes.rows.length) return apiErr(res, 404, 'not_found', 'Event not found');
+    const ev = evRes.rows[0];
+    const sponsors   = rels.rows.filter(r => r.relationship_status !== 'SPEAKER_OR_PANELIST_ONLY');
+    const panelists  = rels.rows.filter(r => r.relationship_status === 'SPEAKER_OR_PANELIST_ONLY');
+    res.json({ ...ev, sponsors, panelists, relationships: rels.rows });
+  } catch (e) {
+    if (e.code === '42P01') return res.json({ demo: true });
+    apiErr(res, 500, 'query_error', e.message);
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /contacts
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/contacts', async (req, res) => {
+  const limit  = safeInt(req.query.limit, 100, 1, 500);
+  const cursor = req.query.cursor || null;
+  const q      = req.query.q ? String(req.query.q).slice(0, 200) : null;
+  const params = []; const conds = [];
+  function p(v) { params.push(v); return '$' + params.length; }
+  if (q) {
+    const like = '%' + q.replace(/[%_]/g, '\\$&') + '%';
+    conds.push(`(pe.full_name ILIKE ${p(like)} OR o.display_name ILIKE ${p(like)} OR cp.value ILIKE ${p(like)})`);
+  }
+  if (cursor) conds.push(`cp.created_at < ${p(cursor)}`);
+  const where = conds.length ? 'WHERE ' + conds.join(' AND ') : '';
+  try {
+    const r = await query(`
+      SELECT cp.*, pe.full_name, pe.public_title, pe.linkedin_url,
+        o.display_name AS org_name, o.id AS organization_id
+      FROM contact_points cp
+      LEFT JOIN people pe ON pe.id = cp.person_id
+      LEFT JOIN organizations o ON o.id = cp.organization_id
+      ${where}
+      AND cp.do_not_contact = false
+      ORDER BY cp.confidence DESC, cp.created_at DESC
+      LIMIT ${p(limit + 1)}
+    `, params);
+    const rows = r.rows; const hasMore = rows.length > limit; if (hasMore) rows.pop();
+    res.json({ rows, nextCursor: hasMore ? rows[rows.length - 1].created_at : null, total: rows.length, demo: rows.length === 0 || undefined });
+  } catch (e) {
+    if (e.code === '42P01') return res.json({ rows: [], nextCursor: null, total: 0, demo: true });
+    apiErr(res, 500, 'query_error', e.message);
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /prospects — orgs ranked by latest opportunity score, non-verified
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/prospects', async (req, res) => {
+  const limit = safeInt(req.query.limit, 50, 1, 200);
+  const market = ['CA', 'AZ', 'ALL'].includes(req.query.market) ? req.query.market : 'ALL';
+  const params = []; const conds = [];
+  function p(v) { params.push(v); return '$' + params.length; }
+  // Prospects = no VERIFIED_* sightings
+  conds.push(`NOT EXISTS (
+    SELECT 1 FROM ad_sightings s WHERE s.organization_id = o.id
+    AND s.relationship_status = ANY(${p(T.VERIFIED_STATUSES)})
+  )`);
+  if (market !== 'ALL') conds.push(`o.headquarters_state = ${p(market)}`);
+  const where = 'WHERE ' + conds.join(' AND ');
+  try {
+    const r = await query(`
+      SELECT o.id, o.display_name, o.domain, o.headquarters_state, o.headquarters_city,
+        o.advertiser_categories, o.last_seen_at, o.created_at,
+        opp.score, opp.computed_at AS score_computed_at
+      FROM organizations o
+      LEFT JOIN opportunity_scores opp ON opp.organization_id = o.id
+        AND opp.computed_at = (SELECT MAX(o2.computed_at) FROM opportunity_scores o2 WHERE o2.organization_id = o.id)
+      ${where}
+      ORDER BY opp.score DESC NULLS LAST, o.last_seen_at DESC NULLS LAST
+      LIMIT ${p(limit)}
+    `, params);
+    res.json({ rows: r.rows, total: r.rows.length, demo: r.rows.length === 0 || undefined });
+  } catch (e) {
+    if (e.code === '42P01') return res.json({ rows: [], total: 0, demo: true });
+    apiErr(res, 500, 'query_error', e.message);
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /analytics/summary
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/analytics/summary', async (req, res) => {
+  try {
+    const r = await query(`
+      SELECT
+        (SELECT COUNT(*) FROM organizations) AS total_orgs,
+        (SELECT COUNT(*) FROM ad_sightings WHERE relationship_status = 'VERIFIED_ADVERTISER') AS verified_advertisers,
+        (SELECT COUNT(*) FROM ad_sightings WHERE relationship_status = 'VERIFIED_CONFERENCE_SPONSOR') AS verified_sponsors,
+        (SELECT COUNT(*) FROM organizations WHERE headquarters_state = 'CA') AS california_orgs,
+        (SELECT COUNT(*) FROM organizations WHERE headquarters_state = 'AZ') AS arizona_orgs,
+        (SELECT COUNT(*) FROM ad_sightings WHERE observed_at >= NOW() - INTERVAL '30 days') AS sightings_30d,
+        (SELECT COUNT(*) FROM events) AS total_events,
+        (SELECT COUNT(*) FROM contact_points WHERE do_not_contact = false) AS total_contacts
+    `);
+    const summary = r.rows[0] || {};
+    const demo = Object.values(summary).every(v => v === '0' || v === 0);
+    res.json({ ...summary, demo: demo || undefined });
+  } catch (e) {
+    if (e.code === '42P01') return res.json({ demo: true });
+    apiErr(res, 500, 'query_error', e.message);
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /analytics/ga4
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/analytics/ga4', async (req, res) => {
+  const days = safeInt(req.query.days, 30, 7, 365);
+  try {
+    const r = await query(`
+      SELECT * FROM ga4_daily_metrics
+      WHERE metric_date >= CURRENT_DATE - $1::int
+      ORDER BY metric_date DESC
+    `, [days]);
+    const demo = r.rows.length === 0 || r.rows[0].is_demo;
+    res.json({ rows: r.rows, total: r.rows.length, demo: demo || undefined });
+  } catch (e) {
+    if (e.code === '42P01') return res.json({ rows: [], total: 0, demo: true });
+    apiErr(res, 500, 'query_error', e.message);
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /analytics/gsc
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/analytics/gsc', async (req, res) => {
+  const limit = safeInt(req.query.limit, 50, 1, 500);
+  const days  = safeInt(req.query.days, 28, 7, 365);
+  try {
+    const r = await query(`
+      SELECT * FROM gsc_query_metrics
+      WHERE metric_date >= CURRENT_DATE - $1::int
+      ORDER BY impressions DESC NULLS LAST
+      LIMIT $2
+    `, [days, limit]);
+    const demo = r.rows.length === 0 || r.rows[0].is_demo;
+    res.json({ rows: r.rows, total: r.rows.length, demo: demo || undefined });
+  } catch (e) {
+    if (e.code === '42P01') return res.json({ rows: [], total: 0, demo: true });
+    apiErr(res, 500, 'query_error', e.message);
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /sources
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/sources', async (req, res) => {
+  try {
+    const r = await query(`
+      SELECT sp.*,
+        (SELECT COUNT(*) FROM sources s WHERE s.source_policy_id = sp.id) AS item_count,
+        (SELECT MAX(shc.checked_at) FROM source_health_checks shc WHERE shc.source_key = sp.source_key) AS last_checked
+      FROM source_policies sp
+      ORDER BY sp.display_name ASC
+    `);
+    const demo = r.rows.length === 0;
+    res.json({ rows: r.rows, total: r.rows.length, demo: demo || undefined });
+  } catch (e) {
+    if (e.code === '42P01') return res.json({ rows: [], total: 0, demo: true });
+    apiErr(res, 500, 'query_error', e.message);
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// POST /imports — 202 stub (queues import job)
+// ═══════════════════════════════════════════════════════════════════════════════
+router.post('/imports', async (req, res) => {
+  const kind = req.body && req.body.kind ? String(req.body.kind).slice(0, 80) : 'MANUAL_UPLOAD';
+  try {
+    await auditLog('import_queued', 'imports', null, { kind, body: req.body }, 'api');
+  } catch (_) {}
+  res.status(202).json({ queued: true, kind, message: 'Import job queued — upload your file via the /imports UI.' });
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// POST /research/jobs — 202 stub
+// ═══════════════════════════════════════════════════════════════════════════════
+router.post('/research/jobs', async (req, res) => {
+  const jobType = req.body && req.body.type ? String(req.body.type).slice(0, 80) : 'GENERAL';
+  const dryRun  = req.body && req.body.dryRun ? true : false;
+  try {
+    await query(
+      `INSERT INTO ingestion_runs (source_key, dry_run, status, stats)
+       VALUES ($1, $2, 'PENDING', '{}')`,
+      [jobType, dryRun]
+    );
+  } catch (_) {}
+  res.status(202).json({ queued: true, jobType, dryRun, message: 'Research job queued — monitor at /admin/jobs.' });
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// POST /review/:id/verify
+// ═══════════════════════════════════════════════════════════════════════════════
+router.post('/review/:id/verify', async (req, res) => {
+  const { id } = req.params;
+  if (!isUuid(id)) return apiErr(res, 400, 'invalid_id', 'id must be a UUID');
+  const idempotencyKey = req.headers['idempotency-key'] || null;
+  try {
+    const r = await query(
+      `UPDATE ad_sightings SET verification_status = 'VERIFIED', verified_by_user_id = NULL
+       WHERE id = $1 RETURNING *`,
+      [id]
+    );
+    if (!r.rows.length) return apiErr(res, 404, 'not_found', 'Ad sighting not found');
+    await auditLog('verify', 'ad_sightings', id, { idempotencyKey }, 'api');
+    res.json({ ok: true, id, status: 'VERIFIED' });
+  } catch (e) {
+    if (e.code === '42P01') return apiErr(res, 404, 'not_found', 'Ad sightings table does not exist yet');
+    apiErr(res, 500, 'query_error', e.message);
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// POST /review/:id/reject
+// ═══════════════════════════════════════════════════════════════════════════════
+router.post('/review/:id/reject', async (req, res) => {
+  const { id } = req.params;
+  if (!isUuid(id)) return apiErr(res, 400, 'invalid_id', 'id must be a UUID');
+  const reason = req.body && req.body.reason ? String(req.body.reason).slice(0, 500) : '';
+  try {
+    const r = await query(
+      `UPDATE ad_sightings SET verification_status = 'REJECTED' WHERE id = $1 RETURNING *`,
+      [id]
+    );
+    if (!r.rows.length) return apiErr(res, 404, 'not_found', 'Ad sighting not found');
+    await auditLog('reject', 'ad_sightings', id, { reason }, 'api');
+    res.json({ ok: true, id, status: 'REJECTED', reason });
+  } catch (e) {
+    if (e.code === '42P01') return apiErr(res, 404, 'not_found', 'Ad sightings table does not exist yet');
+    apiErr(res, 500, 'query_error', e.message);
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// POST /exports
+// ═══════════════════════════════════════════════════════════════════════════════
+router.post('/exports', async (req, res) => {
+  const kind = (req.body && req.body.kind) ? String(req.body.kind).slice(0, 80) : 'DOWNLOAD_EVERYTHING';
+
+  // If the export builder exists, delegate to it
+  let buildExport;
+  try { buildExport = require('../export/build'); } catch (_) { buildExport = null; }
+
+  if (buildExport) {
+    try {
+      const result = await buildExport({ kind });
+      return res.status(202).json({ queued: true, ...result });
+    } catch (e) {
+      return apiErr(res, 500, 'export_error', e.message);
+    }
+  }
+
+  // Stub: create an export record and return it
+  try {
+    const r = await query(
+      `INSERT INTO exports (kind, status, row_counts) VALUES ($1, 'PENDING', '{}') RETURNING id, created_at`,
+      [kind]
+    );
+    const exportRec = r.rows[0];
+    await auditLog('export_queued', 'exports', exportRec.id, { kind }, 'api');
+    res.status(202).json({ queued: true, id: exportRec.id, kind, status: 'PENDING', created_at: exportRec.created_at });
+  } catch (e) {
+    // Table might not exist yet
+    res.status(202).json({ queued: true, kind, message: 'Export queued (db not migrated yet)' });
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /exports/:id
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/exports/:id', async (req, res) => {
+  const { id } = req.params;
+  if (!isUuid(id)) return apiErr(res, 400, 'invalid_id', 'id must be a UUID');
+  try {
+    const r = await query(`SELECT * FROM exports WHERE id = $1`, [id]);
+    if (!r.rows.length) return apiErr(res, 404, 'not_found', 'Export not found');
+    const exp = r.rows[0];
+    res.json({
+      ...exp,
+      download_url: exp.status === 'DONE' && exp.object_key ? '/assets/' + exp.object_key : null,
+    });
+  } catch (e) {
+    if (e.code === '42P01') return res.json({ status: 'PENDING', demo: true });
+    apiErr(res, 500, 'query_error', e.message);
+  }
+});
+
+// ── 404 fallback within /api/v1 ───────────────────────────────────────────────
+router.use((req, res) => {
+  apiErr(res, 404, 'not_found', `No route for ${req.method} ${req.path}`);
+});
+
+module.exports = router;
diff --git a/src/routes/pages.js b/src/routes/pages.js
new file mode 100644
index 0000000..2294a96
--- /dev/null
+++ b/src/routes/pages.js
@@ -0,0 +1,1553 @@
+'use strict';
+/**
+ * RENTV Advertiser Intelligence — page router (spec §24, §25, §26, §27).
+ * Server-rendered HTML strings. Direct DB reads for first paint.
+ * Every page degrades gracefully when tables are empty (DEMO DATA badge).
+ */
+
+const express = require('express');
+const { query } = require('../../db');
+const T = require('../../lib/types');
+const scoring = require('../../lib/scoring');
+
+const router = express.Router();
+
+// ── Formatting helpers ────────────────────────────────────────────────────────
+const esc = (s) => (s == null ? '' : String(s))
+  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+
+const fmtDate = (s) => {
+  if (!s) return '—';
+  try {
+    const d = new Date(s);
+    if (isNaN(d)) return String(s);
+    return d.toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+  } catch (_) { return String(s); }
+};
+
+const fmtDateOnly = (s) => {
+  if (!s) return '—';
+  try { return new Date(s).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); }
+  catch (_) { return String(s); }
+};
+
+const STATUS_BADGE_CLASS = {
+  VERIFIED_ADVERTISER: 'badge-verified-advertiser',
+  VERIFIED_CONFERENCE_SPONSOR: 'badge-verified-sponsor',
+  VERIFIED_EXHIBITOR: 'badge-verified-exhibitor',
+  VERIFIED_MEDIA_PARTNER: 'badge-verified-media',
+  VERIFIED_CONTENT_PARTNER: 'badge-content-partner',
+  SPEAKER_OR_PANELIST_ONLY: 'badge-panelist',
+  PAST_ADVERTISER: 'badge-past-advertiser',
+  LIKELY_PROSPECT: 'badge-likely-prospect',
+  RESEARCH_NEEDED: 'badge-research-needed',
+  DISQUALIFIED: 'badge-disqualified',
+};
+
+function statusBadge(status) {
+  const cls = STATUS_BADGE_CLASS[status] || 'badge-research-needed';
+  const lbl = T.STATUS_LABELS[status] || status || '—';
+  return `<span class="badge ${cls}">${esc(lbl)}</span>`;
+}
+
+function scoreBadge(score) {
+  if (score == null) return '<span class="miss">—</span>';
+  const n = Number(score);
+  const cls = n >= 70 ? 'score-high' : n >= 40 ? 'score-mid' : 'score-low';
+  return `<span class="score-badge ${cls}">${n}</span>`;
+}
+
+function whenChip(ts) {
+  if (!ts) return '';
+  return `<span class="when-chip" title="${esc(new Date(ts).toISOString())}">Created ${esc(fmtDate(ts))}</span>`;
+}
+
+// ── Safe DB query (returns [] on missing table) ──────────────────────────────
+async function safeQ(sql, params) {
+  try { return (await query(sql, params || [])).rows; }
+  catch (e) { if (e.code === '42P01') return []; throw e; }
+}
+
+// ── Shared HTML layout ────────────────────────────────────────────────────────
+function layout(title, bodyHtml, opts) {
+  opts = opts || {};
+  const market = opts.market || 'ALL';
+  const verified = opts.verified ? '1' : '0';
+  return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>${esc(title)} — RENTV Advertiser Intelligence</title>
+<link rel="stylesheet" href="/css/app.css">
+</head>
+<body>
+
+<header class="site-header" role="banner">
+  <a class="brand" href="/" aria-label="RENTV Advertiser Intelligence home">
+    <span>RENTV</span> Advertiser Intel
+  </a>
+
+  <input
+    id="global-search"
+    class="header-search"
+    type="search"
+    placeholder="Search advertisers, contacts, conferences…"
+    aria-label="Search all advertisers and contacts"
+    value="${esc(opts.q || '')}">
+
+  <div class="switch-group" role="group" aria-label="Market filter">
+    <div class="market-switch">
+      <button data-market="ALL" class="${market === 'ALL' ? 'active' : ''}" aria-pressed="${market === 'ALL'}">All</button>
+      <button data-market="CA"  class="${market === 'CA'  ? 'active' : ''}" aria-pressed="${market === 'CA'}">California</button>
+      <button data-market="AZ"  class="${market === 'AZ'  ? 'active' : ''}" aria-pressed="${market === 'AZ'}">Arizona</button>
+    </div>
+    <label class="verified-switch ${verified === '1' ? 'active' : ''}" aria-label="Show verified only">
+      <input type="checkbox" ${verified === '1' ? 'checked' : ''} aria-checked="${verified === '1'}">
+      Verified Only
+    </label>
+  </div>
+
+  <div class="header-spacer"></div>
+
+  <button id="download-everything-btn" class="dl-btn" aria-label="Download everything as ZIP">
+    Download Everything
+  </button>
+  <span id="export-msg" style="display:none;font-size:14px;color:var(--warn);margin-left:8px"></span>
+</header>
+
+<nav aria-label="Main navigation" style="background:var(--surface);border-bottom:1px solid var(--border);padding:0 24px;display:flex;gap:0;overflow-x:auto">
+  ${[
+    ['/', 'Dashboard'],
+    ['/advertisers', 'Advertisers'],
+    ['/ads', 'Ads Gallery'],
+    ['/conferences', 'Conferences'],
+    ['/contacts', 'Contacts'],
+    ['/prospects', 'Prospects'],
+    ['/analytics', 'Analytics'],
+    ['/search-intelligence', 'Search Intelligence'],
+    ['/media-kit', 'Media Kit'],
+    ['/sources', 'Sources'],
+    ['/imports', 'Imports'],
+    ['/review', 'Review'],
+    ['/exports', 'Exports'],
+    ['/settings', 'Settings'],
+  ].map(([href, label]) => {
+    const active = opts.activeNav === href;
+    return `<a href="${esc(href)}" style="display:inline-flex;align-items:center;padding:10px 14px;font-size:15px;font-weight:${active ? '700' : '500'};color:${active ? 'var(--accent)' : 'var(--ink-muted)'};text-decoration:none;border-bottom:3px solid ${active ? 'var(--accent)' : 'transparent'};white-space:nowrap;min-height:44px" ${active ? 'aria-current="page"' : ''}>${esc(label)}</a>`;
+  }).join('')}
+</nav>
+
+<main id="main-content" role="main">
+${bodyHtml}
+</main>
+
+<!-- Score drawer -->
+<div id="score-drawer-overlay" class="drawer-overlay" role="dialog" aria-modal="true" aria-label="Show Me Why score drawer">
+  <div class="score-drawer" style="position:relative">
+    <button onclick="closeScoreDrawer()" class="btn btn-ghost" style="position:absolute;top:16px;right:16px" aria-label="Close score drawer">Close ✕</button>
+    <div id="score-drawer-body"></div>
+  </div>
+</div>
+
+<script src="/js/table.js"></script>
+<script src="/js/app.js"></script>
+</body>
+</html>`;
+}
+
+// ── Demo banner helper ────────────────────────────────────────────────────────
+const demoBanner = `<div class="demo-banner" role="status" aria-live="polite">
+  DEMO DATA — No live data imported yet. Run <code>npm run db:migrate && npm run db:seed</code> then connect GA4/GSC via <a href="/settings">Settings</a>.
+</div>`;
+
+// ── Page helpers ─────────────────────────────────────────────────────────────
+function pageShell(leftRailHtml, contentHtml) {
+  return `<div class="page-shell">
+  <aside class="left-rail" aria-label="Filters and column controls">${leftRailHtml}</aside>
+  <div class="page-content">${contentHtml}</div>
+</div>`;
+}
+
+function standardRail(extraHtml) {
+  return `
+  <div class="rail-section">
+    <h4>Column controls</h4>
+    <div class="field-toggles" id="field-toggles"></div>
+    <div style="margin-top:8px">
+      <button class="mini-btn" id="cols-all">All</button>
+      <button class="mini-btn" id="cols-none">None</button>
+      <button class="mini-btn" id="cols-reset" title="Reset to defaults">&#8635; Reset</button>
+    </div>
+    <p style="font-size:13px;color:var(--ink-faint);margin-top:8px;line-height:1.4">
+      Drag column header to reorder &middot; click to sort &middot; toggle above to show/hide.
+    </p>
+  </div>
+  ${extraHtml || ''}`;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET / — Dashboard (§26)
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/', async (req, res) => {
+  try {
+    const [summary, recentOrgs, upcomingEvents, audiences] = await Promise.all([
+      safeQ(`SELECT
+        (SELECT COUNT(*) FROM organizations) AS total_orgs,
+        (SELECT COUNT(*) FROM ad_sightings WHERE relationship_status = 'VERIFIED_ADVERTISER') AS verified_advertisers,
+        (SELECT COUNT(*) FROM ad_sightings WHERE relationship_status = 'VERIFIED_CONFERENCE_SPONSOR') AS verified_sponsors,
+        (SELECT COUNT(*) FROM organizations WHERE headquarters_state = 'CA') AS california_orgs,
+        (SELECT COUNT(*) FROM organizations WHERE headquarters_state = 'AZ') AS arizona_orgs,
+        (SELECT COUNT(*) FROM ad_sightings WHERE observed_at >= NOW() - INTERVAL '30 days') AS sightings_30d,
+        (SELECT COUNT(*) FROM ad_sightings WHERE observed_at >= NOW() - INTERVAL '7 days') AS sightings_7d,
+        (SELECT COUNT(*) FROM events) AS total_events,
+        (SELECT COUNT(*) FROM contact_points WHERE do_not_contact = false) AS total_contacts`),
+      safeQ(`SELECT o.id, o.display_name, o.last_seen_at, o.headquarters_state, o.created_at
+             FROM organizations o ORDER BY o.created_at DESC LIMIT 5`),
+      safeQ(`SELECT * FROM events WHERE start_date >= CURRENT_DATE ORDER BY start_date ASC LIMIT 5`),
+      safeQ(`SELECT * FROM rentv_audience_snapshots ORDER BY observed_at DESC LIMIT 4`),
+    ]);
+
+    const s = summary[0] || {};
+    const isDemo = !s.total_orgs || s.total_orgs === '0';
+
+    const statCards = [
+      { label: 'Verified Advertisers', value: s.verified_advertisers || 0, href: '/advertisers?verifiedOnly=1' },
+      { label: 'Verified Sponsors', value: s.verified_sponsors || 0, href: '/advertisers?verifiedOnly=1' },
+      { label: 'California Companies', value: s.california_orgs || 0, href: '/advertisers?market=CA' },
+      { label: 'Arizona Companies', value: s.arizona_orgs || 0, href: '/advertisers?market=AZ' },
+      { label: 'New Sightings (7d)', value: s.sightings_7d || 0, href: '/ads' },
+      { label: 'New Sightings (30d)', value: s.sightings_30d || 0, href: '/ads' },
+      { label: 'Total Contacts', value: s.total_contacts || 0, href: '/contacts' },
+      { label: 'Total Events', value: s.total_events || 0, href: '/conferences' },
+    ];
+
+    const statsHtml = statCards.map(c => `
+      <div class="stat-pill">
+        <span class="sp-label">${esc(c.label)}</span>
+        <span class="sp-value"><a href="${esc(c.href)}">${Number(c.value).toLocaleString()}</a></span>
+      </div>`).join('');
+
+    const recentHtml = recentOrgs.length
+      ? recentOrgs.map(o => `<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);font-size:15px">
+          <a href="/advertisers/${esc(o.id)}">${esc(o.display_name)}</a>
+          <span class="miss" style="font-size:13px">${esc(o.headquarters_state || '')} &middot; ${fmtDateOnly(o.last_seen_at)}</span>
+        </div>`).join('')
+      : '<p class="miss">No organizations yet.</p>';
+
+    const eventsHtml = upcomingEvents.length
+      ? upcomingEvents.map(e => `<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);font-size:15px">
+          <a href="/conferences/${esc(e.id)}">${esc(e.name)}</a>
+          <span class="miss" style="font-size:13px">${fmtDateOnly(e.start_date)} &middot; ${esc(e.city || '')}${e.state ? ', ' + esc(e.state) : ''}</span>
+        </div>`).join('')
+      : '<p class="miss">No upcoming events.</p>';
+
+    const audienceHtml = audiences.length
+      ? audiences.map(a => `<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid var(--border);font-size:15px">
+          <span>${esc(a.metric_label)}</span>
+          <span><strong>${esc(a.value_text || (a.value_numeric ? Number(a.value_numeric).toLocaleString() : '—'))}</strong>
+            <span class="miss" style="font-size:12px">&nbsp;as of ${fmtDateOnly(a.observed_at)}</span></span>
+        </div>`).join('')
+      : '<p class="miss">No audience snapshots. Import from <a href="/imports">Imports</a>.</p>';
+
+    const body = `
+<div class="page-content">
+  <h1 style="margin-bottom:20px">Dashboard</h1>
+  ${isDemo ? demoBanner : ''}
+  <div class="stats-bar">${statsHtml}</div>
+
+  <div class="dash-grid">
+    <div class="dash-card">
+      <h3>Recently added companies</h3>
+      ${recentHtml}
+      <p style="margin-top:10px"><a href="/advertisers" class="btn btn-outline" style="font-size:14px">View all advertisers</a></p>
+    </div>
+    <div class="dash-card">
+      <h3>Upcoming conferences</h3>
+      ${eventsHtml}
+      <p style="margin-top:10px"><a href="/conferences" class="btn btn-outline" style="font-size:14px">View all conferences</a></p>
+    </div>
+    <div class="dash-card">
+      <h3>RENTV audience snapshots</h3>
+      ${audienceHtml}
+      <p style="margin-top:10px"><a href="/media-kit" class="btn btn-outline" style="font-size:14px">View media kit</a></p>
+    </div>
+    <div class="dash-card">
+      <h3>Quick actions</h3>
+      <div style="display:flex;flex-direction:column;gap:10px;margin-top:8px">
+        <a href="/advertisers" class="btn btn-primary">View Advertisers</a>
+        <a href="/prospects" class="btn btn-outline">Top Prospects</a>
+        <a href="/review" class="btn btn-outline">Review Queue</a>
+        <a href="/imports" class="btn btn-outline">Import Data</a>
+      </div>
+    </div>
+  </div>
+</div>`;
+
+    res.type('html').send(layout('Dashboard', body, { activeNav: '/' }));
+  } catch (e) {
+    console.error('[pages] /', e.message);
+    res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">Server error: ${esc(e.message)}</div></div>`));
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /advertisers — main table (§25)
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/advertisers', async (req, res) => {
+  const market      = ['CA', 'AZ', 'ALL'].includes(req.query.market) ? req.query.market : 'ALL';
+  const verifiedOnly = req.query.verifiedOnly === '1';
+  const q           = req.query.q ? String(req.query.q).slice(0, 200) : '';
+  const category    = req.query.category ? String(req.query.category).slice(0, 120) : '';
+
+  try {
+    const params = []; const conds = [];
+    function p(v) { params.push(v); return '$' + params.length; }
+
+    if (market !== 'ALL') conds.push(`o.headquarters_state = ${p(market)}`);
+    if (verifiedOnly) conds.push(`EXISTS (
+      SELECT 1 FROM ad_sightings s WHERE s.organization_id = o.id
+      AND s.relationship_status = ANY(${p(T.VERIFIED_STATUSES)}))`);
+    if (category) conds.push(`o.advertiser_categories @> ${p(JSON.stringify([category]))}::jsonb`);
+    if (q) {
+      const like = '%' + q.replace(/[%_]/g, '\\$&') + '%';
+      conds.push(`(o.display_name ILIKE ${p(like)} OR o.domain ILIKE ${p(like)})`);
+    }
+    const where = conds.length ? 'WHERE ' + conds.join(' AND ') : '';
+
+    const rows = await safeQ(`
+      SELECT
+        o.id, o.display_name, o.domain, o.headquarters_state, o.headquarters_city,
+        o.advertiser_categories, o.logo_asset_id, o.last_seen_at, o.created_at,
+        (SELECT s.relationship_status FROM ad_sightings s WHERE s.organization_id = o.id
+         ORDER BY CASE s.relationship_status
+           WHEN 'VERIFIED_ADVERTISER' THEN 1 WHEN 'VERIFIED_CONFERENCE_SPONSOR' THEN 2
+           WHEN 'VERIFIED_EXHIBITOR' THEN 3 WHEN 'VERIFIED_MEDIA_PARTNER' THEN 4
+           WHEN 'VERIFIED_CONTENT_PARTNER' THEN 5 WHEN 'PAST_ADVERTISER' THEN 6
+           WHEN 'LIKELY_PROSPECT' THEN 7 WHEN 'SPEAKER_OR_PANELIST_ONLY' THEN 8
+           WHEN 'RESEARCH_NEEDED' THEN 9 ELSE 10 END ASC LIMIT 1) AS top_status,
+        (SELECT COUNT(*) FROM ad_sightings s WHERE s.organization_id = o.id
+         AND s.verification_status = 'VERIFIED') AS verified_sightings,
+        (SELECT s.observed_at FROM ad_sightings s WHERE s.organization_id = o.id
+         ORDER BY s.observed_at DESC NULLS LAST LIMIT 1) AS last_sighting_at,
+        (SELECT COUNT(*) FROM event_relationships er WHERE er.organization_id = o.id) AS conf_activity,
+        (SELECT opp.score FROM opportunity_scores opp WHERE opp.organization_id = o.id
+         ORDER BY opp.computed_at DESC LIMIT 1) AS score,
+        (SELECT cp.value FROM contact_points cp WHERE cp.organization_id = o.id
+         AND cp.type = 'BUSINESS_PHONE' AND cp.do_not_contact = false LIMIT 1) AS best_phone,
+        (SELECT cp.value FROM contact_points cp WHERE cp.organization_id = o.id
+         AND cp.type = 'BUSINESS_EMAIL' AND cp.do_not_contact = false LIMIT 1) AS best_email,
+        (SELECT cp.value FROM contact_points cp WHERE cp.organization_id = o.id
+         AND cp.type = 'WEBSITE' LIMIT 1) AS website,
+        (SELECT cp.value FROM contact_points cp WHERE cp.organization_id = o.id
+         AND cp.type = 'LINKEDIN' LIMIT 1) AS linkedin_url,
+        (SELECT p.full_name FROM people p WHERE p.organization_id = o.id LIMIT 1) AS best_contact,
+        (SELECT s.source_page_url FROM ad_sightings s WHERE s.organization_id = o.id
+         ORDER BY s.observed_at DESC NULLS LAST LIMIT 1) AS latest_source
+      FROM organizations o ${where}
+      ORDER BY o.last_seen_at DESC NULLS LAST
+      LIMIT 500
+    `, params);
+
+    const isDemo = rows.length === 0;
+
+    // Category chips for rail
+    const catCounts = {};
+    rows.forEach(r => {
+      try {
+        const cats = Array.isArray(r.advertiser_categories) ? r.advertiser_categories : JSON.parse(r.advertiser_categories || '[]');
+        cats.forEach(c => { catCounts[c] = (catCounts[c] || 0) + 1; });
+      } catch (_) {}
+    });
+    const catChips = Object.entries(catCounts).sort((a, b) => b[1] - a[1]).slice(0, 16)
+      .map(([cat, n]) => `<span class="chip${category === cat ? ' active' : ''}" data-cat="${esc(cat)}">${esc(cat.slice(0, 40))}<span class="ct">${n}</span></span>`).join('');
+
+    const tableRows = rows.map(r => {
+      const thumb = r.logo_asset_id
+        ? `<img class="org-thumb" src="/assets/${esc(r.logo_asset_id)}" alt="${esc(r.display_name)} logo" loading="lazy">`
+        : `<div class="org-thumb-placeholder" aria-hidden="true">&#127970;</div>`;
+      const cats = (() => { try { return JSON.parse(r.advertiser_categories || '[]'); } catch (_) { return []; } })();
+      const srcLink = r.latest_source
+        ? `<a href="${esc(r.latest_source)}" target="_blank" rel="noopener noreferrer" title="${esc(r.latest_source)}">Source ↗</a>`
+        : '<span class="miss">—</span>';
+      return `<tr data-id="${esc(r.id)}" class="adv-row">
+        <td style="width:52px">${thumb}</td>
+        <td><a href="/advertisers/${esc(r.id)}">${esc(r.display_name)}</a></td>
+        <td>${statusBadge(r.top_status)}</td>
+        <td style="max-width:180px;white-space:normal;font-size:13px">${esc(cats.slice(0, 2).join(', ') || '—')}</td>
+        <td>${esc(r.headquarters_state || '—')}</td>
+        <td>${fmtDateOnly(r.last_sighting_at)}</td>
+        <td class="num"><a href="/advertisers/${esc(r.id)}#ads">${Number(r.verified_sightings || 0).toLocaleString()}</a></td>
+        <td class="num"><a href="/advertisers/${esc(r.id)}#conferences">${Number(r.conf_activity || 0).toLocaleString()}</a></td>
+        <td>
+          ${scoreBadge(r.score)}
+          ${r.id ? `<button class="btn btn-ghost" style="font-size:12px;padding:4px 8px;min-height:32px;margin-left:6px" data-smw-org="${esc(r.id)}" data-smw-name="${esc(r.display_name)}">Why?</button>` : ''}
+        </td>
+        <td>${r.best_contact ? esc(r.best_contact) : '<span class="miss">—</span>'}</td>
+        <td>${r.best_phone ? `<a href="tel:${esc(r.best_phone)}">${esc(r.best_phone)}</a>` : '<span class="miss">—</span>'}</td>
+        <td>${r.best_email ? `<a href="mailto:${esc(r.best_email)}">${esc(r.best_email)}</a>` : '<span class="miss">—</span>'}</td>
+        <td>${r.website ? `<a href="${/^https?:/.test(r.website) ? esc(r.website) : 'https://' + esc(r.website)}" target="_blank" rel="noopener noreferrer">${esc(r.domain || r.website)} ↗</a>` : '<span class="miss">—</span>'}</td>
+        <td>${r.linkedin_url ? `<a href="${esc(r.linkedin_url)}" target="_blank" rel="noopener noreferrer">LinkedIn ↗</a>` : '<span class="miss">—</span>'}</td>
+        <td>${srcLink}</td>
+      </tr>`;
+    }).join('');
+
+    const rail = standardRail(`
+      <div class="rail-section">
+        <h4>Category</h4>
+        <div class="chips" id="cat-chips">${catChips}</div>
+      </div>
+      <div class="rail-section">
+        <h4>Status</h4>
+        <div class="chips" id="status-chips">
+          ${T.VERIFIED_STATUSES.concat(['LIKELY_PROSPECT', 'RESEARCH_NEEDED']).map(s =>
+            `<span class="chip" data-status="${esc(s)}">${esc(T.STATUS_LABELS[s] || s)}</span>`
+          ).join('')}
+        </div>
+      </div>`);
+
+    const content = `
+      <div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:16px">
+        <h1>Advertisers</h1>
+        <a href="/imports" class="btn btn-outline" style="font-size:15px">+ Import Data</a>
+      </div>
+      ${isDemo ? demoBanner : ''}
+      <div class="tbl-controls">
+        <input class="tbl-search" id="tbl-search" type="search" placeholder="Search all fields (space = AND)…" value="${esc(q)}" aria-label="Search advertisers">
+        <select class="tbl-sort-sel" id="sort-sel" aria-label="Sort by"></select>
+        <span class="tbl-count" id="row-count">${rows.length.toLocaleString()} records</span>
+        <button class="btn btn-ghost" id="csv-btn" style="font-size:14px">Export CSV</button>
+      </div>
+      <div class="tbl-wrap">
+        <table class="adv-tbl" role="grid" aria-label="Advertisers table">
+          <thead id="main-thead">
+            <tr>
+              <th>Logo</th><th>Company</th><th>Status</th><th>Category</th>
+              <th>Market</th><th>Last Sighting</th><th>#Verified</th><th>Conf. Activity</th>
+              <th>Score</th><th>Best Contact</th><th>Phone</th><th>Email</th>
+              <th>Website</th><th>LinkedIn</th><th>Source</th>
+            </tr>
+          </thead>
+          <tbody id="main-tbody">
+            ${tableRows || '<tr><td colspan="15" class="empty-state"><p>No advertisers match your filters.</p></td></tr>'}
+          </tbody>
+        </table>
+      </div>
+      <script>
+      (function(){
+        // Client-side search
+        var q='', statusF=new Set(), catF=new Set();
+        var allRows=Array.from(document.querySelectorAll('#main-tbody tr.adv-row'));
+        function filter(){
+          var terms=q.toLowerCase().split(/\s+/).filter(Boolean);
+          var shown=0;
+          allRows.forEach(function(tr){
+            var txt=tr.textContent.toLowerCase();
+            var st=tr.querySelector('.badge')?tr.querySelector('.badge').textContent.trim():'';
+            var pass=terms.every(function(t){return txt.includes(t);});
+            if(statusF.size && !Array.from(statusF).some(function(s){return st.includes(s);})) pass=false;
+            tr.style.display=pass?'':'none';
+            if(pass) shown++;
+          });
+          document.getElementById('row-count').textContent=shown+' of '+allRows.length+' records';
+        }
+        document.getElementById('tbl-search').addEventListener('input',function(e){q=e.target.value;filter();});
+        document.querySelectorAll('#status-chips .chip').forEach(function(c){
+          c.addEventListener('click',function(){
+            c.classList.toggle('active');
+            if(c.classList.contains('active')) statusF.add(c.textContent.trim());
+            else statusF.delete(c.textContent.trim());
+            filter();
+          });
+        });
+        document.querySelectorAll('#cat-chips .chip').forEach(function(c){
+          c.addEventListener('click',function(){
+            c.classList.toggle('active');
+            if(c.classList.contains('active')) catF.add(c.dataset.cat);
+            else catF.delete(c.dataset.cat);
+            filter();
+          });
+        });
+        // Sort select
+        var COLS=[
+          {k:'display_name',l:'Company',g:'Identity'},{k:'top_status',l:'Status',g:'Identity'},
+          {k:'headquarters_state',l:'Market',g:'Identity'},{k:'last_sighting_at',l:'Last Sighting',g:'Activity'},
+          {k:'verified_sightings',l:'# Verified',g:'Activity'},{k:'conf_activity',l:'Conf. Activity',g:'Activity'},
+          {k:'score',l:'Score',g:'Activity'}
+        ];
+        var sel=document.getElementById('sort-sel');
+        COLS.forEach(function(c){var o=document.createElement('option');o.value=c.k;o.textContent=c.l;sel.appendChild(o);});
+        // CSV export
+        document.getElementById('csv-btn').addEventListener('click',function(){
+          var rows=allRows.filter(function(tr){return tr.style.display!=='none';});
+          var cols=['Company','Status','Category','Market','Last Sighting','#Verified','Conf','Score','Contact','Phone','Email','Website'];
+          var csv=cols.map(function(c){return '"'+c+'"';}).join(',')+'\n'
+            +rows.map(function(tr){
+              var tds=Array.from(tr.querySelectorAll('td'));
+              return [1,2,3,4,5,6,7,8,9,10,11,12].map(function(i){
+                var v=(tds[i]||{}).textContent||'';
+                return '"'+v.replace(/"/g,'""').trim()+'"';
+              }).join(',');
+            }).join('\n');
+          var a=document.createElement('a');
+          a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'}));
+          a.download='rentv-advertisers.csv';a.click();URL.revokeObjectURL(a.href);
+        });
+      })();
+      </script>`;
+
+    res.type('html').send(layout('Advertisers', pageShell(rail, content), { activeNav: '/advertisers', market, verified: verifiedOnly, q }));
+  } catch (e) {
+    console.error('[pages] /advertisers', e.message);
+    res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">Server error: ${esc(e.message)}</div></div>`));
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /advertisers/:id — org profile (§25 tabs)
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/advertisers/:id', async (req, res) => {
+  const { id } = req.params;
+  if (!/^[0-9a-f-]{36}$/i.test(id)) return res.redirect('/advertisers');
+
+  try {
+    const [orgRows, sightings, events, contacts, scoreRows, evidenceRows, auditRows] = await Promise.all([
+      safeQ(`SELECT * FROM organizations WHERE id = $1`, [id]),
+      safeQ(`SELECT s.*, p.name AS pub_name, p.domain AS pub_domain
+             FROM ad_sightings s LEFT JOIN publications p ON p.id = s.publication_id
+             WHERE s.organization_id = $1 ORDER BY s.observed_at DESC NULLS LAST LIMIT 100`, [id]),
+      safeQ(`SELECT er.*, e.name AS event_name, e.start_date, e.end_date, e.city, e.state, e.official_url
+             FROM event_relationships er JOIN events e ON e.id = er.event_id
+             WHERE er.organization_id = $1 ORDER BY e.start_date DESC NULLS LAST LIMIT 50`, [id]),
+      safeQ(`SELECT cp.*, pe.full_name, pe.public_title, pe.linkedin_url AS person_li
+             FROM contact_points cp LEFT JOIN people pe ON pe.id = cp.person_id
+             WHERE cp.organization_id = $1 AND cp.do_not_contact = false
+             ORDER BY cp.confidence DESC LIMIT 50`, [id]),
+      safeQ(`SELECT * FROM opportunity_scores WHERE organization_id = $1 ORDER BY computed_at DESC LIMIT 1`, [id]),
+      safeQ(`SELECT er.* FROM evidence_records er WHERE er.id IN (
+               SELECT evidence_id FROM ad_sightings WHERE organization_id = $1 AND evidence_id IS NOT NULL
+             ) LIMIT 30`, [id]),
+      safeQ(`SELECT * FROM audit_logs WHERE entity_id = $1 ORDER BY created_at DESC LIMIT 30`, [id]),
+    ]);
+
+    if (!orgRows.length) return res.status(404).type('html').send(layout('Not Found', `<div class="page-content"><div class="alert alert-danger">Organization not found. <a href="/advertisers">Back to advertisers</a></div></div>`));
+
+    const org = orgRows[0];
+    const scoreRow = scoreRows[0];
+    let scoreExplain = null;
+    if (scoreRow && scoreRow.factors) {
+      try { scoreExplain = scoring.explainScore(scoreRow.factors); } catch (_) {}
+    }
+
+    const cats = (() => { try { return Array.isArray(org.advertiser_categories) ? org.advertiser_categories : JSON.parse(org.advertiser_categories || '[]'); } catch (_) { return []; } })();
+    const bestEmail = contacts.find(c => c.type === 'BUSINESS_EMAIL');
+    const bestPhone = contacts.find(c => c.type === 'BUSINESS_PHONE');
+    const website   = contacts.find(c => c.type === 'WEBSITE');
+    const linkedin  = contacts.find(c => c.type === 'LINKEDIN');
+
+    const FACTOR_LABELS = {
+      verifiedAdvertising: 'Verified advertising',
+      verifiedConferenceSpendSignal: 'Conference spend signal',
+      recency: 'Recency',
+      repeatActivity: 'Repeat activity',
+      californiaFit: 'California fit',
+      arizonaFit: 'Arizona fit',
+      categoryFit: 'Category fit',
+      rentvAudienceFit: 'RENTV audience fit',
+      contactCompleteness: 'Contact completeness',
+      evidenceQuality: 'Evidence quality',
+    };
+
+    const scoreHtml = scoreExplain
+      ? `<div class="settings-card">
+          <h3>Opportunity Score: ${scoreExplain.score} / 100</h3>
+          <p style="font-size:14px;color:var(--ink-muted)">RENTV sales opportunity score — not an ad-spend estimate. Computed ${fmtDate(scoreRow.computed_at)}.</p>
+          ${scoreExplain.factors.map(f => `
+            <div class="factor-row">
+              <span class="factor-name">${esc(FACTOR_LABELS[f.factor] || f.factor)}</span>
+              <div class="factor-bar-wrap"><div class="factor-bar" style="width:${f.value}%"></div></div>
+              <span class="factor-contrib">${f.value}</span>
+            </div>`).join('')}
+        </div>`
+      : `<p class="miss">No score computed yet. Run the scoring job.</p>`;
+
+    const sightingsHtml = sightings.length
+      ? sightings.map(s => `
+        <div class="evidence-card">
+          <div class="ev-title">${statusBadge(s.relationship_status)} ${esc(s.headline || 'Ad sighting')}</div>
+          <div class="ev-meta">
+            <span>${fmtDate(s.observed_at)}</span>
+            ${s.pub_name ? `<span>via <a href="/advertisers?q=${encodeURIComponent(s.pub_name)}">${esc(s.pub_name)}</a></span>` : ''}
+            ${s.source_page_url ? `<span><a href="${esc(s.source_page_url)}" target="_blank" rel="noopener noreferrer">Source ↗</a></span>` : ''}
+            <span>Verification: ${esc(s.verification_status || '—')}</span>
+            <span>Confidence: ${s.confidence ? Math.round(s.confidence * 100) + '%' : '—'}</span>
+          </div>
+          ${s.visible_copy ? `<div class="ev-excerpt">${esc(s.visible_copy)}</div>` : ''}
+          <div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap">
+            ${s.verification_status !== 'VERIFIED' ? `<button class="btn btn-outline" style="font-size:13px;min-height:36px" onclick="reviewAction('${esc(s.id)}','verify')">Mark Verified</button>` : ''}
+            ${s.verification_status !== 'REJECTED' ? `<button class="btn btn-ghost" style="font-size:13px;min-height:36px" onclick="reviewAction('${esc(s.id)}','reject')">Reject</button>` : ''}
+          </div>
+        </div>`).join('')
+      : '<p class="miss">No ad sightings recorded yet.</p>';
+
+    const confSponsors = events.filter(e => e.relationship_status !== 'SPEAKER_OR_PANELIST_ONLY');
+    const panelists    = events.filter(e => e.relationship_status === 'SPEAKER_OR_PANELIST_ONLY');
+
+    const confsHtml = events.length ? `
+      ${confSponsors.length ? `<div class="section-divider">Sponsor / Exhibitor Roles (${confSponsors.length})</div>
+        ${confSponsors.map(e => `<div class="evidence-card">
+          <div class="ev-title"><a href="/conferences/${esc(e.event_id)}">${esc(e.event_name)}</a></div>
+          <div class="ev-meta">
+            <span>${statusBadge(e.relationship_status)}</span>
+            <span>${fmtDateOnly(e.start_date)}${e.city ? ' &middot; ' + esc(e.city) : ''}${e.state ? ', ' + esc(e.state) : ''}</span>
+            ${e.sponsor_level ? `<span>Level: ${esc(e.sponsor_level)}</span>` : ''}
+            ${e.official_url ? `<span><a href="${esc(e.official_url)}" target="_blank" rel="noopener noreferrer">Official ↗</a></span>` : ''}
+          </div>
+        </div>`).join('')}` : ''}
+      ${panelists.length ? `<div class="section-divider">Speaker / Panelist Roles (${panelists.length})</div>
+        <div class="alert alert-info" style="margin-bottom:16px">These are speaking roles only — not verified sponsorships.</div>
+        ${panelists.map(e => `<div class="evidence-card">
+          <div class="ev-title"><a href="/conferences/${esc(e.event_id)}">${esc(e.event_name)}</a></div>
+          <div class="ev-meta">
+            <span>${statusBadge(e.relationship_status)}</span>
+            <span>${fmtDateOnly(e.start_date)}${e.city ? ' &middot; ' + esc(e.city) : ''}${e.state ? ', ' + esc(e.state) : ''}</span>
+            ${e.session_title ? `<span>Session: ${esc(e.session_title)}</span>` : ''}
+          </div>
+        </div>`).join('')}` : ''}
+    ` : '<p class="miss">No conference activity recorded.</p>';
+
+    const contactsHtml = contacts.length
+      ? contacts.map(c => `
+        <div class="evidence-card">
+          <div class="ev-title">${esc(c.full_name || 'Organization contact')}${c.public_title ? ` <span class="miss" style="font-weight:400">&middot; ${esc(c.public_title)}</span>` : ''}</div>
+          <div class="ev-meta">
+            <span>${esc(c.type.replace(/_/g, ' '))}</span>
+            <span>${c.type === 'BUSINESS_EMAIL' ? `<a href="mailto:${esc(c.value)}">${esc(c.value)}</a>` :
+                   c.type === 'BUSINESS_PHONE' ? `<a href="tel:${esc(c.value)}">${esc(c.value)}</a>` :
+                   c.type === 'WEBSITE' || c.type === 'LINKEDIN' ? `<a href="${esc(c.value)}" target="_blank" rel="noopener noreferrer">${esc(c.value)} ↗</a>` :
+                   esc(c.value)}</span>
+            ${c.confidence ? `<span>Confidence: ${Math.round(c.confidence * 100)}%</span>` : ''}
+            ${c.verified_at ? `<span>Verified: ${fmtDateOnly(c.verified_at)}</span>` : ''}
+          </div>
+          ${c.person_li ? `<div style="margin-top:6px"><a href="${esc(c.person_li)}" target="_blank" rel="noopener noreferrer" class="btn btn-ghost" style="font-size:13px;min-height:32px">Open LinkedIn ↗</a></div>` : ''}
+        </div>`).join('')
+      : '<p class="miss">No contacts recorded. <a href="/review">Add via review queue</a>.</p>';
+
+    const evidenceHtml = evidenceRows.length
+      ? evidenceRows.map(e => `
+        <div class="evidence-card">
+          <div class="ev-title">${esc(e.source_title || e.evidence_type)}</div>
+          <div class="ev-meta">
+            <span class="badge badge-research-needed" style="font-size:11px">${esc(e.evidence_type)}</span>
+            ${e.source_url ? `<span><a href="${esc(e.source_url)}" target="_blank" rel="noopener noreferrer">Source ↗</a></span>` : ''}
+            <span>Observed: ${fmtDateOnly(e.observed_at)}</span>
+            <span>Retrieved: ${fmtDate(e.retrieved_at)}</span>
+            <span>Confidence: ${Math.round((e.confidence || 0) * 100)}%</span>
+            <span>Export: ${e.export_allowed ? 'Allowed' : 'Internal only'}</span>
+          </div>
+          ${e.excerpt ? `<div class="ev-excerpt">${esc(e.excerpt)}</div>` : ''}
+        </div>`).join('')
+      : '<p class="miss">No evidence records linked yet.</p>';
+
+    const auditHtml = auditRows.length
+      ? auditRows.map(a => `
+        <div class="audit-row">
+          <div style="flex:0 0 180px">
+            <span class="when-chip" title="${esc(new Date(a.created_at).toISOString())}">${fmtDate(a.created_at)}</span>
+          </div>
+          <div>
+            <span class="audit-action">${esc(a.action)}</span>
+            <span class="audit-meta">&nbsp;&mdash; by ${esc(a.actor || 'system')}</span>
+          </div>
+        </div>`).join('')
+      : '<p class="miss">No audit history.</p>';
+
+    const header = `
+      <div class="org-header">
+        <div class="org-thumb-placeholder" style="flex-shrink:0;width:64px;height:64px;font-size:28px" aria-hidden="true">&#127970;</div>
+        <div class="org-header-info">
+          <h1>${esc(org.display_name)}</h1>
+          <div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:6px">
+            ${statusBadge(sightings[0] && sightings[0].relationship_status)}
+            ${scoreBadge(scoreRow && scoreRow.score)}
+            ${org.headquarters_state ? `<span class="badge badge-panelist">${esc(org.headquarters_state)}</span>` : ''}
+            ${cats.slice(0, 2).map(c => `<span class="badge badge-panelist" style="font-size:11px">${esc(c)}</span>`).join('')}
+          </div>
+          ${org.description ? `<p style="color:var(--ink-muted);font-size:15px;margin:0">${esc(org.description)}</p>` : ''}
+          <div class="org-actions">
+            ${website ? `<a href="${/^https?:/.test(website.value) ? esc(website.value) : 'https://' + esc(website.value)}" class="btn btn-outline" target="_blank" rel="noopener noreferrer">Website ↗</a>` : ''}
+            ${linkedin ? `<a href="${esc(linkedin.value)}" class="btn btn-outline" target="_blank" rel="noopener noreferrer">LinkedIn ↗</a>` : ''}
+            ${bestEmail ? `<a href="mailto:${esc(bestEmail.value)}" class="btn btn-outline">Email</a>` : ''}
+            ${bestPhone ? `<a href="tel:${esc(bestPhone.value)}" class="btn btn-outline">Call</a>` : ''}
+            ${scoreRow ? `<button class="btn btn-ghost" data-smw-org="${esc(id)}" data-smw-name="${esc(org.display_name)}">Show Me Why &#9432;</button>` : ''}
+          </div>
+        </div>
+      </div>`;
+
+    const tabs = [
+      ['summary', 'Summary'],
+      ['ads', 'Ads & Creatives'],
+      ['conferences', 'Conference Sponsorships'],
+      ['contacts', 'Contacts'],
+      ['evidence', 'Sources & Evidence'],
+      ['audit', 'Audit History'],
+    ];
+
+    const tabBar = `<div class="tab-bar" role="tablist">
+      ${tabs.map(([k, l]) => `<button class="tab-btn" role="tab" data-tab="${k}" aria-selected="false">${esc(l)}</button>`).join('')}
+    </div>`;
+
+    const summaryTab = `<div class="tab-panel" id="tab-summary">
+      <div class="settings-card">
+        <h3>Organization details</h3>
+        <table style="width:100%;border-collapse:collapse;font-size:15px">
+          ${[
+            ['Legal name', org.legal_name],
+            ['Domain', org.domain ? `<a href="https://${esc(org.domain)}" target="_blank" rel="noopener noreferrer">${esc(org.domain)} ↗</a>` : '—'],
+            ['Headquarters', [org.headquarters_city, org.headquarters_state].filter(Boolean).join(', ') || '—'],
+            ['Status', org.active_status],
+            ['First seen', fmtDateOnly(org.first_seen_at)],
+            ['Last seen', fmtDate(org.last_seen_at)],
+            ['Created', fmtDate(org.created_at)],
+            ['Categories', cats.join(', ') || '—'],
+          ].map(([k, v]) => `<tr><td style="padding:8px 0;color:var(--ink-muted);width:180px;border-bottom:1px solid var(--border)">${esc(k)}</td><td style="padding:8px 0;border-bottom:1px solid var(--border)">${v}</td></tr>`).join('')}
+        </table>
+      </div>
+      ${scoreHtml}
+    </div>`;
+
+    const body = `<div class="page-content">
+      <p><a href="/advertisers">&larr; All advertisers</a></p>
+      ${header}
+      ${tabBar}
+      ${summaryTab}
+      <div class="tab-panel" id="tab-ads">${sightingsHtml}</div>
+      <div class="tab-panel" id="tab-conferences">${confsHtml}</div>
+      <div class="tab-panel" id="tab-contacts">${contactsHtml}</div>
+      <div class="tab-panel" id="tab-evidence">${evidenceHtml}</div>
+      <div class="tab-panel" id="tab-audit">${auditHtml}</div>
+      <script>
+      (function(){
+        var tabs=document.querySelectorAll('.tab-btn');
+        var panels=document.querySelectorAll('.tab-panel');
+        function show(k){
+          tabs.forEach(function(t){t.classList.toggle('active',t.dataset.tab===k);t.setAttribute('aria-selected',t.dataset.tab===k);});
+          panels.forEach(function(p){p.classList.toggle('active',p.id==='tab-'+k);});
+          try{localStorage.setItem('adv-tab-${esc(id)}',k);}catch(_){}
+        }
+        tabs.forEach(function(t){t.addEventListener('click',function(){show(t.dataset.tab);});});
+        var saved=localStorage.getItem('adv-tab-${esc(id)}');
+        show(saved&&document.getElementById('tab-'+saved)?saved:'summary');
+        function reviewAction(sid,action){
+          fetch('/api/v1/review/'+sid+'/'+action,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})
+            .then(function(r){return r.json();})
+            .then(function(d){if(d.ok){location.reload();}else{alert(d.message||'Error');}})
+            .catch(function(e){alert(e.message);});
+        }
+        window.reviewAction=reviewAction;
+      })();
+      </script>
+    </div>`;
+
+    res.type('html').send(layout(org.display_name, body, { activeNav: '/advertisers' }));
+  } catch (e) {
+    console.error('[pages] /advertisers/:id', e.message);
+    res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">Server error: ${esc(e.message)}</div></div>`));
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /ads — visual ad gallery
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/ads', async (req, res) => {
+  try {
+    const rows = await safeQ(`
+      SELECT s.id, s.relationship_status, s.headline, s.observed_at, s.source_page_url,
+        s.verification_status, s.confidence, s.created_at,
+        o.display_name AS org_name, o.id AS org_id,
+        p.name AS pub_name, ca.object_key AS thumb_key
+      FROM ad_sightings s
+      LEFT JOIN organizations o ON o.id = s.organization_id
+      LEFT JOIN publications p ON p.id = s.publication_id
+      LEFT JOIN creative_assets ca ON ca.id = s.thumbnail_asset_id
+      ORDER BY s.observed_at DESC NULLS LAST LIMIT 200`);
+
+    const isDemo = rows.length === 0;
+    const cards = rows.map(r => `
+      <div class="ad-card">
+        ${r.thumb_key
+          ? `<img src="/assets/${esc(r.thumb_key)}" alt="${esc(r.org_name || '')} ad thumbnail" loading="lazy">`
+          : `<div style="height:160px;display:flex;align-items:center;justify-content:center;background:var(--surface-alt);font-size:40px">&#128444;</div>`}
+        <div class="ad-card-body">
+          <div class="org-name"><a href="/advertisers/${esc(r.org_id)}">${esc(r.org_name || '—')}</a></div>
+          <div class="ad-meta">${statusBadge(r.relationship_status)} &middot; ${fmtDateOnly(r.observed_at)}</div>
+          ${r.headline ? `<p style="font-size:14px;color:var(--ink-muted);margin:4px 0 0">${esc(r.headline)}</p>` : ''}
+        </div>
+        <div class="ad-card-footer">
+          <a href="/ads/${esc(r.id)}" class="btn btn-outline" style="font-size:13px;min-height:36px">Details</a>
+          ${r.source_page_url ? `<a href="${esc(r.source_page_url)}" class="btn btn-ghost" style="font-size:13px;min-height:36px" target="_blank" rel="noopener noreferrer">Source ↗</a>` : ''}
+        </div>
+      </div>`).join('');
+
+    const body = `<div class="page-content">
+      <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:12px">
+        <h1>Ad Gallery</h1>
+        <div style="display:flex;align-items:center;gap:12px">
+          <label class="density-wrap">Density <input type="range" id="density-sl" min="1" max="10" step="1"></label>
+        </div>
+      </div>
+      ${isDemo ? demoBanner : ''}
+      <div class="card-grid">${cards || '<p class="miss">No ad sightings yet. Import data or run a research job.</p>'}</div>
+      <script>
+      (function(){
+        var sl=document.getElementById('density-sl');
+        if(!sl) return;
+        var stored=parseInt(localStorage.getItem('ads:density'),10);
+        var val=(stored>=1&&stored<=10)?stored:5;
+        sl.value=val;
+        function apply(v){var px=Math.round(450-(Math.max(1,Math.min(10,v))-1)*(450-200)/9);document.documentElement.style.setProperty('--card-min',px+'px');}
+        apply(val);
+        sl.addEventListener('input',function(){apply(+sl.value);localStorage.setItem('ads:density',sl.value);});
+      })();
+      </script>
+    </div>`;
+
+    res.type('html').send(layout('Ad Gallery', body, { activeNav: '/ads' }));
+  } catch (e) {
+    res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+  }
+});
+
+// GET /ads/:id
+router.get('/ads/:id', async (req, res) => {
+  if (!/^[0-9a-f-]{36}$/i.test(req.params.id)) return res.redirect('/ads');
+  try {
+    const rows = await safeQ(`
+      SELECT s.*, o.display_name AS org_name, o.id AS org_id, p.name AS pub_name,
+        er.source_url, er.source_title, er.excerpt, er.observed_at AS ev_observed_at,
+        er.confidence AS ev_confidence, er.export_allowed
+      FROM ad_sightings s
+      LEFT JOIN organizations o ON o.id = s.organization_id
+      LEFT JOIN publications p ON p.id = s.publication_id
+      LEFT JOIN evidence_records er ON er.id = s.evidence_id
+      WHERE s.id = $1`, [req.params.id]);
+
+    if (!rows.length) return res.status(404).type('html').send(layout('Not Found', `<div class="page-content"><p>Ad sighting not found. <a href="/ads">Back to gallery</a></p></div>`));
+    const s = rows[0];
+    const body = `<div class="page-content">
+      <p><a href="/ads">&larr; Ad Gallery</a></p>
+      <h1>${esc(s.headline || 'Ad Sighting')}</h1>
+      <div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:16px">
+        ${statusBadge(s.relationship_status)}
+        <span class="badge badge-panelist">Verification: ${esc(s.verification_status || '—')}</span>
+      </div>
+      <div class="settings-card">
+        ${[
+          ['Company', s.org_id ? `<a href="/advertisers/${esc(s.org_id)}">${esc(s.org_name || '—')}</a>` : '—'],
+          ['Publication', esc(s.pub_name || '—')],
+          ['Observed', fmtDate(s.observed_at)],
+          ['First observed', fmtDateOnly(s.first_observed_at)],
+          ['Last observed', fmtDateOnly(s.last_observed_at)],
+          ['Source URL', s.source_page_url ? `<a href="${esc(s.source_page_url)}" target="_blank" rel="noopener noreferrer">${esc(s.source_page_url)} ↗</a>` : '—'],
+          ['Landing URL', s.landing_url ? `<a href="${esc(s.landing_url)}" target="_blank" rel="noopener noreferrer">${esc(s.landing_url)} ↗</a>` : '—'],
+          ['Confidence', s.confidence ? Math.round(s.confidence * 100) + '%' : '—'],
+          ['Created', fmtDate(s.created_at)],
+        ].map(([k, v]) => `<div style="display:flex;gap:16px;padding:8px 0;border-bottom:1px solid var(--border);font-size:15px"><span style="color:var(--ink-muted);flex:0 0 160px">${esc(k)}</span><span>${v}</span></div>`).join('')}
+      </div>
+      ${s.visible_copy ? `<div class="settings-card"><h3>Ad copy</h3><p style="font-style:italic">${esc(s.visible_copy)}</p></div>` : ''}
+      ${s.excerpt ? `<div class="settings-card"><h3>Evidence excerpt</h3><blockquote class="ev-excerpt" style="margin:0">${esc(s.excerpt)}</blockquote></div>` : ''}
+      <div style="display:flex;gap:10px;margin-top:16px">
+        <button class="btn btn-outline" onclick="reviewAction('${esc(s.id)}','verify')">Mark Verified</button>
+        <button class="btn btn-ghost" onclick="reviewAction('${esc(s.id)}','reject')">Reject</button>
+      </div>
+      <script>function reviewAction(id,a){fetch('/api/v1/review/'+id+'/'+a,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(r=>r.json()).then(d=>{if(d.ok)location.reload();else alert(d.message||'Error');}).catch(e=>alert(e.message));}</script>
+    </div>`;
+    res.type('html').send(layout('Ad: ' + (s.headline || s.org_name || ''), body, { activeNav: '/ads' }));
+  } catch (e) {
+    res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /conferences
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/conferences', async (req, res) => {
+  try {
+    const rows = await safeQ(`
+      SELECT e.*,
+        (SELECT COUNT(*) FROM event_relationships er WHERE er.event_id = e.id
+         AND er.relationship_status != 'SPEAKER_OR_PANELIST_ONLY') AS sponsor_count,
+        (SELECT COUNT(*) FROM event_relationships er WHERE er.event_id = e.id
+         AND er.relationship_status = 'SPEAKER_OR_PANELIST_ONLY') AS panelist_count
+      FROM events e ORDER BY e.start_date DESC NULLS LAST LIMIT 200`);
+
+    const isDemo = rows.length === 0;
+    const tableRows = rows.map(r => `<tr>
+      <td><a href="/conferences/${esc(r.id)}">${esc(r.name)}</a></td>
+      <td>${fmtDateOnly(r.start_date)}</td>
+      <td>${esc(r.city || '—')}${r.state ? ', ' + esc(r.state) : ''}</td>
+      <td>${esc(r.event_type || '—')}</td>
+      <td class="num"><a href="/conferences/${esc(r.id)}">${Number(r.sponsor_count || 0).toLocaleString()}</a></td>
+      <td class="num">${Number(r.panelist_count || 0).toLocaleString()}</td>
+      <td>${r.official_url ? `<a href="${esc(r.official_url)}" target="_blank" rel="noopener noreferrer">Official ↗</a>` : '<span class="miss">—</span>'}</td>
+    </tr>`).join('');
+
+    const body = `<div class="page-content">
+      <h1>Conferences</h1>
+      ${isDemo ? demoBanner : ''}
+      <div class="tbl-controls">
+        <input class="tbl-search" id="conf-search" type="search" placeholder="Search conferences…" aria-label="Search conferences">
+        <span class="tbl-count" id="conf-count">${rows.length} events</span>
+      </div>
+      <div class="tbl-wrap">
+        <table class="adv-tbl" id="conf-tbl" aria-label="Conferences">
+          <thead><tr><th>Name</th><th>Date</th><th>Location</th><th>Type</th><th>Sponsors</th><th>Panelists</th><th>Link</th></tr></thead>
+          <tbody>${tableRows || '<tr><td colspan="7"><p class="miss" style="padding:20px">No conferences recorded yet.</p></td></tr>'}</tbody>
+        </table>
+      </div>
+      <script>
+      (function(){
+        var s=document.getElementById('conf-search'),rows=Array.from(document.querySelectorAll('#conf-tbl tbody tr'));
+        if(!s)return;
+        s.addEventListener('input',function(){var q=s.value.toLowerCase().split(/\s+/).filter(Boolean),n=0;rows.forEach(function(tr){var pass=q.every(function(t){return tr.textContent.toLowerCase().includes(t);});tr.style.display=pass?'':'none';if(pass)n++;});document.getElementById('conf-count').textContent=n+' of '+rows.length+' events';});
+      })();
+      </script>
+    </div>`;
+    res.type('html').send(layout('Conferences', body, { activeNav: '/conferences' }));
+  } catch (e) {
+    res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+  }
+});
+
+// GET /conferences/:id
+router.get('/conferences/:id', async (req, res) => {
+  if (!/^[0-9a-f-]{36}$/i.test(req.params.id)) return res.redirect('/conferences');
+  try {
+    const [evRows, rels] = await Promise.all([
+      safeQ(`SELECT * FROM events WHERE id = $1`, [req.params.id]),
+      safeQ(`SELECT er.*, o.display_name AS org_name, o.id AS org_id, o.domain,
+               o.advertiser_categories
+             FROM event_relationships er
+             JOIN organizations o ON o.id = er.organization_id
+             WHERE er.event_id = $1
+             ORDER BY CASE er.relationship_status
+               WHEN 'VERIFIED_CONFERENCE_SPONSOR' THEN 1 WHEN 'VERIFIED_EXHIBITOR' THEN 2
+               WHEN 'VERIFIED_MEDIA_PARTNER' THEN 3 WHEN 'VERIFIED_CONTENT_PARTNER' THEN 4
+               WHEN 'SPEAKER_OR_PANELIST_ONLY' THEN 5 ELSE 6 END, o.display_name`, [req.params.id]),
+    ]);
+    if (!evRows.length) return res.status(404).type('html').send(layout('Not Found', `<div class="page-content"><p>Conference not found. <a href="/conferences">Back</a></p></div>`));
+    const ev = evRows[0];
+    const sponsors  = rels.filter(r => r.relationship_status !== 'SPEAKER_OR_PANELIST_ONLY');
+    const panelists = rels.filter(r => r.relationship_status === 'SPEAKER_OR_PANELIST_ONLY');
+
+    const relCard = (r) => `<div class="evidence-card">
+      <div class="ev-title"><a href="/advertisers/${esc(r.org_id)}">${esc(r.org_name)}</a></div>
+      <div class="ev-meta">
+        ${statusBadge(r.relationship_status)}
+        ${r.sponsor_level ? `<span>Level: ${esc(r.sponsor_level)}</span>` : ''}
+        ${r.session_title ? `<span>Session: ${esc(r.session_title)}</span>` : ''}
+        ${r.panel_role ? `<span>Role: ${esc(r.panel_role)}</span>` : ''}
+        ${r.domain ? `<span><a href="https://${esc(r.domain)}" target="_blank" rel="noopener noreferrer">${esc(r.domain)} ↗</a></span>` : ''}
+        <span>Confidence: ${Math.round((r.confidence || 0) * 100)}%</span>
+      </div>
+    </div>`;
+
+    const body = `<div class="page-content">
+      <p><a href="/conferences">&larr; Conferences</a></p>
+      <h1>${esc(ev.name)}</h1>
+      <div class="settings-card" style="margin-bottom:24px">
+        ${[
+          ['Date', fmtDateOnly(ev.start_date) + (ev.end_date ? ' – ' + fmtDateOnly(ev.end_date) : '')],
+          ['Location', [ev.venue, ev.city, ev.state].filter(Boolean).join(', ') || '—'],
+          ['Type', ev.event_type || '—'],
+          ['Official URL', ev.official_url ? `<a href="${esc(ev.official_url)}" target="_blank" rel="noopener noreferrer">${esc(ev.official_url)} ↗</a>` : '—'],
+          ['Sponsor page', ev.sponsor_page_url ? `<a href="${esc(ev.sponsor_page_url)}" target="_blank" rel="noopener noreferrer">Sponsor page ↗</a>` : '—'],
+        ].map(([k,v]) => `<div style="display:flex;gap:16px;padding:8px 0;border-bottom:1px solid var(--border);font-size:15px"><span style="color:var(--ink-muted);flex:0 0 140px">${esc(k)}</span><span>${v}</span></div>`).join('')}
+      </div>
+
+      <div class="section-divider">Sponsors, Exhibitors &amp; Partners (${sponsors.length})</div>
+      ${sponsors.length ? sponsors.map(relCard).join('') : '<p class="miss">No verified sponsors recorded. Panelists are listed separately below.</p>'}
+
+      <div class="section-divider" style="margin-top:28px">Speakers &amp; Panelists (${panelists.length})</div>
+      ${panelists.length
+        ? `<div class="alert alert-info" style="margin-bottom:16px">Speaking roles only — not sponsors unless separately verified.</div>` + panelists.map(relCard).join('')
+        : '<p class="miss">No panelist records.</p>'}
+    </div>`;
+    res.type('html').send(layout(ev.name, body, { activeNav: '/conferences' }));
+  } catch (e) {
+    res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /contacts
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/contacts', async (req, res) => {
+  const q = req.query.q ? String(req.query.q).slice(0, 200) : '';
+  try {
+    const params = [];
+    function p(v) { params.push(v); return '$' + params.length; }
+    const conds = ['cp.do_not_contact = false'];
+    if (q) {
+      const like = '%' + q.replace(/[%_]/g, '\\$&') + '%';
+      conds.push(`(pe.full_name ILIKE ${p(like)} OR o.display_name ILIKE ${p(like)} OR cp.value ILIKE ${p(like)})`);
+    }
+    const rows = await safeQ(`
+      SELECT cp.id, cp.type, cp.value, cp.confidence, cp.verified_at, cp.created_at,
+        pe.full_name, pe.public_title, pe.linkedin_url AS person_li,
+        o.display_name AS org_name, o.id AS org_id
+      FROM contact_points cp
+      LEFT JOIN people pe ON pe.id = cp.person_id
+      LEFT JOIN organizations o ON o.id = cp.organization_id
+      WHERE ${conds.join(' AND ')}
+      ORDER BY cp.confidence DESC, cp.created_at DESC
+      LIMIT 500`, params);
+
+    const isDemo = rows.length === 0;
+    const tableRows = rows.map(r => {
+      const val = r.type === 'BUSINESS_EMAIL' ? `<a href="mailto:${esc(r.value)}">${esc(r.value)}</a>`
+        : r.type === 'BUSINESS_PHONE' ? `<a href="tel:${esc(r.value)}">${esc(r.value)}</a>`
+        : r.type === 'WEBSITE' || r.type === 'LINKEDIN' ? `<a href="${esc(r.value)}" target="_blank" rel="noopener noreferrer">${esc(r.value)} ↗</a>`
+        : esc(r.value);
+      return `<tr>
+        <td>${r.full_name ? esc(r.full_name) : '<span class="miss">—</span>'}</td>
+        <td>${r.public_title ? esc(r.public_title) : '<span class="miss">—</span>'}</td>
+        <td><a href="/advertisers/${esc(r.org_id)}">${esc(r.org_name || '—')}</a></td>
+        <td><span class="badge badge-panelist" style="font-size:11px">${esc(r.type.replace(/_/g, ' '))}</span></td>
+        <td>${val}</td>
+        <td>${r.confidence ? Math.round(r.confidence * 100) + '%' : '—'}</td>
+        <td>${fmtDateOnly(r.verified_at)}</td>
+        <td>${r.person_li ? `<a href="${esc(r.person_li)}" target="_blank" rel="noopener noreferrer">LinkedIn ↗</a>` : '<span class="miss">—</span>'}</td>
+        <td><span class="when-chip" title="${esc(r.created_at ? new Date(r.created_at).toISOString() : '')}">${fmtDate(r.created_at)}</span></td>
+      </tr>`;
+    }).join('');
+
+    const body = `<div class="page-content">
+      <h1>Contacts</h1>
+      ${isDemo ? demoBanner : ''}
+      <div class="tbl-controls">
+        <input class="tbl-search" id="contact-search" type="search" placeholder="Search contacts…" value="${esc(q)}" aria-label="Search contacts">
+        <span class="tbl-count" id="contact-count">${rows.length} contacts</span>
+      </div>
+      <div class="tbl-wrap">
+        <table class="adv-tbl" id="contact-tbl" aria-label="Contacts">
+          <thead><tr><th>Name</th><th>Title</th><th>Company</th><th>Type</th><th>Value</th><th>Confidence</th><th>Verified</th><th>LinkedIn</th><th>Created</th></tr></thead>
+          <tbody>${tableRows || '<tr><td colspan="9"><p class="miss" style="padding:20px">No contacts yet.</p></td></tr>'}</tbody>
+        </table>
+      </div>
+      <script>
+      (function(){var s=document.getElementById('contact-search'),rows=Array.from(document.querySelectorAll('#contact-tbl tbody tr'));if(!s)return;s.addEventListener('input',function(){var q=s.value.toLowerCase().split(/\s+/).filter(Boolean),n=0;rows.forEach(function(tr){var pass=q.every(function(t){return tr.textContent.toLowerCase().includes(t);});tr.style.display=pass?'':'none';if(pass)n++;});document.getElementById('contact-count').textContent=n+' of '+rows.length+' contacts';});})();
+      </script>
+    </div>`;
+    res.type('html').send(layout('Contacts', body, { activeNav: '/contacts', q }));
+  } catch (e) {
+    res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /prospects
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/prospects', async (req, res) => {
+  const market = ['CA', 'AZ', 'ALL'].includes(req.query.market) ? req.query.market : 'ALL';
+  try {
+    const params = [T.VERIFIED_STATUSES];
+    const conds = [`NOT EXISTS (SELECT 1 FROM ad_sightings s WHERE s.organization_id = o.id AND s.relationship_status = ANY($1))`];
+    if (market !== 'ALL') { params.push(market); conds.push(`o.headquarters_state = $${params.length}`); }
+
+    const rows = await safeQ(`
+      SELECT o.id, o.display_name, o.domain, o.headquarters_state, o.headquarters_city,
+        o.advertiser_categories, o.last_seen_at, o.created_at,
+        opp.score, opp.computed_at AS score_at
+      FROM organizations o
+      LEFT JOIN opportunity_scores opp ON opp.organization_id = o.id
+        AND opp.computed_at = (SELECT MAX(x.computed_at) FROM opportunity_scores x WHERE x.organization_id = o.id)
+      WHERE ${conds.join(' AND ')}
+      ORDER BY opp.score DESC NULLS LAST, o.last_seen_at DESC NULLS LAST
+      LIMIT 200`, params);
+
+    const isDemo = rows.length === 0;
+    const tableRows = rows.map((r, i) => {
+      const cats = (() => { try { return Array.isArray(r.advertiser_categories) ? r.advertiser_categories : JSON.parse(r.advertiser_categories || '[]'); } catch (_) { return []; } })();
+      return `<tr>
+        <td class="num">${i + 1}</td>
+        <td><a href="/advertisers/${esc(r.id)}">${esc(r.display_name)}</a></td>
+        <td>${esc(r.headquarters_state || '—')}</td>
+        <td style="font-size:13px;max-width:200px;white-space:normal">${esc(cats.slice(0, 2).join(', ') || '—')}</td>
+        <td>${scoreBadge(r.score)}</td>
+        <td>${fmtDateOnly(r.score_at)}</td>
+        <td>${r.domain ? `<a href="https://${esc(r.domain)}" target="_blank" rel="noopener noreferrer">${esc(r.domain)} ↗</a>` : '<span class="miss">—</span>'}</td>
+        <td>${r.id ? `<button class="btn btn-ghost" style="font-size:12px;padding:4px 8px;min-height:32px" data-smw-org="${esc(r.id)}" data-smw-name="${esc(r.display_name)}">Why?</button>` : ''}</td>
+      </tr>`;
+    }).join('');
+
+    const body = `<div class="page-content">
+      <h1>Top Prospects</h1>
+      <p style="color:var(--ink-muted);font-size:15px">Companies ranked by RENTV advertising opportunity score — not verified advertisers yet.</p>
+      ${isDemo ? demoBanner : ''}
+      <div class="tbl-wrap">
+        <table class="adv-tbl" aria-label="Prospects">
+          <thead><tr><th>#</th><th>Company</th><th>State</th><th>Category</th><th>Score</th><th>Scored</th><th>Website</th><th>Why?</th></tr></thead>
+          <tbody>${tableRows || '<tr><td colspan="8"><p class="miss" style="padding:20px">No prospects yet — run the scoring job.</p></td></tr>'}</tbody>
+        </table>
+      </div>
+    </div>`;
+    res.type('html').send(layout('Prospects', body, { activeNav: '/prospects', market }));
+  } catch (e) {
+    res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /analytics
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/analytics', async (req, res) => {
+  try {
+    const [ga4, conn] = await Promise.all([
+      safeQ(`SELECT * FROM ga4_daily_metrics ORDER BY metric_date DESC LIMIT 30`),
+      safeQ(`SELECT * FROM analytics_connections WHERE kind = 'GA4' LIMIT 1`),
+    ]);
+    const isDemo = ga4.length === 0 || (ga4[0] && ga4[0].is_demo);
+    const connStatus = conn[0] ? conn[0].status : 'NOT_CONNECTED';
+
+    const tableRows = ga4.map(r => `<tr>
+      <td>${fmtDateOnly(r.metric_date)}</td>
+      <td class="num">${(r.sessions || 0).toLocaleString()}</td>
+      <td class="num">${(r.total_users || 0).toLocaleString()}</td>
+      <td class="num">${(r.new_users || 0).toLocaleString()}</td>
+      <td class="num">${(r.engaged_sessions || 0).toLocaleString()}</td>
+      <td class="num">${r.engagement_rate ? (r.engagement_rate * 100).toFixed(1) + '%' : '—'}</td>
+      <td class="num">${r.avg_engagement_time ? Number(r.avg_engagement_time).toFixed(1) + 's' : '—'}</td>
+      <td class="num">${(r.views || 0).toLocaleString()}</td>
+    </tr>`).join('');
+
+    const body = `<div class="page-content">
+      <h1>Analytics — GA4</h1>
+      <div class="alert alert-info" style="margin-bottom:16px">
+        Connection status: <strong>${esc(connStatus)}</strong>.
+        ${connStatus !== 'CONNECTED' ? `<a href="/settings">Connect GA4 →</a>` : 'Import running.'}
+      </div>
+      ${isDemo ? demoBanner : ''}
+      <div class="tbl-wrap">
+        <table class="adv-tbl" aria-label="GA4 daily metrics">
+          <thead><tr><th>Date</th><th>Sessions</th><th>Users</th><th>New Users</th><th>Engaged</th><th>Eng. Rate</th><th>Avg Time</th><th>Views</th></tr></thead>
+          <tbody>${tableRows || '<tr><td colspan="8"><p class="miss" style="padding:20px">No GA4 data. <a href="/imports">Import CSV</a> or <a href="/settings">connect API</a>.</p></td></tr>'}</tbody>
+        </table>
+      </div>
+      <p style="margin-top:16px"><a href="/search-intelligence" class="btn btn-outline">View Search Intelligence →</a></p>
+    </div>`;
+    res.type('html').send(layout('Analytics', body, { activeNav: '/analytics' }));
+  } catch (e) {
+    res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /search-intelligence
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/search-intelligence', async (req, res) => {
+  try {
+    const rows = await safeQ(`
+      SELECT query, SUM(clicks) AS clicks, SUM(impressions) AS impressions,
+        AVG(ctr) AS ctr, AVG(position) AS position, MAX(is_brand) AS is_brand
+      FROM gsc_query_metrics
+      WHERE metric_date >= CURRENT_DATE - 28
+      GROUP BY query ORDER BY impressions DESC NULLS LAST LIMIT 100`);
+
+    const isDemo = rows.length === 0;
+    const tableRows = rows.map(r => `<tr>
+      <td>${esc(r.query || '—')}</td>
+      <td class="num">${Number(r.impressions || 0).toLocaleString()}</td>
+      <td class="num">${Number(r.clicks || 0).toLocaleString()}</td>
+      <td class="num">${r.ctr ? (r.ctr * 100).toFixed(1) + '%' : '—'}</td>
+      <td class="num">${r.position ? Number(r.position).toFixed(1) : '—'}</td>
+      <td>${r.is_brand ? '<span class="badge badge-verified-media">Brand</span>' : '<span class="badge badge-panelist">Non-brand</span>'}</td>
+    </tr>`).join('');
+
+    const body = `<div class="page-content">
+      <h1>Search Intelligence — GSC</h1>
+      <p style="color:var(--ink-muted);font-size:15px">Organic search data from Google Search Console. Last 28 days.</p>
+      ${isDemo ? demoBanner : ''}
+      <div class="tbl-controls">
+        <input class="tbl-search" id="gsc-search" type="search" placeholder="Search queries…" aria-label="Search queries">
+        <span class="tbl-count" id="gsc-count">${rows.length} queries</span>
+      </div>
+      <div class="tbl-wrap">
+        <table class="adv-tbl" id="gsc-tbl" aria-label="GSC queries">
+          <thead><tr><th>Query</th><th>Impressions</th><th>Clicks</th><th>CTR</th><th>Avg Position</th><th>Type</th></tr></thead>
+          <tbody>${tableRows || '<tr><td colspan="6"><p class="miss" style="padding:20px">No GSC data. <a href="/imports">Import CSV</a> or <a href="/settings">connect API</a>.</p></td></tr>'}</tbody>
+        </table>
+      </div>
+      <script>(function(){var s=document.getElementById('gsc-search'),rows=Array.from(document.querySelectorAll('#gsc-tbl tbody tr'));if(!s)return;s.addEventListener('input',function(){var q=s.value.toLowerCase().split(/\s+/).filter(Boolean),n=0;rows.forEach(function(tr){var pass=q.every(function(t){return tr.textContent.toLowerCase().includes(t);});tr.style.display=pass?'':'none';if(pass)n++;});document.getElementById('gsc-count').textContent=n+' of '+rows.length+' queries';});})();</script>
+    </div>`;
+    res.type('html').send(layout('Search Intelligence', body, { activeNav: '/search-intelligence' }));
+  } catch (e) {
+    res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /media-kit (§27)
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/media-kit', async (req, res) => {
+  try {
+    const [audiences, rates] = await Promise.all([
+      safeQ(`SELECT * FROM rentv_audience_snapshots ORDER BY observed_at DESC`),
+      safeQ(`SELECT * FROM rentv_rate_snapshots ORDER BY product_key, observed_at DESC`),
+    ]);
+
+    const isDemo = audiences.length === 0 && rates.length === 0;
+
+    // Group audience snapshots by metric_key to show conflict warning when >1 value
+    const byKey = {};
+    audiences.forEach(a => { (byKey[a.metric_key] = byKey[a.metric_key] || []).push(a); });
+
+    const audienceHtml = Object.entries(byKey).map(([key, snaps]) => {
+      const latest = snaps[0];
+      const hasConflict = snaps.length > 1 &&
+        snaps.some(s => String(s.value_numeric || s.value_text) !== String(latest.value_numeric || latest.value_text));
+      return `
+        <div class="mk-metric">
+          <div class="mk-val">${esc(latest.value_text || (latest.value_numeric ? Number(latest.value_numeric).toLocaleString() : '—'))}</div>
+          <div class="mk-label">${esc(latest.metric_label)}</div>
+          <div class="mk-source">As of ${fmtDateOnly(latest.observed_at)} &middot; ${esc(latest.source_key || 'manual entry')}</div>
+          ${hasConflict ? `<div class="conflict-warning" style="margin-top:8px;font-size:12px">
+            Multiple values recorded. Showing most recent. History:
+            ${snaps.map(s => `${esc(s.value_text || String(s.value_numeric))} (${fmtDateOnly(s.observed_at)})`).join(' / ')}
+          </div>` : ''}
+        </div>`;
+    }).join('');
+
+    // Group rates by product
+    const byProduct = {};
+    rates.forEach(r => { (byProduct[r.product_key] = byProduct[r.product_key] || []).push(r); });
+
+    const ratesHtml = Object.entries(byProduct).map(([pk, snaps]) => {
+      const latest = snaps[0];
+      const hasMultiple = snaps.length > 1;
+      return `<div class="settings-card" style="margin-bottom:12px">
+        <h3>${esc(latest.product_label)}</h3>
+        <div style="display:flex;gap:16px;flex-wrap:wrap;align-items:baseline">
+          <span style="font-size:28px;font-weight:900">$${latest.rate ? Number(latest.rate).toLocaleString() : '—'}</span>
+          <span style="color:var(--ink-muted);font-size:15px">${esc(latest.unit || '')}</span>
+        </div>
+        <div class="mk-source">Rate as of ${fmtDateOnly(latest.observed_at)} &middot; ${esc(latest.source_key || 'manual')}</div>
+        ${latest.package_notes ? `<p style="font-size:14px;color:var(--ink-muted);margin-top:8px">${esc(latest.package_notes)}</p>` : ''}
+        ${hasMultiple ? `<details style="margin-top:10px"><summary style="cursor:pointer;font-size:13px;color:var(--accent)">Rate history (${snaps.length} snapshots)</summary>
+          <div style="margin-top:8px">${snaps.map(s => `<div style="font-size:13px;padding:4px 0;border-bottom:1px solid var(--border)">${fmtDateOnly(s.observed_at)}: <strong>$${s.rate ? Number(s.rate).toLocaleString() : '—'}</strong> ${esc(s.unit || '')} &mdash; ${esc(s.source_key || 'manual')}</div>`).join('')}</div>
+        </details>` : ''}
+      </div>`;
+    }).join('');
+
+    const body = `<div class="page-content">
+      <h1>Media Kit</h1>
+      <p style="color:var(--ink-muted);font-size:15px">Sourced, dated audience and rate data. All figures show their source and observation date. Conflicting snapshots are shown together — not merged.</p>
+      ${isDemo ? demoBanner : ''}
+
+      <h2 style="margin-bottom:16px">Audience Snapshots</h2>
+      ${audienceHtml || '<p class="miss">No audience data. <a href="/imports">Import or add manually</a>.</p>'}
+      <div class="mk-snapshot">${audienceHtml}</div>
+
+      <h2 style="margin:28px 0 16px">Rate Card</h2>
+      ${ratesHtml || '<p class="miss">No rate snapshots. Seed data or import a rate card.</p>'}
+
+      <div style="margin-top:24px">
+        <button onclick="window.print()" class="btn btn-outline">Print Media Kit</button>
+      </div>
+    </div>`;
+    res.type('html').send(layout('Media Kit', body, { activeNav: '/media-kit' }));
+  } catch (e) {
+    res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /sources
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/sources', async (req, res) => {
+  try {
+    const rows = await safeQ(`
+      SELECT sp.*,
+        (SELECT COUNT(*) FROM sources s WHERE s.source_policy_id = sp.id) AS item_count
+      FROM source_policies sp ORDER BY sp.display_name`);
+
+    const isDemo = rows.length === 0;
+    const tableRows = rows.map(r => `<tr>
+      <td>${esc(r.display_name)}</td>
+      <td>${esc(r.source_key)}</td>
+      <td>${esc(r.access_method || '—')}</td>
+      <td>${r.enabled ? '<span class="badge badge-verified-advertiser">Enabled</span>' : '<span class="badge badge-disqualified">Disabled</span>'}</td>
+      <td>${r.allows_automated_access ? 'Yes' : 'No'}</td>
+      <td class="num"><a href="/sources">${Number(r.item_count || 0).toLocaleString()}</a></td>
+      <td>${r.base_url ? `<a href="${esc(r.base_url)}" target="_blank" rel="noopener noreferrer">${esc(r.base_url)} ↗</a>` : '<span class="miss">—</span>'}</td>
+      <td><span class="when-chip" title="${esc(r.created_at ? new Date(r.created_at).toISOString() : '')}">${fmtDate(r.created_at)}</span></td>
+    </tr>`).join('');
+
+    const body = `<div class="page-content">
+      <h1>Sources</h1>
+      ${isDemo ? demoBanner : ''}
+      <div class="tbl-wrap">
+        <table class="adv-tbl" aria-label="Source policies">
+          <thead><tr><th>Name</th><th>Key</th><th>Access Method</th><th>Status</th><th>Automated?</th><th>Items</th><th>URL</th><th>Created</th></tr></thead>
+          <tbody>${tableRows || '<tr><td colspan="8"><p class="miss" style="padding:20px">No source policies yet. Run <code>npm run db:seed</code>.</p></td></tr>'}</tbody>
+        </table>
+      </div>
+    </div>`;
+    res.type('html').send(layout('Sources', body, { activeNav: '/sources' }));
+  } catch (e) {
+    res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /imports
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/imports', async (req, res) => {
+  try {
+    const runs = await safeQ(`SELECT * FROM analytics_import_runs ORDER BY started_at DESC LIMIT 20`);
+    const runsHtml = runs.map(r => `<div class="audit-row">
+      <div style="flex:0 0 180px"><span class="when-chip" title="${esc(r.started_at ? new Date(r.started_at).toISOString() : '')}">${fmtDate(r.started_at)}</span></div>
+      <div>
+        <span class="audit-action">${esc(r.kind)}</span>
+        <span class="badge ${r.status === 'DONE' ? 'badge-verified-advertiser' : r.status === 'ERROR' ? 'badge-disqualified' : 'badge-likely-prospect'}" style="margin-left:8px">${esc(r.status)}</span>
+        ${r.row_count != null ? `<span class="audit-meta">&nbsp;&mdash; ${Number(r.row_count).toLocaleString()} rows</span>` : ''}
+        ${r.error ? `<span class="audit-meta" style="color:var(--danger)"> &mdash; ${esc(r.error)}</span>` : ''}
+      </div>
+    </div>`).join('');
+
+    const body = `<div class="page-content">
+      <h1>Imports</h1>
+      <p style="color:var(--ink-muted);font-size:15px">Upload CSV, XLSX, EML, PDF, or image files. Connect GA4, GSC, or Gmail via <a href="/settings">Settings</a>.</p>
+
+      <div class="dash-grid" style="margin-bottom:28px">
+        ${[
+          ['GA4 CSV', 'Upload a GA4 export CSV', '/imports#ga4'],
+          ['Search Console CSV', 'Upload GSC export CSV', '/imports#gsc'],
+          ['Advertiser Spreadsheet', 'Upload advertiser list CSV/XLSX', '/imports#advertisers'],
+          ['Conference Sponsor List', 'Upload sponsor list CSV', '/imports#conferences'],
+          ['Ad Image / PDF', 'Upload ad creative or email', '/imports#ad'],
+        ].map(([title, desc, href]) => `
+          <div class="dash-card">
+            <h3>${esc(title)}</h3>
+            <p style="font-size:15px;color:var(--ink-muted)">${esc(desc)}</p>
+            <form method="POST" action="/api/v1/imports" enctype="multipart/form-data" style="margin-top:10px">
+              <input type="hidden" name="kind" value="${esc(title.toUpperCase().replace(/\s/g,'_'))}">
+              <input type="file" name="file" style="font-size:14px;margin-bottom:10px;display:block" accept=".csv,.xlsx,.eml,.pdf,.jpg,.png,.webp">
+              <button type="submit" class="btn btn-primary" style="font-size:15px">Upload ${esc(title)}</button>
+            </form>
+          </div>`).join('')}
+      </div>
+
+      <h2>Recent import runs</h2>
+      ${runs.length ? runsHtml : '<p class="miss">No imports yet.</p>'}
+    </div>`;
+    res.type('html').send(layout('Imports', body, { activeNav: '/imports' }));
+  } catch (e) {
+    res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /review
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/review', async (req, res) => {
+  try {
+    const items = await safeQ(`
+      SELECT s.id, s.relationship_status, s.verification_status, s.observed_at,
+        s.source_page_url, s.headline, s.confidence, s.created_at,
+        o.display_name AS org_name, o.id AS org_id
+      FROM ad_sightings s
+      LEFT JOIN organizations o ON o.id = s.organization_id
+      WHERE s.verification_status = 'UNVERIFIED'
+      ORDER BY s.created_at DESC LIMIT 100`);
+
+    const isDemo = items.length === 0;
+    const cards = items.map(s => `
+      <div class="dash-card" style="margin-bottom:14px">
+        <div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap">
+          <div>
+            ${whenChip(s.created_at)}
+            <div style="margin-top:8px"><a href="/advertisers/${esc(s.org_id)}" style="font-size:18px;font-weight:700">${esc(s.org_name || '—')}</a></div>
+            <div style="margin-top:6px;display:flex;gap:8px;flex-wrap:wrap">
+              ${statusBadge(s.relationship_status)}
+              <span class="badge badge-research-needed">Unverified</span>
+              ${s.confidence ? `<span class="badge badge-panelist">Conf: ${Math.round(s.confidence * 100)}%</span>` : ''}
+            </div>
+            ${s.headline ? `<p style="font-size:15px;margin:8px 0 0;color:var(--ink-muted)">${esc(s.headline)}</p>` : ''}
+            ${s.source_page_url ? `<p style="margin:6px 0 0"><a href="${esc(s.source_page_url)}" target="_blank" rel="noopener noreferrer" style="font-size:14px">Source ↗</a> &middot; ${fmtDateOnly(s.observed_at)}</p>` : ''}
+          </div>
+          <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
+            <button class="btn btn-outline" onclick="reviewAction('${esc(s.id)}','verify')" style="font-size:15px">Verify</button>
+            <button class="btn btn-ghost" onclick="reviewAction('${esc(s.id)}','reject')" style="font-size:15px">Reject</button>
+            <a href="/ads/${esc(s.id)}" class="btn btn-ghost" style="font-size:15px">Details</a>
+          </div>
+        </div>
+      </div>`).join('');
+
+    const body = `<div class="page-content">
+      <h1>Review Queue</h1>
+      <p style="color:var(--ink-muted);font-size:15px">Verify or reject unverified ad sightings. Do not mark a speaker/panelist as a sponsor without separate evidence.</p>
+      ${isDemo ? demoBanner : ''}
+      ${cards || '<div class="empty-state"><div class="em-icon">&#10003;</div><p>All sightings reviewed. No items pending.</p></div>'}
+      <script>function reviewAction(id,a){fetch('/api/v1/review/'+id+'/'+a,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(r=>r.json()).then(d=>{if(d.ok)location.reload();else alert(d.message||'Error');}).catch(e=>alert(e.message));}</script>
+    </div>`;
+    res.type('html').send(layout('Review', body, { activeNav: '/review' }));
+  } catch (e) {
+    res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /exports
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/exports', async (req, res) => {
+  try {
+    const rows = await safeQ(`SELECT * FROM exports ORDER BY created_at DESC LIMIT 20`);
+    const tableRows = rows.map(r => `<tr>
+      <td><span class="when-chip" title="${esc(r.created_at ? new Date(r.created_at).toISOString() : '')}">${fmtDate(r.created_at)}</span></td>
+      <td>${esc(r.kind)}</td>
+      <td><span class="badge ${r.status === 'DONE' ? 'badge-verified-advertiser' : r.status === 'ERROR' ? 'badge-disqualified' : 'badge-likely-prospect'}">${esc(r.status)}</span></td>
+      <td>${r.status === 'DONE' && r.object_key ? `<a href="/assets/${esc(r.object_key)}" class="btn btn-outline" style="font-size:13px;min-height:36px">Download</a>` : '<span class="miss">—</span>'}</td>
+    </tr>`).join('');
+
+    const body = `<div class="page-content">
+      <h1>Exports</h1>
+      <div class="alert alert-info" style="margin-bottom:20px">Use <strong>Download Everything</strong> in the header to create a full ZIP export. Exports run as background jobs.</div>
+      <div style="margin-bottom:24px">
+        <button id="start-export-btn" class="btn btn-primary" style="font-size:16px" onclick="startExportPage()">Create New Export</button>
+        <span id="export-status-msg" style="margin-left:12px;font-size:15px;color:var(--ink-muted)"></span>
+      </div>
+      <h2>Export history</h2>
+      ${rows.length ? `<div class="tbl-wrap"><table class="adv-tbl"><thead><tr><th>Created</th><th>Kind</th><th>Status</th><th>Download</th></tr></thead><tbody>${tableRows}</tbody></table></div>`
+        : '<p class="miss">No exports yet.</p>'}
+      <script>
+      function startExportPage(){
+        var btn=document.getElementById('start-export-btn');
+        var msg=document.getElementById('export-status-msg');
+        btn.disabled=true; btn.textContent='Creating…'; msg.textContent='';
+        fetch('/api/v1/exports',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind:'DOWNLOAD_EVERYTHING'})})
+          .then(function(r){return r.json();})
+          .then(function(d){msg.textContent=d.message||'Export queued — refresh this page to check status.';btn.textContent='Create New Export';btn.disabled=false;})
+          .catch(function(e){msg.textContent='Error: '+e.message;btn.textContent='Create New Export';btn.disabled=false;});
+      }
+      </script>
+    </div>`;
+    res.type('html').send(layout('Exports', body, { activeNav: '/exports' }));
+  } catch (e) {
+    res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /settings
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/settings', async (req, res) => {
+  try {
+    const conns = await safeQ(`SELECT * FROM analytics_connections ORDER BY kind`);
+    const connHtml = ['GA4', 'GSC', 'GOOGLE_ADS'].map(kind => {
+      const c = conns.find(x => x.kind === kind);
+      return `<div class="settings-card">
+        <h3>${esc(kind)}</h3>
+        <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
+          <span class="badge ${c && c.status === 'CONNECTED' ? 'badge-verified-advertiser' : 'badge-research-needed'}">${c ? esc(c.status) : 'NOT_CONNECTED'}</span>
+          ${c && c.last_import_at ? `<span class="miss" style="font-size:14px">Last import: ${fmtDate(c.last_import_at)}</span>` : ''}
+        </div>
+        <p style="font-size:14px;color:var(--ink-muted);margin-top:8px">Configure via environment variables in <code>.env</code>. See <code>docs/ANALYTICS_CONNECTORS.md</code>.</p>
+        <form method="POST" action="/api/v1/research/jobs" style="margin-top:10px">
+          <input type="hidden" name="type" value="${esc(kind + '_IMPORT')}">
+          <button type="submit" class="btn btn-outline" style="font-size:14px">Trigger ${esc(kind)} import</button>
+        </form>
+      </div>`;
+    }).join('');
+
+    const body = `<div class="page-content">
+      <h1>Settings</h1>
+      <h2 style="margin:0 0 16px">Analytics Connections</h2>
+      ${connHtml}
+      <h2 style="margin:24px 0 16px">Research Jobs</h2>
+      <div class="settings-card">
+        <h3>Run research jobs</h3>
+        <div style="display:flex;flex-wrap:wrap;gap:10px;margin-top:10px">
+          ${['california', 'arizona', 'conferences'].map(j => `
+            <form method="POST" action="/api/v1/research/jobs">
+              <input type="hidden" name="type" value="${j}">
+              <button type="submit" class="btn btn-outline" style="font-size:14px">Research: ${j.charAt(0).toUpperCase() + j.slice(1)}</button>
+            </form>`).join('')}
+        </div>
+        <p style="font-size:14px;color:var(--ink-muted);margin-top:10px">Jobs run according to source policies. Check <a href="/admin/jobs">Admin &rarr; Jobs</a> for status.</p>
+      </div>
+      <h2 style="margin:24px 0 16px">Score Weights</h2>
+      <div class="settings-card">
+        <p style="font-size:15px;color:var(--ink-muted)">Default weights are configured in <code>lib/scoring.js</code>. Admin weight overrides stored in <code>opportunity_scores.factors</code>. See documentation for details.</p>
+      </div>
+    </div>`;
+    res.type('html').send(layout('Settings', body, { activeNav: '/settings' }));
+  } catch (e) {
+    res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /admin/jobs
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/admin/jobs', async (req, res) => {
+  try {
+    const runs = await safeQ(`SELECT * FROM ingestion_runs ORDER BY started_at DESC LIMIT 50`);
+    const isDemo = runs.length === 0;
+    const cards = runs.map(r => `
+      <div class="dash-card" style="margin-bottom:12px">
+        <div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap">
+          <div>
+            ${whenChip(r.started_at)}
+            <div style="margin-top:8px;font-size:18px;font-weight:700">${esc(r.source_key || 'General')}</div>
+            <div style="margin-top:6px;display:flex;gap:8px;flex-wrap:wrap">
+              <span class="badge ${r.status === 'DONE' ? 'badge-verified-advertiser' : r.status === 'ERROR' ? 'badge-disqualified' : r.status === 'RUNNING' ? 'badge-verified-media' : 'badge-panelist'}">${esc(r.status)}</span>
+              ${r.dry_run ? '<span class="badge badge-content-partner">Dry run</span>' : ''}
+            </div>
+            ${r.error ? `<p style="font-size:14px;color:var(--danger);margin:6px 0 0">${esc(r.error)}</p>` : ''}
+            ${r.finished_at ? `<p style="font-size:13px;color:var(--ink-faint);margin:4px 0 0">Finished: ${fmtDate(r.finished_at)}</p>` : ''}
+          </div>
+        </div>
+      </div>`).join('');
+
+    const body = `<div class="page-content">
+      <h1>Admin &mdash; Jobs</h1>
+      ${isDemo ? demoBanner : ''}
+      ${cards || '<div class="empty-state"><p>No job runs recorded yet.</p></div>'}
+    </div>`;
+    res.type('html').send(layout('Admin: Jobs', body, { activeNav: '/admin/jobs' }));
+  } catch (e) {
+    res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+  }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /admin/audit
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/admin/audit', async (req, res) => {
+  try {
+    const rows = await safeQ(`SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 200`);
+    const isDemo = rows.length === 0;
+    const rowsHtml = rows.map(r => `
+      <div class="audit-row">
+        <div style="flex:0 0 220px">${whenChip(r.created_at)}</div>
+        <div>
+          <span class="audit-action">${esc(r.action)}</span>
+          ${r.entity_table ? `<span class="audit-meta">&nbsp;&mdash; ${esc(r.entity_table)}</span>` : ''}
+          ${r.entity_id ? `<span class="audit-meta"> / ${esc(String(r.entity_id).slice(0,8))}…</span>` : ''}
+          <span class="audit-meta">&nbsp;&mdash; by ${esc(r.actor || 'system')}</span>
+        </div>
+      </div>`).join('');
+
+    const body = `<div class="page-content">
+      <h1>Admin &mdash; Audit Log</h1>
+      ${isDemo ? demoBanner : ''}
+      ${rowsHtml || '<div class="empty-state"><p>No audit entries yet.</p></div>'}
+    </div>`;
+    res.type('html').send(layout('Admin: Audit', body, { activeNav: '/admin/audit' }));
+  } catch (e) {
+    res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+  }
+});
+
+module.exports = router;
diff --git a/src/search/provider.js b/src/search/provider.js
new file mode 100644
index 0000000..d4aa533
--- /dev/null
+++ b/src/search/provider.js
@@ -0,0 +1,127 @@
+'use strict';
+/**
+ * SearchProvider interface (spec §9, §14).
+ *
+ * getSearchProvider(name) returns a provider for one of:
+ *   google_cse | brave | bing | serper | manual
+ * Default comes from process.env.SEARCH_PROVIDER (falls back to 'manual').
+ *
+ * Real API providers are thin stubs: they read their env key and throw a
+ * friendly "configure X" error if it is absent — the app must WORK with the
+ * manual provider when no key is configured (§9: "Do not scrape Google result
+ * HTML. The app must work with manual search links when no API key is
+ * configured.").
+ *
+ * The `manual` provider returns one-click SEARCH URLS (google/bing web search)
+ * and NEVER fetches. buildLinkedInSearchUrls() returns Google/Bing search URLs
+ * using site:linkedin.com/in and site:linkedin.com/company templates — these
+ * are HUMAN-CLICK URLs only; the app must NEVER fetch linkedin.com (§6.3/§6.4).
+ */
+
+const T = require('../../lib/types');
+const queries = require('./queries');
+
+function googleSearchUrl(query) {
+  return `https://www.google.com/search?q=${encodeURIComponent(query)}`;
+}
+function bingSearchUrl(query) {
+  return `https://www.bing.com/search?q=${encodeURIComponent(query)}`;
+}
+
+/**
+ * Manual provider — never fetches. search() returns click-URLs the human opens.
+ */
+const manualProvider = {
+  name: 'manual',
+  fetches: false,
+  async search(query, _opts = {}) {
+    return {
+      provider: 'manual',
+      query,
+      urls: {
+        google: googleSearchUrl(query),
+        bing: bingSearchUrl(query),
+      },
+      note: 'Manual mode: open one of these URLs in a browser. The app does not fetch results.',
+    };
+  },
+};
+
+/** Factory for the API-key-gated stub providers. */
+function apiStub(name, envKey, humanLabel) {
+  return {
+    name,
+    fetches: true,
+    async search(_query, _opts = {}) {
+      if (!process.env[envKey]) {
+        throw new Error(
+          `Search provider "${name}" is not configured — set ${envKey} to enable it, or use SEARCH_PROVIDER=manual (§9).`
+        );
+      }
+      // NOTE: real HTTP calls to the provider's OFFICIAL API go here. They must
+      // use the official search API (§6.20) — never scrape result HTML — and any
+      // outbound fetch must go through lib/compliance/fetch-guard.safeFetch.
+      throw new Error(
+        `Search provider "${name}" (${humanLabel}) API call not yet implemented — key present but live query wiring pending.`
+      );
+    },
+  };
+}
+
+const PROVIDERS = {
+  manual: () => manualProvider,
+  google_cse: () => apiStub('google_cse', 'GOOGLE_CSE_API_KEY', 'Google Programmable Search'),
+  brave: () => apiStub('brave', 'BRAVE_SEARCH_API_KEY', 'Brave Search'),
+  bing: () => apiStub('bing', 'BING_SEARCH_API_KEY', 'Bing Web Search'),
+  serper: () => apiStub('serper', 'SERPER_API_KEY', 'Serper.dev'),
+};
+
+/** getSearchProvider(name) — resolves name → provider, defaulting via env. */
+function getSearchProvider(name) {
+  const chosen = name || process.env.SEARCH_PROVIDER || 'manual';
+  if (!T.SEARCH_PROVIDERS.includes(chosen)) {
+    throw new Error(
+      `getSearchProvider: unknown provider "${chosen}" — must be one of ${T.SEARCH_PROVIDERS.join(', ')}`
+    );
+  }
+  return PROVIDERS[chosen]();
+}
+
+/**
+ * buildLinkedInSearchUrls(org) — returns Google/Bing SEARCH URLs (human-click
+ * only) for the three §14 LinkedIn templates. The app NEVER fetches linkedin.com;
+ * these open a normal browser tab for a person to inspect the public profile.
+ *
+ * org: { companyName, companyDomain }
+ */
+function buildLinkedInSearchUrls(org = {}) {
+  const companyQ = queries.linkedinCompanyQuery(org);
+  const inCaQ = queries.linkedinInCaliforniaQuery(org);
+  const inAzQ = queries.linkedinInArizonaQuery(org);
+  return {
+    company: { query: companyQ, google: googleSearchUrl(companyQ), bing: bingSearchUrl(companyQ) },
+    peopleCalifornia: { query: inCaQ, google: googleSearchUrl(inCaQ), bing: bingSearchUrl(inCaQ) },
+    peopleArizona: { query: inAzQ, google: googleSearchUrl(inAzQ), bing: bingSearchUrl(inAzQ) },
+    note: 'Human-click search URLs only. The application must NEVER fetch linkedin.com (§6.3/§6.4).',
+  };
+}
+
+/**
+ * openLinkedInProfileUrl(url) — passthrough. Returns the URL for a human to
+ * open in a normal browser (the "Open LinkedIn" button, §14). Deliberately
+ * does NOT fetch; it only validates the shape and hands the URL back.
+ */
+function openLinkedInProfileUrl(url) {
+  if (!url || typeof url !== 'string') {
+    throw new Error('openLinkedInProfileUrl: a url string is required');
+  }
+  return url; // for a human to open — never fetched by the app
+}
+
+module.exports = {
+  getSearchProvider,
+  buildLinkedInSearchUrls,
+  openLinkedInProfileUrl,
+  googleSearchUrl,
+  bingSearchUrl,
+};
diff --git a/src/search/queries.js b/src/search/queries.js
new file mode 100644
index 0000000..6649d14
--- /dev/null
+++ b/src/search/queries.js
@@ -0,0 +1,90 @@
+'use strict';
+/**
+ * §14 search-query templates.
+ *
+ * Each function takes { companyName, companyDomain } and returns the exact
+ * query strings listed in §14. These feed a permitted search-provider API OR
+ * become one-click manual search URLs. The two site:linkedin.com/in templates
+ * and the site:linkedin.com/company template produce HUMAN-CLICK search queries
+ * only — the app NEVER fetches linkedin.com (§6.3/§6.4, §14).
+ */
+
+function q(companyName) {
+  return `"${companyName}"`;
+}
+
+/** All §14 templates, in order, as a flat array of query strings. */
+function allQueries({ companyName, companyDomain } = {}) {
+  const name = companyName || '';
+  const domain = (companyDomain || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '');
+  const c = q(name);
+  const out = [
+    `${c} commercial real estate California marketing`,
+    `${c} Arizona commercial real estate sponsor`,
+    `${c} media contact`,
+    `${c} press release marketing director`,
+    `${c} partnerships events`,
+  ];
+  if (domain) {
+    out.push(`site:${domain} contact marketing`);
+    out.push(`site:${domain} newsroom "media contact"`);
+  }
+  out.push(`site:linkedin.com/company ${c}`);
+  out.push(`site:linkedin.com/in (marketing OR communications OR partnerships OR "business development") ${c} California`);
+  out.push(`site:linkedin.com/in (marketing OR communications OR partnerships OR "business development") ${c} Arizona`);
+  out.push(`${c} sponsor OR exhibitor OR advertising`);
+  return out;
+}
+
+// --- Individual named templates (for callers that want them one at a time) ---
+
+function marketingQuery({ companyName }) {
+  return `${q(companyName)} commercial real estate California marketing`;
+}
+function arizonaSponsorQuery({ companyName }) {
+  return `${q(companyName)} Arizona commercial real estate sponsor`;
+}
+function mediaContactQuery({ companyName }) {
+  return `${q(companyName)} media contact`;
+}
+function pressReleaseQuery({ companyName }) {
+  return `${q(companyName)} press release marketing director`;
+}
+function partnershipsQuery({ companyName }) {
+  return `${q(companyName)} partnerships events`;
+}
+function siteContactQuery({ companyDomain }) {
+  const d = (companyDomain || '').replace(/^www\./, '');
+  return `site:${d} contact marketing`;
+}
+function siteNewsroomQuery({ companyDomain }) {
+  const d = (companyDomain || '').replace(/^www\./, '');
+  return `site:${d} newsroom "media contact"`;
+}
+function linkedinCompanyQuery({ companyName }) {
+  return `site:linkedin.com/company ${q(companyName)}`;
+}
+function linkedinInCaliforniaQuery({ companyName }) {
+  return `site:linkedin.com/in (marketing OR communications OR partnerships OR "business development") ${q(companyName)} California`;
+}
+function linkedinInArizonaQuery({ companyName }) {
+  return `site:linkedin.com/in (marketing OR communications OR partnerships OR "business development") ${q(companyName)} Arizona`;
+}
+function sponsorExhibitorQuery({ companyName }) {
+  return `${q(companyName)} sponsor OR exhibitor OR advertising`;
+}
+
+module.exports = {
+  allQueries,
+  marketingQuery,
+  arizonaSponsorQuery,
+  mediaContactQuery,
+  pressReleaseQuery,
+  partnershipsQuery,
+  siteContactQuery,
+  siteNewsroomQuery,
+  linkedinCompanyQuery,
+  linkedinInCaliforniaQuery,
+  linkedinInArizonaQuery,
+  sponsorExhibitorQuery,
+};
diff --git a/test/classification.test.js b/test/classification.test.js
new file mode 100644
index 0000000..44affa6
--- /dev/null
+++ b/test/classification.test.js
@@ -0,0 +1,84 @@
+'use strict';
+/**
+ * Tests for the classification guard (spec §6.16, §2, §21).
+ * The module is being written concurrently by a teammate; import it
+ * defensively and t.skip() every case if it isn't present yet so
+ * `node --test` never hard-crashes.
+ *
+ * Rule under test: a SPEAKER_OR_PANELIST_ONLY relationship may NOT be promoted
+ * to a verified sponsor/advertiser status without separate sponsor evidence
+ * (§6.16 "Do not treat a panelist as a sponsor without sponsor evidence"; the
+ * CoStar content-partner seed in §21 must not be mislabeled either).
+ */
+
+const test = require('node:test');
+const assert = require('node:assert');
+
+let classification;
+try {
+  classification = require('../lib/classification');
+} catch (_e) {
+  classification = null;
+}
+
+test('promoting panelist -> verified sponsor WITHOUT evidence throws', (t) => {
+  if (!classification || !classification.assertNotPanelistMislabeledAsSponsor) {
+    return t.skip('classification not present yet');
+  }
+  assert.throws(() =>
+    classification.assertNotPanelistMislabeledAsSponsor(
+      'SPEAKER_OR_PANELIST_ONLY',
+      'VERIFIED_CONFERENCE_SPONSOR',
+      false // hasSponsorEvidence
+    )
+  );
+});
+
+test('promoting panelist -> verified sponsor WITH evidence does not throw', (t) => {
+  if (!classification || !classification.assertNotPanelistMislabeledAsSponsor) {
+    return t.skip('classification not present yet');
+  }
+  assert.doesNotThrow(() =>
+    classification.assertNotPanelistMislabeledAsSponsor(
+      'SPEAKER_OR_PANELIST_ONLY',
+      'VERIFIED_CONFERENCE_SPONSOR',
+      true // hasSponsorEvidence
+    )
+  );
+});
+
+test('promoting panelist -> verified ADVERTISER without evidence throws', (t) => {
+  if (!classification || !classification.assertNotPanelistMislabeledAsSponsor) {
+    return t.skip('classification not present yet');
+  }
+  assert.throws(() =>
+    classification.assertNotPanelistMislabeledAsSponsor(
+      'SPEAKER_OR_PANELIST_ONLY',
+      'VERIFIED_ADVERTISER',
+      false
+    )
+  );
+});
+
+test('a non-panelist starting status is not blocked by the guard', (t) => {
+  if (!classification || !classification.assertNotPanelistMislabeledAsSponsor) {
+    return t.skip('classification not present yet');
+  }
+  // A RESEARCH_NEEDED -> VERIFIED_CONFERENCE_SPONSOR transition is out of scope
+  // of the panelist rule; it should not throw for lack of sponsor evidence here.
+  assert.doesNotThrow(() =>
+    classification.assertNotPanelistMislabeledAsSponsor(
+      'RESEARCH_NEEDED',
+      'VERIFIED_CONFERENCE_SPONSOR',
+      false
+    )
+  );
+});
+
+test('statusLabel returns a plain-English label', (t) => {
+  if (!classification || !classification.statusLabel) {
+    return t.skip('classification.statusLabel not present yet');
+  }
+  assert.strictEqual(typeof classification.statusLabel('VERIFIED_ADVERTISER'), 'string');
+  assert.ok(classification.statusLabel('VERIFIED_ADVERTISER').length > 0);
+});
diff --git a/test/compliance.test.js b/test/compliance.test.js
new file mode 100644
index 0000000..29719fb
--- /dev/null
+++ b/test/compliance.test.js
@@ -0,0 +1,144 @@
+'use strict';
+/**
+ * Tests for the compliance guards (spec §6, §32).
+ * Modules under test are being written concurrently by teammates, so each is
+ * imported DEFENSIVELY and the relevant test t.skip()s when the module is
+ * absent — `node --test` must never hard-crash on a missing file.
+ *
+ * Pure-function level only: no DB, no live network. The fetch-guard cases we
+ * assert (LinkedIn hosts, literal private/metadata/loopback IPs) all reject
+ * BEFORE any DNS resolution, so these stay offline and deterministic.
+ */
+
+const test = require('node:test');
+const assert = require('node:assert');
+
+function tryRequire(p) {
+  try {
+    return require(p);
+  } catch (_e) {
+    return null;
+  }
+}
+
+const fetchGuard = tryRequire('../lib/compliance/fetch-guard');
+const noInferredEmail = tryRequire('../lib/compliance/no-inferred-email');
+const sourcePolicy = tryRequire('../lib/compliance/source-policy');
+
+// --------------------------------------------------------------------------
+// fetch-guard — assertFetchAllowed / isBlockedIp (§6.4, §32)
+// --------------------------------------------------------------------------
+
+test('assertFetchAllowed throws for a LinkedIn profile URL', async (t) => {
+  if (!fetchGuard || !fetchGuard.assertFetchAllowed) return t.skip('fetch-guard not present yet');
+  await assert.rejects(
+    () => fetchGuard.assertFetchAllowed('https://www.linkedin.com/in/x'),
+    /linkedin/i
+  );
+});
+
+test('assertFetchAllowed throws for the cloud metadata IP 169.254.169.254', async (t) => {
+  if (!fetchGuard || !fetchGuard.assertFetchAllowed) return t.skip('fetch-guard not present yet');
+  await assert.rejects(() => fetchGuard.assertFetchAllowed('http://169.254.169.254/'));
+});
+
+test('assertFetchAllowed throws for loopback 127.0.0.1', async (t) => {
+  if (!fetchGuard || !fetchGuard.assertFetchAllowed) return t.skip('fetch-guard not present yet');
+  await assert.rejects(() => fetchGuard.assertFetchAllowed('http://127.0.0.1/'));
+});
+
+test('isBlockedIp flags private/loopback/metadata ranges, allows a public IP', (t) => {
+  if (!fetchGuard || !fetchGuard.isBlockedIp) return t.skip('isBlockedIp not present yet');
+  const { isBlockedIp } = fetchGuard;
+  assert.strictEqual(isBlockedIp('169.254.169.254'), true); // metadata / link-local
+  assert.strictEqual(isBlockedIp('127.0.0.1'), true); // loopback
+  assert.strictEqual(isBlockedIp('10.0.0.5'), true); // private
+  assert.strictEqual(isBlockedIp('192.168.1.1'), true); // private
+  assert.strictEqual(isBlockedIp('::1'), true); // IPv6 loopback
+  assert.strictEqual(isBlockedIp('8.8.8.8'), false); // public — allowed
+});
+
+// --------------------------------------------------------------------------
+// source-policy — validateSourcePolicy (§6.2)
+// --------------------------------------------------------------------------
+
+test('validateSourcePolicy rejects an automated costar.com policy', (t) => {
+  if (!sourcePolicy || !sourcePolicy.validateSourcePolicy) return t.skip('source-policy not present yet');
+  const result = sourcePolicy.validateSourcePolicy({
+    sourceKey: 'costar',
+    displayName: 'CoStar',
+    baseUrl: 'https://www.costar.com',
+    accessMethod: 'first_party_public_web',
+    allowsAutomatedAccess: true,
+    enabled: true,
+  });
+  assert.strictEqual(result.valid, false);
+  assert.ok(Array.isArray(result.errors) && result.errors.length > 0);
+});
+
+test('validateSourcePolicy accepts a permitted first-party public source', (t) => {
+  if (!sourcePolicy || !sourcePolicy.validateSourcePolicy) return t.skip('source-policy not present yet');
+  const result = sourcePolicy.validateSourcePolicy({
+    sourceKey: 'rentv',
+    displayName: 'RENTV public pages',
+    baseUrl: 'https://www.rentv.com',
+    accessMethod: 'first_party_public_web',
+    allowsAutomatedAccess: true,
+    maxRequestsPerMinute: 6,
+    enabled: true,
+  });
+  assert.strictEqual(result.valid, true, JSON.stringify(result.errors));
+});
+
+test('validateSourcePolicy rejects an automated linkedin.com policy', (t) => {
+  if (!sourcePolicy || !sourcePolicy.validateSourcePolicy) return t.skip('source-policy not present yet');
+  const result = sourcePolicy.validateSourcePolicy({
+    sourceKey: 'li',
+    displayName: 'LinkedIn',
+    baseUrl: 'https://www.linkedin.com',
+    accessMethod: 'first_party_public_web',
+    allowsAutomatedAccess: true,
+    enabled: true,
+  });
+  assert.strictEqual(result.valid, false);
+});
+
+// --------------------------------------------------------------------------
+// no-inferred-email — assertContactEvidence (§6.8, §6.9, §6.11)
+// --------------------------------------------------------------------------
+
+test('assertContactEvidence throws for an empty contact', (t) => {
+  if (!noInferredEmail || !noInferredEmail.assertContactEvidence) return t.skip('no-inferred-email not present yet');
+  assert.throws(() => noInferredEmail.assertContactEvidence({}));
+});
+
+test('assertContactEvidence throws without source_evidence_id', (t) => {
+  if (!noInferredEmail || !noInferredEmail.assertContactEvidence) return t.skip('no-inferred-email not present yet');
+  assert.throws(() => noInferredEmail.assertContactEvidence({ explicitly_public: true }));
+});
+
+test('assertContactEvidence throws when not explicitly_public', (t) => {
+  if (!noInferredEmail || !noInferredEmail.assertContactEvidence) return t.skip('no-inferred-email not present yet');
+  assert.throws(() => noInferredEmail.assertContactEvidence({ source_evidence_id: 'x' }));
+});
+
+test('assertContactEvidence passes for an explicitly-public, evidenced contact', (t) => {
+  if (!noInferredEmail || !noInferredEmail.assertContactEvidence) return t.skip('no-inferred-email not present yet');
+  assert.strictEqual(
+    noInferredEmail.assertContactEvidence({ source_evidence_id: 'x', explicitly_public: true }),
+    true
+  );
+});
+
+test('looksLikePatternEmail flags a first.last@domain permutation', (t) => {
+  if (!noInferredEmail || !noInferredEmail.looksLikePatternEmail) return t.skip('looksLikePatternEmail not present yet');
+  assert.strictEqual(
+    noInferredEmail.looksLikePatternEmail('jane.smith@acme.com', 'Jane Smith', 'acme.com'),
+    true
+  );
+  // A genuinely-published generic mailbox is not a name permutation.
+  assert.strictEqual(
+    noInferredEmail.looksLikePatternEmail('media@acme.com', 'Jane Smith', 'acme.com'),
+    false
+  );
+});
diff --git a/test/export-filter.test.js b/test/export-filter.test.js
new file mode 100644
index 0000000..4f86fd7
--- /dev/null
+++ b/test/export-filter.test.js
@@ -0,0 +1,103 @@
+'use strict';
+/**
+ * Tests for rights-aware export filtering (spec §28, §6.14, §6.18).
+ * The module is being written concurrently by a teammate; import it
+ * defensively and t.skip() every case if it isn't present yet so
+ * `node --test` never hard-crashes.
+ *
+ * Rule under test: exports must honor suppression, do-not-contact, private
+ * notes, and source rights — those rows/fields must be filtered OUT of any
+ * exported dataset (§28 "Exports must honor suppression, do-not-contact,
+ * private notes, source rights").
+ */
+
+const test = require('node:test');
+const assert = require('node:assert');
+
+let rights;
+try {
+  rights = require('../src/export/rights');
+} catch (_e) {
+  rights = null;
+}
+
+test('applyExportRights excludes do_not_contact contacts', (t) => {
+  if (!rights || !rights.applyExportRights) return t.skip('export/rights not present yet');
+  const rows = [
+    { id: 1, value: 'ok@acme.com', do_not_contact: false, export_allowed: true },
+    { id: 2, value: 'stop@acme.com', do_not_contact: true, export_allowed: true },
+  ];
+  const out = rights.applyExportRights(rows, 'contacts');
+  const ids = out.map((r) => r.id);
+  assert.ok(ids.includes(1));
+  assert.ok(!ids.includes(2), 'do_not_contact contact must be excluded');
+});
+
+test('applyExportRights excludes export_allowed=false contacts', (t) => {
+  if (!rights || !rights.applyExportRights) return t.skip('export/rights not present yet');
+  const rows = [
+    { id: 1, value: 'ok@acme.com', do_not_contact: false, export_allowed: true },
+    { id: 3, value: 'internal@acme.com', do_not_contact: false, export_allowed: false },
+  ];
+  const out = rights.applyExportRights(rows, 'contacts');
+  const ids = out.map((r) => r.id);
+  assert.ok(ids.includes(1));
+  assert.ok(!ids.includes(3), 'export_allowed=false contact must be excluded');
+});
+
+test('applyExportRights excludes private notes', (t) => {
+  if (!rights || !rights.applyExportRights) return t.skip('export/rights not present yet');
+  const rows = [
+    { id: 10, body: 'public note', is_private: false },
+    { id: 11, body: 'private note', is_private: true },
+  ];
+  const out = rights.applyExportRights(rows, 'notes');
+  const ids = out.map((r) => r.id);
+  assert.ok(ids.includes(10));
+  assert.ok(!ids.includes(11), 'is_private note must be excluded from export');
+});
+
+test('applyExportRights excludes rows for a suppressed organization', (t) => {
+  if (!rights || !rights.applyExportRights) return t.skip('export/rights not present yet');
+  const rows = [
+    { id: 'org-keep', name: 'Keep Co' },
+    { id: 'org-suppressed', name: 'Suppressed Co' },
+  ];
+  // Suppression is driven by a suppression-request set passed in ctx (§6.14).
+  // filterOrganizations matches the org's own id against suppressedOrgIds.
+  const ctx = { suppressedOrgIds: new Set(['org-suppressed']) };
+  const out = rights.applyExportRights(rows, 'organizations', ctx);
+  const ids = out.map((r) => r.id);
+  assert.ok(ids.includes('org-keep'));
+  assert.ok(!ids.includes('org-suppressed'), 'suppressed organization must be excluded from export');
+});
+
+test('applyExportRights classifies internal-only creatives as link-only (bytes omitted)', (t) => {
+  if (!rights || !rights.applyExportRights) return t.skip('export/rights not present yet');
+  const rows = [
+    { id: 30, organization_id: 'a', file_name: 'ad.png', rights_status: 'EXPORT_ALLOWED' },
+    { id: 31, organization_id: 'a', file_name: 'internal.png', rights_status: 'INTERNAL_EVIDENCE_ONLY' },
+  ];
+  const out = rights.applyExportRights(rows, 'creative_assets', {});
+  const byId = Object.fromEntries(out.map((r) => [r.id, r]));
+  // Export-allowed → file bytes bundled; internal-only → metadata kept but bytes omitted.
+  assert.strictEqual(byId[30]._exportClass, 'include');
+  assert.strictEqual(byId[31]._exportClass, 'link_only');
+  assert.notStrictEqual(byId[31]._exportClass, 'include', 'internal-only bytes must not be exported');
+});
+
+test('classifyAssetForExport never exports UNKNOWN-rights image bytes', (t) => {
+  if (!rights || !rights.classifyAssetForExport) return t.skip('classifyAssetForExport not present yet');
+  assert.strictEqual(rights.classifyAssetForExport({ rights_status: 'EXPORT_ALLOWED' }), 'include');
+  assert.notStrictEqual(rights.classifyAssetForExport({ rights_status: 'UNKNOWN' }), 'include');
+  assert.notStrictEqual(rights.classifyAssetForExport({ rights_status: 'INTERNAL_EVIDENCE_ONLY' }), 'include');
+});
+
+test('applyExportRights returns an array and does not mutate the input', (t) => {
+  if (!rights || !rights.applyExportRights) return t.skip('export/rights not present yet');
+  const rows = [{ id: 1, do_not_contact: false, export_allowed: true }];
+  const copy = JSON.parse(JSON.stringify(rows));
+  const out = rights.applyExportRights(rows, 'contacts', {});
+  assert.ok(Array.isArray(out));
+  assert.deepStrictEqual(rows, copy, 'input rows must not be mutated');
+});
diff --git a/test/scoring.test.js b/test/scoring.test.js
new file mode 100644
index 0000000..c4e4bb6
--- /dev/null
+++ b/test/scoring.test.js
@@ -0,0 +1,127 @@
+'use strict';
+/**
+ * Tests for the Advertising Opportunity Score (spec §13).
+ * Pure functions only — no DB, no network. Runs under the built-in
+ * `node --test` runner (no jest/vitest/mocha).
+ */
+
+const test = require('node:test');
+const assert = require('node:assert');
+
+const {
+  calculateAdvertiserOpportunityScore,
+  explainScore,
+  DEFAULT_WEIGHTS,
+  clampScore,
+  FACTOR_KEYS,
+} = require('../lib/scoring');
+
+// The ten factors the §13 formula weights.
+const FULL_INPUT = {
+  verifiedAdvertising: 100,
+  verifiedConferenceSpendSignal: 100,
+  recency: 100,
+  repeatActivity: 100,
+  californiaFit: 100,
+  arizonaFit: 100,
+  categoryFit: 100,
+  rentvAudienceFit: 100,
+  contactCompleteness: 100,
+  evidenceQuality: 100,
+};
+
+test('all-100 input scores exactly 100 (weights sum to 1.0)', () => {
+  assert.strictEqual(calculateAdvertiserOpportunityScore(FULL_INPUT), 100);
+});
+
+test('all-zero input scores exactly 0', () => {
+  const zeroed = Object.fromEntries(FACTOR_KEYS.map((k) => [k, 0]));
+  assert.strictEqual(calculateAdvertiserOpportunityScore(zeroed), 0);
+});
+
+test('known input maps to the known §13 output', () => {
+  // Verify the exact §13 spec formula by computing it independently.
+  const input = {
+    verifiedAdvertising: 80, // *0.22 = 17.6
+    verifiedConferenceSpendSignal: 60, // *0.15 = 9.0
+    recency: 90, // *0.12 = 10.8
+    repeatActivity: 50, // *0.10 = 5.0
+    californiaFit: 100, // *0.10 = 10.0
+    arizonaFit: 0, // *0.05 = 0.0
+    categoryFit: 70, // *0.08 = 5.6
+    rentvAudienceFit: 40, // *0.08 = 3.2
+    contactCompleteness: 100, // *0.04 = 4.0
+    evidenceQuality: 90, // *0.06 = 5.4
+  };
+  // sum = 70.6 → round → 71
+  assert.strictEqual(calculateAdvertiserOpportunityScore(input), 71);
+});
+
+test('DEFAULT_WEIGHTS sum to ~1.0', () => {
+  const sum = Object.values(DEFAULT_WEIGHTS).reduce((a, b) => a + b, 0);
+  assert.ok(Math.abs(sum - 1.0) < 1e-9, `weights sum to ${sum}, expected 1.0`);
+});
+
+test('FACTOR_KEYS covers every weighted factor (and nothing else)', () => {
+  assert.strictEqual(FACTOR_KEYS.length, 10);
+  assert.deepStrictEqual([...FACTOR_KEYS].sort(), Object.keys(DEFAULT_WEIGHTS).sort());
+});
+
+test('clampScore bounds values into [0,100]', () => {
+  assert.strictEqual(clampScore(-50), 0);
+  assert.strictEqual(clampScore(0), 0);
+  assert.strictEqual(clampScore(50), 50);
+  assert.strictEqual(clampScore(100), 100);
+  assert.strictEqual(clampScore(150), 100);
+  assert.strictEqual(clampScore(NaN), 0);
+  assert.strictEqual(clampScore(undefined), 0);
+});
+
+test('out-of-range factor inputs are clamped before weighting', () => {
+  const over = Object.fromEntries(FACTOR_KEYS.map((k) => [k, 999]));
+  assert.strictEqual(calculateAdvertiserOpportunityScore(over), 100);
+  const under = Object.fromEntries(FACTOR_KEYS.map((k) => [k, -999]));
+  assert.strictEqual(calculateAdvertiserOpportunityScore(under), 0);
+});
+
+test('admin weight overrides are applied', () => {
+  // Put ALL weight on one factor; only that factor should matter.
+  const weights = Object.fromEntries(FACTOR_KEYS.map((k) => [k, 0]));
+  weights.recency = 1.0;
+  const input = Object.fromEntries(FACTOR_KEYS.map((k) => [k, 0]));
+  input.recency = 77;
+  assert.strictEqual(calculateAdvertiserOpportunityScore(input, weights), 77);
+});
+
+test('explainScore returns a per-factor contribution for "Show Me Why"', () => {
+  const out = explainScore(FULL_INPUT);
+  assert.strictEqual(out.score, 100);
+  assert.strictEqual(out.factors.length, 10);
+  for (const f of out.factors) {
+    assert.ok(FACTOR_KEYS.includes(f.factor));
+    assert.strictEqual(typeof f.value, 'number');
+    assert.strictEqual(typeof f.weight, 'number');
+    assert.strictEqual(typeof f.contribution, 'number');
+  }
+});
+
+test('explainScore contributions sum to the score (±1 for rounding)', () => {
+  const input = {
+    verifiedAdvertising: 83,
+    verifiedConferenceSpendSignal: 41,
+    recency: 77,
+    repeatActivity: 12,
+    californiaFit: 95,
+    arizonaFit: 8,
+    categoryFit: 66,
+    rentvAudienceFit: 54,
+    contactCompleteness: 30,
+    evidenceQuality: 71,
+  };
+  const out = explainScore(input);
+  const sum = out.factors.reduce((a, f) => a + f.contribution, 0);
+  assert.ok(
+    Math.abs(sum - out.score) <= 1,
+    `contributions ${sum} should be within 1 of score ${out.score}`
+  );
+});
diff --git a/test/types.test.js b/test/types.test.js
new file mode 100644
index 0000000..26cbf5e
--- /dev/null
+++ b/test/types.test.js
@@ -0,0 +1,99 @@
+'use strict';
+/**
+ * Tests for the shared contract vocabulary (spec §2, §8, §14).
+ * Pure functions + frozen constant lists — no DB, no network.
+ */
+
+const test = require('node:test');
+const assert = require('node:assert');
+
+const {
+  RELATIONSHIP_STATUS,
+  VERIFIED_STATUSES,
+  STATUS_LABELS,
+  ADVERTISER_CATEGORIES,
+  CONTACT_ROLE_PRIORITY,
+  LINKEDIN_BLOCKED_HOSTS,
+  normalizeName,
+  isVerified,
+} = require('../lib/types');
+
+test('normalizeName strips corporate suffixes and articles', () => {
+  assert.strictEqual(normalizeName('The Rockefeller Group, Inc.'), 'rockefeller');
+});
+
+test('normalizeName is stable across punctuation/casing variants', () => {
+  const a = normalizeName('Hanley Investment Group, LLC');
+  const b = normalizeName('hanley investment');
+  assert.strictEqual(a, b);
+  assert.strictEqual(a, 'hanley investment');
+});
+
+test('normalizeName handles empty / null input safely', () => {
+  assert.strictEqual(normalizeName(''), '');
+  assert.strictEqual(normalizeName(null), '');
+  assert.strictEqual(normalizeName(undefined), '');
+});
+
+test('isVerified is true only for VERIFIED_* relationship statuses', () => {
+  assert.strictEqual(isVerified('VERIFIED_ADVERTISER'), true);
+  assert.strictEqual(isVerified('VERIFIED_CONFERENCE_SPONSOR'), true);
+  assert.strictEqual(isVerified('VERIFIED_EXHIBITOR'), true);
+  assert.strictEqual(isVerified('VERIFIED_MEDIA_PARTNER'), true);
+  assert.strictEqual(isVerified('VERIFIED_CONTENT_PARTNER'), true);
+});
+
+test('isVerified is false for panelist / prospect / research / disqualified', () => {
+  assert.strictEqual(isVerified('SPEAKER_OR_PANELIST_ONLY'), false);
+  assert.strictEqual(isVerified('PAST_ADVERTISER'), false);
+  assert.strictEqual(isVerified('LIKELY_PROSPECT'), false);
+  assert.strictEqual(isVerified('RESEARCH_NEEDED'), false);
+  assert.strictEqual(isVerified('DISQUALIFIED'), false);
+  assert.strictEqual(isVerified('NOT_A_STATUS'), false);
+});
+
+test('every VERIFIED_STATUS is a member of RELATIONSHIP_STATUS', () => {
+  for (const s of VERIFIED_STATUSES) {
+    assert.ok(RELATIONSHIP_STATUS.includes(s), `${s} not in RELATIONSHIP_STATUS`);
+  }
+});
+
+test('STATUS_LABELS covers every RELATIONSHIP_STATUS', () => {
+  for (const s of RELATIONSHIP_STATUS) {
+    assert.ok(
+      typeof STATUS_LABELS[s] === 'string' && STATUS_LABELS[s].length > 0,
+      `missing plain-English label for ${s}`
+    );
+  }
+  // no orphan labels for statuses that don't exist
+  for (const k of Object.keys(STATUS_LABELS)) {
+    assert.ok(RELATIONSHIP_STATUS.includes(k), `orphan label ${k}`);
+  }
+});
+
+test('ADVERTISER_CATEGORIES has the full §8 taxonomy', () => {
+  // §8 lists 24 controlled categories.
+  assert.strictEqual(ADVERTISER_CATEGORIES.length, 24);
+  assert.ok(ADVERTISER_CATEGORIES.includes('Brokerage and investment sales'));
+  assert.ok(ADVERTISER_CATEGORIES.includes('Other CRE service'));
+  // no duplicates
+  assert.strictEqual(new Set(ADVERTISER_CATEGORIES).size, ADVERTISER_CATEGORIES.length);
+});
+
+test('CONTACT_ROLE_PRIORITY ranks marketing leadership highest (§14)', () => {
+  assert.strictEqual(CONTACT_ROLE_PRIORITY[0], 'CHIEF_MARKETING_OFFICER');
+  assert.strictEqual(CONTACT_ROLE_PRIORITY.length, 10);
+  assert.strictEqual(new Set(CONTACT_ROLE_PRIORITY).size, CONTACT_ROLE_PRIORITY.length);
+});
+
+test('LINKEDIN_BLOCKED_HOSTS lists the LinkedIn hosts (§6.4)', () => {
+  assert.ok(LINKEDIN_BLOCKED_HOSTS.includes('linkedin.com'));
+  assert.ok(LINKEDIN_BLOCKED_HOSTS.includes('www.linkedin.com'));
+  assert.ok(LINKEDIN_BLOCKED_HOSTS.includes('lnkd.in'));
+});
+
+test('the constant lists are frozen (single source of truth)', () => {
+  assert.ok(Object.isFrozen(RELATIONSHIP_STATUS));
+  assert.ok(Object.isFrozen(ADVERTISER_CATEGORIES));
+  assert.ok(Object.isFrozen(CONTACT_ROLE_PRIORITY));
+});

← 806e0b0 auto-data-snapshot: 2026-08-07T16:56:57 (8 data files) — REA  ·  back to Rentv Adintel  ·  review(contrarian): wire panelist-mislabel guard onto the li 721e080 →