# CRM-PLAN — WhatsApp ⇄ Ultron single source of truth

> **For any Claude session picking this up:** read this file top to bottom before touching code.
> Status boxes below are the ground truth of progress. Update them + CLAUDE.md as you ship.
> Decided with Ahmed 2026-07-16. Vision: Ultron (Supabase) = the shared operational truth his
> sales team works from; jarvis-memory = Ahmed's private brain (summaries only, never raw private data).
> Later: Meta WhatsApp Business API so the whole team messages leads through Ultron itself.
> **2026-07-18: the CRM RESTRUCTURE section at the bottom is the new master plan** — per-client
> funnels + tasks + multi-user Baileys WhatsApp connect + DeepSeek auto-ack. Read it before building.

## Why (the problem)
- WhatsApp conversations live ONLY in `claude-voice/whatsapp-reader/whatsapp.db` (SQLite on Ahmed's Mac).
- The hourly `crm_sync.py` sends DeepSeek **summaries** to jarvis-memory. Nothing reaches Ultron:
  `resolve.js` (number→lead matching) is a stub that always returns null.
- Ultron has **zero conversation storage**; the team can't see who said what, follow-ups depend on
  Ahmed's private memory recall (fuzzy, not dependable at operational scale).
- Media/voice pipeline ALREADY WORKS (`media.js`): downloads → sha256-dedup → private Supabase bucket
  `wa-media` → whisper large-v3-turbo transcription (auto-lang, VPS fallback). README claims otherwise — stale.

## Architecture decision
- **Separate Postgres schema `wa`** in the same Supabase project for conversation data (Ahmed's call:
  don't crowd the main schema). Lead identity (`lead_phones`) lives in `public` next to `leads`.
- Dashboard reads `wa.*` ONLY through RPCs defined in `public` (PostgREST doesn't expose the `wa`
  schema — that's intentional; functions in `public` can still query it).
- Phones stored canonical: digits only, Saudi → `9665XXXXXXXX` (`norm_phone()` in SQL, same logic in JS).
- `leads` table untouched (additive only). Sparse WhatsApp-origin leads get
  `place_id = 'wa:' + canonical_phone`, `source = 'WhatsApp'` (same synthetic-PK style as bigasscrm).

## Phases

### Phase 1 — Identity spine ✅ (this session, 2026-07-16)
- [x] `norm_phone(text)` SQL function (db/schema.sql §12)
- [x] `public.lead_phones` (canonical phone PK → place_id, many per lead) + idempotent backfill from
      `leads.phone` and `leads.alt_phones`
- [x] Resolver logic lives in the sync script (below), NOT in resolve.js (which stays a stub for the daemon)

### Phase 2 — Conversations into Supabase ✅ (this session, 2026-07-16)
- [x] Schema `wa`: `wa.threads` (peer PK, peer_phone, place_id, visibility 'team'|'private'),
      `wa.messages` (verbatim body incl. merged `[voice note] …` transcripts, unique (peer_number, wa_id)),
      `wa.media` (mirror of local media table: hash, storage_url, mime, filename, transcript)
- [x] RPCs: `get_lead_conversation(p_place_id, lim)` and `get_wa_media(p_hash)`
- [x] **`claude-voice/whatsapp-reader/ultron_sync.js`** — hourly, mirrors whatsapp.db → Supabase:
      media table upsert → per-DM-thread: resolve lead via `lead_phones` (else CREATE sparse lead +
      lead_phones row) → upsert thread → insert new messages → advance per-peer watermark
      (local sqlite `ultron_sync_state`) only on success. Groups skipped (v1). Own number excluded.
      Denylist `personal.json` (same dir): numbers listed there never sync + their threads forced
      `visibility='private'` (Ahmed's personal chats NEVER reach the team).
- [x] launchd `com.jarvis.wa-ultron-sync.plist` (hourly, like wa-crm-sync)
- [x] Dashboard: **Conversation panel in the lead detail modal** (`U.openLead` sidecar in
      assets/ultron.js): chat bubbles (ME/THEM), voice transcripts inline, media chips →
      `/api/wa-media?hash=` (server signs a 1h URL from the private bucket, 302 redirect).
      Server routes: `/api/lead-conversation`, `/api/wa-media` (both session-auth'd).

### ⚠️ LID reality check (learned the hard way, first sync 2026-07-16)
Only ~8 of ~459 DM peers have a resolved real phone — the rest are Baileys **LIDs** (huge random
numeric ids that LOOK like phones). First sync minted 455 junk leads from them (deleted + rebuilt).
Rule now enforced in ultron_sync.js `canonicalPhone()`: ONLY `peer_phone` (daemon/lid_map-resolved)
is trusted; raw peer_number accepted only if it normalizes to exactly `9665\d{8}`. LID threads sync
with `place_id NULL` (invisible in dashboard) and auto-link later: the sync tracks `last_phone` in
`ultron_sync_state` and re-upserts a thread when lid_map resolves its phone, even with no new
messages. **Consequence: growing lid_map coverage is now the #1 lever for making conversations
visible** — see Phase 3.

### Phase 3 — Follow-up scanner + LID coverage ⬜ (next)
- **LID coverage first**: most threads are invisible until lid↔phone resolves. Mine more channels
  (contacts sync, history, onWhatsApp probes where safe), consider a backfill pass over lid_map when
  the daemon learns new pairs. Measure with lidmap.js `--maponce` coverage report.
- Commitment extraction: extend `crm_sync.py`'s hourly DeepSeek pass to ALSO emit structured
  `{who, what, due_ts}` commitments → new `wa.followups` table (or public schema — decide then).
- 30-min deterministic cron: overdue commitments, threads where THEY spoke last (cadence per stage,
  NOT a fixed 48h — same-day for hot threads), orphan leads (no name/company), → Telegram
  (`scripts/notify.js` pattern) + jarvis `/task` + HUD.
- Morning digest 07:00 Riyadh: today's meetings/promises pulled from followups.

### bigasscrm import audit (2026-07-16, verified) — feed into Phase 4
No row loss (34,925 inserted; ~11k were deliberate in-file dupes; `leads_backup_20260716` intact).
Only real gap: xlsx **"Industry / Business Type" was dropped** (~35.9k values, no `leads.industry`
column exists) → add the column + backfill from UltronCRM.xlsx (synthetic place_id is recomputable).
Phone ordering: both numbers saved (`phone` + `alt_phones`, 3,480 rows have both) but company number
often sits in `phone` while the personal mobile is in `alt_phones` — Ahmed wants personal-mobile-first
→ in-place SQL swap, no re-import. WhatsApp matching is unaffected (lead_phones indexed both).
"Source Count" also dropped — skip, derivable from source_detail.

### Phase 4 — Source-aware filtration ✅ (DB + UI live 2026-07-16)
UI shipped: platform-scoped filter row (Maps: Category/City/Freshness · bigasscrm: Industry/Seniority/
location/WA-history), options from `/api/filter-options`, person rows show full_name+job_title.
**Outage postmortem (same day):** after the search_leads upgrade the leads page 404'd everywhere.
THREE stacked causes, all now baked into schema.sql: (1) legacy 6-param + 8-param search_leads
overloads still lived in the DB (dropped; §13 drops all old signatures), (2) PostgREST schema cache
went stale after DDL (`notify pgrst, 'reload schema'` fixes), (3) the `wa` schema had NO grants for
`service_role` → "permission denied for schema wa" on every RPC touching wa.* (§12 now grants
usage+select). Phone swap applied: 31 rows (audit's 3,480 mostly already mobile-first) → 5,989
bigasscrm rows mobile-first. Industry backfill: 25,464/34,925 (72.9%; rest blank in xlsx).
DB layer shipped in **db/schema.sql §13** (see also CLAUDE.md). Ahmed's decisions baked in:
- **Two Platform buckets today**: `'Google Maps'` and `'bigasscrm'`. WhatsApp-sourced leads FOLD INTO
  bigasscrm; future sources keep their own name. Bucketing is a query-level CASE expression only —
  **leads.source values are never rewritten**. `get_sources()` now returns the distinct platform groups.
- **`leads.industry` restored** (the dropped "Industry / Business Type" xlsx column) + `leads_industry_idx`.
  Backfilled 25,464 bigasscrm rows (72.9% of 34,925; rest had no industry in source) via
  `scripts/backfill-industry.py` (xlsx→csv, place_id recomputed = `bigass:`+sha1(lower(email|phone|company|full_name)),
  validated 46,187/46,187 against the import CSV) + `scripts/upload-industry.js` (staging table → COALESCE update).
- **`get_filter_options(p_source)`** RPC → `{categories,cities,industries,seniorities,states}` scoped to a
  platform group, freq-ordered, capped 200, categories deduped case/whitespace-insensitively. UI picks which
  lists to show per platform (Industry/Seniority for bigasscrm; Category/City for Maps).
- **`search_leads` v3**: new params `p_industry`, `p_seniority`, `p_has_wa` (after `p_source`); `p_source`
  compares against the platform group; `q` also matches `full_name`+`job_title`; `p_has_wa` yes/no tests
  `wa.threads` history; returned rows carry `full_name/job_title/seniority/industry/state/linkedin`. PostgREST
  calls by name so the existing dashboard call is unaffected.
- **Phone preference**: `scripts/phone-swap.js` promotes the personal Saudi mobile from `alt_phones` into
  `phone` (05XXXXXXXX), old phone → alt_phones. bigasscrm only, in-place SQL, reversible.
- **v2 SHIPPED 2026-07-18** (commit `5d9de04` on master — VPS + Supabase + Railway all live): full
  per-platform UI + `search_leads` v4 (`p_state`/`p_size`/`p_rating`/`p_vol`/`p_presence`) + normalization
  helpers `norm_state()`/`size_band()`/`vol_band()` shared by option lists AND matching. Driven by a full
  data profile (2026-07-18): seniority folded 24 dirty→13 clean tokens (token-contains matching so
  "Manager" hits "Senior, Manager"), state dupes merged, company_size dual encodings banded, Maps
  "Add hours" junk + bigasscrm garbage cities excluded, bigasscrm's 100%-filled curated `category`
  taxonomy promoted to its primary filter. Person columns + source_detail now in rows/CSV export.
  **Data facts to remember:** `followers` col = 100% EMPTY (import gap), `priority` 98% empty,
  `department` 2.3% filled, raw `industry` = 1,311 messy values (category is the clean one),
  Maps `review_tier` col ~99% unbackfilled (vol_band computes from `reviews` instead).

### Phase 5 — Media hardening ⬜
- Size cap in media.js (e.g. 50MB skip), mime allowlist for INLINE render (images only — everything
  else download-only), never parse files server-side (esp. xlsx — repo rule), optional ClamAV on VPS.
- Fix stale README (claims media never downloaded).

## New file map (Phases 1–2)
- `db/schema.sql` §12 — norm_phone, lead_phones (+backfill), schema `wa`, RPCs
- `claude-voice/whatsapp-reader/ultron_sync.js` — the hourly Mac→Supabase mirror (node, pg + better-sqlite3)
- `claude-voice/whatsapp-reader/com.jarvis.wa-ultron-sync.plist` — launchd, hourly
- `claude-voice/whatsapp-reader/personal.json` — privacy denylist (gitignored if it ever holds real numbers)
- `dashboard/server.js` — `/api/lead-conversation`, `/api/wa-media`
- `dashboard/assets/ultron.js` — conversation panel in the lead modal

## Deploy checklist
```bash
ssh ultron 'cd /root/ultron && node scripts/db-setup.js'      # after rsync — applies schema §12
cd claude-voice/whatsapp-reader && npm install                 # pg dependency
launchctl load ~/Library/LaunchAgents/com.jarvis.wa-ultron-sync.plist   # (copy plist there first)
node ultron_sync.js --once                                     # manual first run, verify counts
ssh ultron 'systemctl restart ultron-dashboard'                # dashboard files are RAM-cached
# Railway (team-facing) dashboard updates only on git push origin master — needs a clean commit.
```

## Invariants (do not break)
- jarvis-memory pipeline (crm_sync.py → /remember) keeps running unchanged — the brain still gets summaries.
- `leads` upserts stay COALESCE / additive; sync must NEVER overwrite scraper data.
- whatsapp-reader daemon stays read-only w.r.t. WhatsApp (never sends).
- Private threads (personal.json) must never appear in any dashboard RPC output.

---

# ULTRON CRM RESTRUCTURE — pipelines, tasks, multi-user WhatsApp (planned 2026-07-18)

> Ahmed's ask (2026-07-18, voice): turn Ultron from a scraper dashboard into a true sales CRM like
> Apollo — per-client customizable funnels ("contacted → replied → meeting → …"), shared team view of
> who converted to which stage, email + WhatsApp unified per lead, and EVERY team member connecting
> their own WhatsApp number(s) by QR from their Ultron account, with auto lead-linking and a DeepSeek
> auto-acknowledge option. Design below is research-backed (Apollo/HubSpot/Pipedrive/Close/Attio +
> Baileys multi-session realities). **Approved by Ahmed 2026-07-18 ("just do it"): funnel first, then
> WhatsApp, BOTH on the existing VPS — scraping paused (`data/plan-manager.off`) to free the RAM.**
>
> **BUILD STATUS (2026-07-18, same session):**
> - ✅ F-track SHIPPED (master `e8bd795`, VPS + Supabase live): schema §14 (pipelines/stages/history/tasks
>   + seed + trigger + board/stage/stagectx/my-tasks/timeline RPCs), `/pipeline` kanban + My Day, lead-modal
>   stage selectors + follow-up quick-add. Boards seeded for the 3 existing clients.
> - ✅ Schema §15 SHIPPED + MIGRATED live: wa_accounts/wa_auth_state/wa_lid_map; wa.threads+messages
>   repivoted to (owner_account_id, peer_number) — 458 threads/1073 messages backfilled through the
>   synthetic `mac-reader:…` account; Mac `ultron_sync.js` updated in lockstep and verified (`--once` clean).
> - ✅ `/connections` page + server proxy routes (`/api/wa/*`, QR-over-SSE) shipped; `WA_CTRL_*` env staged
>   on the VPS (control port 3100; Railway needs the same 2 vars set manually to enable the page there).
> - ✅ `ultron-wa/` service DEPLOYED + RUNNING (systemd `ultron-wa` on the VPS, control API 0.0.0.0:3100
>   bearer-guarded, verified 401-without-token from outside). Supervisor + fork-per-number workers,
>   Baileys 6.7.23 (ESM via dynamic import), Postgres auth state, shared wa_lid_map, DeepSeek auto-ack
>   (fail-closed — needs DEEPSEEK_API_KEY in /root/ultron/.env to ever send; WA_AUTOREPLY_KILL=1 = global off).
>   The synthetic mac-reader account is EXCLUDED from the reconciler (status 'new' + label filter) — the
>   Mac daemon keeps owning Ahmed's own number. Railway got WA_CTRL_URL/WA_CTRL_TOKEN via CLI.
>   ⚠️ UNTESTED end-to-end: no real number linked yet — first link (Connections page → ADD NUMBER →
>   CONNECT → scan) is the acceptance test. ⚠️ Control API is plain HTTP + bearer on the open internet;
>   fine for now per project posture, TLS (Caddy + subdomain) is the upgrade when convenient.
> - ⬜ Not yet: media download in the VPS workers (Mac pipeline still handles its own account's media),
>   Smartlead webhooks → timeline, contacts auto-log from worker outbound, team funnel analytics page.

## R1. Core architecture decision (the funnel model)
**Lead-centric (Close CRM), NOT deal-centric. No deals/opportunities table.** Ultron is aged-lead
outbound at volume — the `lead_clients` row ("this lead worked for this client") IS the opportunity.
Stage lives on that membership row (Attio's list-entry pattern), so one lead can sit in several
clients' pipelines at independent stages with zero new join tables.

**Explicitly skipped (with reasons, don't relitigate):** separate deal entity (dead weight for lead
working); per-lead $ value + weighted forecasting (agency is paid per client, not per deal); ML lead
scoring (freshness/review_tier/has-WA already triage); building an in-house email sequencer (that's
Smartlead — ingest its webhooks instead); multiple pipelines per client (one default each, table
supports more later).

## R2. Schema additions (all additive; DDL sketches researched, adjust on build)
- `pipelines` (id, client_id FK, name default 'Sales', is_default) — one seeded per client.
- `pipeline_stages` (pipeline_id FK, name, position, kind 'open'|'won'|'lost', color, wa_template).
- `lead_clients` += pipeline_id, stage_id, entered_stage_at, board_position, owner_id (which agent
  owns this lead-for-this-client). place_id+client_id stays the natural key.
- `lead_stage_history` (place_id, client_id, pipeline_id, from_stage_id, to_stage_id, changed_by,
  changed_at) — powers BOTH the timeline feed and conversion analytics.
- `tasks` (place_id, client_id, assigned_to, title, type call|whatsapp|followup|meeting, due_at,
  done_at) + partial index on open tasks. Pipedrive rule: every worked lead should carry exactly one
  scheduled next activity; "no answer, retry" is a TASK, not a pipeline stage.
- `get_lead_timeline(p_place_id, p_client_id)` RPC — UNION ALL over contacts + lead_notes +
  lead_stage_history + wa.messages (+ future outreach_events) normalized to one reverse-chron feed.

**Default stage template** (seed for every client; Saudi WhatsApp-first outbound):
`New → Contacted → Replied → Interested → Meeting/Quote → Won | Lost`. Never auto-demote a stage
(Apollo rule: a later send doesn't move Replied back to Contacted).

## R3. UI build order (each copies a proven pattern)
1. **Lead drawer unified timeline** (Close/HubSpot record view) — extend the existing lead modal +
   CRM sidecar: stage dropdown scoped to active client + the merged feed. Highest leverage, mostly wiring.
2. **Per-client pipeline kanban** (Pipedrive/Attio board) — one column per stage, drag = stage change
   (writes history), cards show WA badge + owner + days-in-stage. Existing client switcher picks the
   board. The leads TABLE stays the triage surface — kanban is only for the worked subset.
3. **My Day** (Pipedrive activities) — Overdue + Today task lists per agent, complete→prompt next follow-up.
4. **Team funnel dashboard** (Close small-team analytics) — per-client stage funnel + conversion %,
   activity per rep, stale-lead list (open stage, no touch in N days). Chart.js already loaded.

## R4. Multi-user WhatsApp layer (Baileys) — architecture decided, not built
**Topology: new always-on `ultron-wa` service on the VPS (systemd), NEVER Railway** (Railway restarts
on deploy + ephemeral disk = dropped sockets, re-link storms, the exact 428 loop we know). Supervisor
process reconciles desired accounts vs running workers and exposes a localhost-only control API the
dashboard calls; **one forked child process per WhatsApp number** (crash/ban isolation, per-number 3h
recycle vs the ~48h silent-disconnect, event-loop isolation). ~80MB RAM per session — **the current
2-core box can't host many sockets next to the scrapers; measure at 2-3 numbers, likely provision a
small dedicated wa-box.** Reuse verbatim the proven daemon.js knobs: Baileys pinned 6.7.23,
`fetchLatestWaWebVersion()` (never fetchLatestBaileysVersion), browser tuple `['Jarvis Reader','Safari','1.0.0']`
(NEVER the Desktop identity — 428), `markOnlineOnConnect:false`, `syncFullHistory:true` set BEFORE
first link, qrTimeout 180s, exponential backoff reconnect. Port `lidmap.js` + the ultron_sync.js
resolve-and-upsert pipeline + media pipeline + personal.json privacy as-is.

- **`public.wa_accounts`**: user_id FK, phone (norm_phone), label, status new|linking|connected|
  disconnected|logged_out|banned, auto_reply bool, last_qr_at/last_connected_at/last_disconnect_reason.
  Agents manage only their own rows; admin sees all (existing role guards).
- **Auth state**: Phase 1 = `useMultiFileAuthState` per-number folder on VPS disk (proven); Phase 2 =
  `usePostgresAuthState` adapter → `public.wa_auth_state` (account_id, key_type, key_id, value jsonb)
  storing creds + ALL signal keys (pre-key/session/sender-key/app-state-sync-*) — losing signal keys
  while keeping creds = the Bad MAC decrypt loop. Inject the adapter so the swap is one line.
- **Multi-account migration (the one breaking change)**: `wa.threads` PK peer_number →
  `(owner_account_id, peer_number)`; `wa.messages` unique → `(owner_account_id, peer_number, wa_id)`;
  threads += stage (new|replied|in_progress|negotiating|won|lost), last_inbound_ts, last_outbound_ts,
  auto_reply_last_ts. Keep owner_number text (Mac daemon still writes it); synthetic wa_accounts row
  represents the Mac number. `get_lead_conversation` joins wa_accounts→app_users so every thread shows
  WHICH EMPLOYEE owns the relationship; log outbound as `contacts` rows so dedup badges keep working.
- **QR connect UX**: /connections page → POST /api/wa/link → supervisor forks worker in link mode →
  QR streamed browser-ward over **SSE** (rotates ~20-30s, re-render each event; countdown + regenerate;
  pairing-code fallback for headless). Post-scan `restartRequired` disconnect is NORMAL (recreate
  socket on saved creds). loggedOut → red card + Telegram ping + re-link button. Heartbeat per worker
  → ops-watch.
- **Lead-linking**: same LID discipline as ultron_sync.js (only lid-resolved `peer_phone` or strict
  `9665\d{8}` trusted; LID-only threads keep place_id NULL until resolved; 15-30min reconciler
  re-links). Unknown resolved numbers → sparse lead `'wa:'+phone`, source='WhatsApp', INSERT-only.
- **DeepSeek auto-reply (opt-in per number)**: hook AFTER durable store of an inbound DM; outbound
  queue worker (never inline), trigger rules = first inbound with no outbound in 6h AND no auto-ack
  in 24h, business hours Asia/Riyadh, never groups/personal-denylist, per-number + global daily caps,
  Gaussian 5-40s send delay. Model `deepseek-v4-flash` (deepseek-chat deprecates 2026-07-24), ≤160
  chars, same-language (ar/en) ack only — no prices/promises/questions; DeepSeek error → send nothing.
  Kill switches: per-account flag, global `WA_AUTOREPLY_KILL=1` env, auto-off when not connected.
  Honor "إيقاف"/"stop" → permanent per-peer suppression.
- **Ban risk**: real employee numbers doing normal outreach = low-risk class, but unofficial-API bans
  are permanent. Mitigations baked in: reply-only automation (never initiates), human pacing/caps,
  7-day read-only warm-up for fresh links, passive presence, no relink hammering, one worker per
  number. Long-term: outbound migrates to the official Meta Cloud API; Baileys stays for read + light ack.

## R5. Build phases (smallest shippable slices)
- **W1 — read-only, one number, dashboard-linked**: ultron-wa supervisor + 1 worker on VPS,
  wa_accounts, threads/messages composite-key migration, /connections QR-over-SSE, employee
  attribution in the lead modal. (Multi-file auth state.)
- **W2 — true multi-number + robustness**: N workers, heartbeats + ops-watch, 3h recycle, re-link
  alerts, pairing-code fallback, Postgres auth state, LID reconciler, RAM decision (wa-box?).
- **W3 — auto-reply** (per R4 rules) + log sends to `contacts`.
- **F1-F4 — funnel** (independent of W-track, can start first): R2 schema + seed → lead drawer
  timeline → kanban → My Day → team funnel dashboard.
- **Smartlead** (existing roadmap): webhooks → `outreach_events` → timeline + funnel. After F1.

## ATTIO CLONE TRACK (2026-07-19 — spec in Attio-CRM-Spec-for-Ultron.md, READ IT)
Ahmed's client MIS runs Attio; Ultron is being rebuilt around its 4 primitives (Objects / Records /
Lists / auto-Activity). Status:
- ✅ **Theme v3** (master `476de28`): pure black monochrome, zero blue (Ahmed: indigo "felt cheap").
  Light-gray highlights, white-on-black buttons, stage dots reseeded to a no-blue Attio palette.
- ✅ **Record shell — Attio Phases 1+2** (master `b793d5d`): every lead opens as a tabbed record
  (rail + Overview highlights w/ stage progress bar / Activity grouped feed / Notes / Tasks /
  WhatsApp / Details) with the "X of N in stage" prev/next walker fed by kanban columns and the
  leads table. New route `/api/lead-tasks`. This is the reusable shell for Company/People records later.
- ⬜ **Lists (Attio Phase 3 — NEXT)**: `lists` + `list_entries` + per-list `status` enum, table+board
  views, seed "Cold Call Queue 4.9+". The shared-team-funnel primitive Ahmed explicitly wants.
- ⬜ **Company rollup (Phases 4-5) — DRY-RUN DONE, AWAITING AHMED'S GO.** Findings (agent, 2026-07-19,
  read-only): 60,418 leads → **39,618 companies** with the name+city key; 23,209 rows carry a usable
  domain key; 1,025 groups would join Google-Maps businesses with their bigasscrm person-contacts
  (the gold). **Three data traps MUST be fixed in the migration recipe first:** (1) aggregator-domain
  blocklist — mahally.com alone fake-merges 456 unrelated Salla stores (also franchisecenter.sa,
  confidential.careers, sites/drive.google.com, ihg.com); (2) ~3-4k bigasscrm rows have a BARE FIRST
  NAME in `company` ("Mohammed" ×442, "Ahmed" ×395…) — require ≥2 tokens or fall back to singleton;
  (3) city strings are dirty (postal codes, "makkah 24372" vs "mecca") — normalize before keying;
  plus Arabic normalization (alef variants, shadda) and suffix additions (saudi/ksa/international/فرع).
  Size-2-5 name-keyed merges were eyeballed: overwhelmingly legitimate (cross-source dupes + real branches).
