# Lead Enrichment (Manage · Leads)

**Portal:** Manage · **Tab:** Lead Show → *Identity* · **Job:** `EnrichLeadJob` (queued)

## What it does
Turns a captured lead into an enriched **identity profile** — without paying a vendor or
scraping anyone. It **derives** location + device from the `ip_address` / `user_agent` already
stored on the lead's most recent [funnel registration](/docs/modules_handbook/main/landing-lead-capture/readMe.md),
and adds cheap in-house identity signals:

- **Location** (approximate) — IP → country / region / city / ISP / timezone / coordinates, via the
  local **MaxMind GeoLite2** databases (offline, free, no data egress).
- **Device** — User-Agent → device type / brand / model / OS / browser (matomo/device-detector).
- **Phone** — validity / country / line type / carrier (libphonenumber, offline).
- **WhatsApp** — is the number on WhatsApp + its public profile (about / avatar) via the
  [wa-bridge](/docs/modules_handbook/manage/messages/readMe.md) sidecar. **Auto-on**: uses the first
  connected QR (Bridge) channel automatically. A `+60` mobile NOT on WhatsApp is a strong fake-number
  signal. ⚠️ **Rate-capped** (per-minute + per-day) — bulk existence probing is a real ban risk on the
  linked number, so it is throttled + cached, never looped hot (see *Setup notes*).
- **Gravatar** — public avatar + display name by email hash.
- **Business** — the lead's phone → any business listed on it via **Google Places** (New) Text Search.
  MY SME owners list their personal mobile as the business phone, so this is a real occupation signal.
  **Disabled by default** (soft no-op) until a `GOOGLE_PLACES_KEY` is set.
- **Area income** — the IP-derived **state** → a real household-income prior from the **DOSM** Household
  Income Survey medians (offline config map). Area-level only, never a household fact.

The result shows on the lead's **Identity tab** with a status badge and a **Re-run** action.
Everything is **approximate / best-effort** and labelled as such in the UI. Consent is covered by
the capture form + privacy policy (PDPA).

On top of those cheap signals there is a **Phase 2 "intelligence" layer** (skipped only for
disposable-email junk): a **web search** (via a swappable [search driver](#setup-notes) — Serper.dev, a real
Google index) discovers the person's public footprint, an AI step disambiguates which profiles
are really them, classifieds / business pages are deep-fetched, and a final **AI sales report**
(Gemini, via the shared `AiClient`) synthesises everything into a confidence score, a recommended
action (`hot_follow_up` / `standard` / `verify_first` / `flag`), a profile summary, and occupation /
income / hometown estimates. It shows as the **Sales intelligence** card on the Identity tab.

> Truecaller and personal LinkedIn/Facebook/Instagram page scraping (ADB-bridge) are intentionally
> NOT built — we discover profile URLs via Google + AI-disambiguate them, but only fetch
> business/classifieds HTML. Paid identity vendors + lead scoring remain future work.
> ⚠️ There is **no separate design spec** — this handbook is the only record of the design.
> (Earlier revisions deferred to `docs/specs/lead-enrichment.md`, a 272-line "Proposal / spec only"
> document added in `b3e29096` and **deleted in `8d1eea9d`, 2026-06-29**, once this handbook
> superseded it. It survives only on a few unmerged branches — do not resurrect it; keep
> future-phase decisions here.)

## Quality verdicts — agent tag + fake score (2026-08-11)

Two verdicts the sales team kept deriving by eye, now columns on `lead_enrichments` (migration `2026_08_11_300001`), computed FREE in the heuristics stage and surfaced on the session Registrations roster (tags beside the name), the session Summary tab's quality strip + its Telegram text, and `toShowArray()['quality']`:

- **`is_property_professional`** (null = not judged / 0 no / 1 yes / 2 related, `LeadEnrichment::PROF_*` + `PROFS`) + **`prof_evidence`** — is this "lead" actually an estate agent/negotiator who registered to watch the pitch? Three layers, cheap first: **(1)** deterministic — a **REN/REA/PEA licence number** or an agency keyword (ejen hartanah, IQI, Propnex…) in the person's own `wa_about` / `wa_name` / `gravatar_name` (`LeadHeuristics::propertyProfessional()`, runs even with intel off); **(2)** the same regex over the intel snippets/excerpts tied to an **accepted identity** (`LeadIntelService::snippetCorpus()`, below); **(3)** the AI report itself — `lead_intel.md` now asks for `is_property_professional` (yes/related/no) + evidence. Keyword hits are RELATED, never YES — "property investor" chatter must not tag our actual customers, and `test_property_investors_are_not_agents` pins that.
- **`fake_score`** (0–100; 0 = checked-clean, null = never judged) + **`fake_reasons`** (json) — the first place the existing signals are COMBINED: gibberish name (keyboard runs / vowel-free stretches / repeats — `looksKeyboardMashed()`), digits-in-name, disposable email (`DisposableEmail`), gibberish inbox, invalid phone, and the handbook's own strongest cheap signal — a MY mobile `wa_exists = false`. Weights sit in one visible table in `LeadHeuristics::fakeSignals()`; the flag line is `LeadEnrichment::FAKE_FLAG_SCORE` (60), deliberately reachable by phone-side OR identity-side signals alone but never by one weak signal (not being on WhatsApp alone is 25 — plenty of real people aren't).

Backfill existing leads with `php artisan leads:enrich --all` (the columns stay null until a run). Covered by `tests/Feature/Lead/LeadQualitySignalsTest.php`, including the false-positive guards (Malay/Chinese names never read as mash; "Ren" the name never reads as a licence).

### Trust boundary + `prof_source` (2026-08-18)

Search-derived text may **never** produce a definite `PROF_YES` — only text provably the lead's
OWN (`wa_about` / `wa_name` / `gravatar_name`) can. `LeadHeuristics::propertyProfessional(array
$texts, bool $ownText = true)` takes a second parameter for exactly this: a licence-number match
with `$ownText = false` returns `PROF_RELATED`, never `PROF_YES` — finding a REN number **by
searching** is not proof it belongs to the person being searched for, only that it appears
somewhere the search touched. `LeadEnrichmentService::heuristics()` (own text) passes `true`;
`LeadIntelService::analyze()`'s deterministic scan passes `false`.

**The corpus that scan runs over is narrowed to ACCEPTED identities only.** Before this fix,
`snippetCorpus()` fed the scan *every* discovery result — including a candidate the AI
disambiguation step already rejected (a low-confidence pick, or a LinkedIn slug failing the
name-slug check) and the whole noisy `other` bucket (agency team pages, same-named strangers'
listings). A REN number in *anyone's* snippet then meant `PROF_YES`, permanently — a genuine
buyer sharing a name with a negotiator was filtered out of follow-up forever. `acceptedIdentities()`
(shared by `promote()` and the corpus builder, so the two definitions of "accepted" can never
drift) applies the SAME guards `acceptedProfileUrl()` already used for the shown profile links —
confidence floor + LinkedIn name-slug check — and the corpus now only admits: candidate rows
whose `link` IS an accepted identity's URL, `refine` rows (Stage 3 now searches only accepted
identities, not the raw AI pick), and `platform_excerpts` whose `url` is ALSO an accepted
identity URL (mudah/carousell can never clear this — they have no disambiguation step to have
cleared, so a mudah/carousell excerpt is always excluded). Raw per-platform buckets and the
`other` bucket never enter it.

**`prof_source`** (`lead_enrichments.prof_source`, `string(20)` nullable — `own_bio` / `search` /
`ai_report`, migration `2026_08_18_100001`) records WHICH tier produced the stored verdict, so a
weak one can in principle be walked back. The merge rule is **raise-only WITHIN a trust tier;
own-bio outranks search** — trust order **`own_bio` > `ai_report` > `search`**:
- `own_bio` (from `heuristics()`, merged at `LeadEnrichmentService::enrich()`) still raises over
  anything, as before — a REN number read off the person's own WhatsApp about outranks an AI "no".
  A tie in verdict RANK (e.g. own-bio RELATED vs a same-run search RELATED) is broken by SOURCE
  tier, not left to whichever wrote last — `LeadEnrichmentService::profOutranks()`.
- Ordering matters for `ai_report` vs `search`: the AI report runs (Stage 5) and is promoted into
  `$fields` *before* the deterministic snippet scan (Stage 5's tail). So "a report `NO` is not
  overridden by a search `RELATED`" is not a later lowering step — it is a **refusal to raise**:
  `search` may raise the field over `null` only; once the report has already written a value
  (including `NO`), the weaker search-tier evidence is not applied over it.
- **A re-run seeds the comparison from the PERSISTED row**, not just the current run's own_bio
  scan (`LeadEnrichmentService::heldProfBeforeIntel()`) — otherwise a run where the WhatsApp
  lookup is rate-capped (no own_bio signal this time) could let a lower-trust in-run `search`
  result silently downgrade an already-stored higher-trust `own_bio` verdict.

⚠️ **`prof_source` is DATA-ONLY so far — nothing walks a weak verdict back yet.** A stored
`search`-tier RELATED is **not** cleared by a later `ai_report` NO, because the merge rule
compares verdict RANK first and only breaks a same-RANK TIE by source tier; it does not let a
higher-trust source LOWER an already-higher-ranked value from a weaker one. A genuine walk-back
mechanism (e.g. "re-open a `search`-only verdict for review once `ai_report` runs") is a
follow-up task, not built here — flagged so this section doesn't overclaim self-healing.

### The shared state resolver (Phase 2, 2026-08-18)

`Src\Lead\Support\LeadQuality` turns `is_property_professional` / `fake_score` (here) + the admin
override (`leads.agent_verdict` / `fake_verdict`, below) into ONE effective state string per
dimension (`agent`/`agent_linked`/`not_agent`/`unjudged`, `fake`/`genuine`/`unjudged`) plus WHO
decided — as a PHP method (used by `BuildsLeadInsight` and the Leads index row) AND the SAME
mapping as a SQL `CASE` fragment (used by the Leads index filter + its counts), so a roster tag,
an index filter and its count can never disagree. Full detail — including the Agent/Fake filters,
the export's resolved-label columns and the "Fake?" hedge — is in the
[leads handbook](/docs/modules_handbook/manage/leads/readMe.md).

> **A human can override both verdicts (2026-08-17).** `leads.agent_verdict` / `leads.fake_verdict`
> (+ `_by`/`_at`) are the admin's confirmed yes/no, set from the identity header of the Lead Show
> page or the `LeadDetailModal` (shared `LeadQualityTags.vue`, `POST manage/leads/{id}/verdict`).
> They live on **`leads`** — not this table — so an enrichment re-run can never wipe them, and a
> never-enriched lead is still taggable. A set verdict outranks the AI on every surface
> (`LeadQuality` / `BuildsLeadInsight` resolve the precedence for every surface); clearing it
> hands the display back to the columns above. Details in the
> [leads handbook](/docs/modules_handbook/manage/leads/readMe.md).

## How it works

### Trigger → job → service → providers → repository
- A landing registration ([`RegisterLeadAction`](/app/Actions/RegisterLeadAction.php)) dispatches
  **`EnrichLeadJob`** after the sign-up commits — **async**, so a provider outage never blocks or
  fails registration. The admin **Re-run** button and the `leads:enrich` command dispatch the same
  job. It is **unique per lead** for 5 min (no double-work on bursts / re-runs).
- **`LeadEnrichmentService`** (`Src\Lead\Enrichment`) builds an **`EnrichmentContext`** from the
  lead + its most recent registration (ip / ua) + the account email + profile phone, then runs
  every **enabled** provider. Each provider is isolated — one that fails or finds nothing can never
  abort the others or the lead. It merges the resolved columns, derives the run status
  (**Complete** / **Partial** / **Not found**), and delegates the write to the repository. The
  service performs **no DB writes itself**.
- Each provider implements **`EnrichmentProvider`** (`key()` + `enrich(context): EnrichmentResult`)
  and returns `EnrichmentResult::ok/empty/failed` — never throws for an expected miss. A provider
  only writes the columns it resolved, so a **partial re-run never nulls previously-resolved data**.
- **`LeadEnrichmentRepository::upsertForLead`** filters with `data_only()` then `updateOrCreate`s
  the single `lead_enrichments` row inside `DB::transaction` (keyed on `lead_id`, idempotent).
- Toggles + GeoLite2 DB paths + the WhatsApp lookup live in **`config/enrichment.php`**.

### A second writer — the leads CSV import (2026-07-21)
`LeadEnrichmentService` is no longer the only thing that fills this table. The **leads CSV
import** ([`BulkMemberImportAction::applyExtras`](/app/Actions/BulkMemberImportAction.php)) also
writes it, for legacy exports that already carry a computed intelligence block:
`intel_occupation` → `estimated_occupation`, `intel_income` → `income_bracket`, `intel_summary` →
`profile_summary`, `intel_hometown` → `hometown_note`, and `intel_ip_location` → `geo_city` /
`geo_region` / `geo_isp` (+ the DOSM `area_income_*` prior the resolved state unlocks). It goes
through the same `LeadEnrichmentRepository::upsertForLead`, and is **fill-blank-only** — it can
never overwrite a value a real enrichment run (or a human) already produced. It writes no
`status`, so an imported profile stays *Pending* until enrichment actually runs, and leaves
`geo_ip` NULL — that blank is what distinguishes an imported location from an IP-derived one.
See the [leads handbook](/docs/modules_handbook/manage/leads/readMe.md) for the column aliases.

### Data + privacy
- One row per lead (`lead_enrichments`, 1:1 via `Lead::enrichment()`); purged when the lead is
  deleted (`Lead::deleting`). The merged raw provider payloads (`raw`) are the richest PII blob and
  are **encrypted at rest** (`encrypted:array` cast) and never serialized to the frontend.
- Status label + colour come from `LeadEnrichment::STATUSES` (server metadata) — the Vue never
  hardcodes them. Admin-only (the tab + the `enrich` route sit behind the `admin` middleware);
  writes are blamed via `RecordsBlame`.

### Phase 2 — the intelligence layer (`LeadIntelService`)
Runs for any lead with a **searchable identity** (name / email / phone present) and a
**non-disposable email** (`DisposableEmail`). An invalid or missing phone does **not** block the
search — the person may still be findable by name / email, and the AI report weighs the bad phone
as a negative signal (`verify_first` / `flag`) — but disposable-email junk never spends money.
`is_valid_lead` (phone valid + non-disposable email) is still recorded and drives the Identity-tab
warning. `LeadEnrichmentService` calls it after the cheap signals + heuristics; it
returns promoted scalar `fields` + an encrypted `intel` blob that the repository persists.
- **Discover** — a **cascade** (`LeadIntelService::discover()`): the queries run in **descending
  precision** (exact phone `"0xx" OR "+60xx"` first — near-unique for a MY consumer → email → name →
  `site:facebook.com` → `site:mudah.my OR carousell`) and **STOP EARLY** once enough high-signal
  (non-`other`) hits are in and the minimum queries have run (config `intel.discovery.*`). This turns
  the old unconditional 5–7 searches into a typical 2–3 — each saved search is a paid Serper query.
  The search itself goes through the **`SearchProvider`** contract (driver-bound), so the vendor is a
  config switch. Results are cached per query (`cache_ttl`), so a **Re-run costs nothing**.
- **Disambiguate** — `AiClient` (`PROMPT_LEAD_DISAMBIGUATION`, cheap model) picks the LinkedIn /
  Facebook / Instagram result most likely to be this exact person + a confidence. Two deterministic
  **guards** then decide whether the URL is actually shown (`promote()` → `acceptedProfileUrl()`): a
  **confidence floor** (`intel.min_profile_confidence`, default 60) drops low-confidence guesses, and a
  **LinkedIn name-slug check** drops a URL whose slug shares no name token with the lead (e.g.
  `sg.linkedin.com/in/keithtraveldiary` for a lead named "Cheng Wai Kit") even at high confidence — a
  wrong same-named / wrong-country profile is worse than none. Personal pages are **not** scraped (no ADB).
- **Refine** — a bounded second search pass on the chosen identities to sharpen the snippets.
- **Platform** — `PageFetcher::fetchHtml()` (a free plain-HTTP fetch — mudah / carousell are ordinary
  sites, no scraping vendor needed) deep-fetches the top mudah / carousell / FB-business URLs → trimmed
  text excerpts.
- **Report** — `AiClient` (`PROMPT_LEAD_INTEL`) synthesises the bundle into the structured sales
  report. The report is fed a **focused, deduped snippet set** — only the chosen profiles' rows +
  classifieds + refine (capped by `intel.max_report_snippets`), **not** all ~40 discovery results —
  which is the single biggest token saving. The default model is a current **flash-class** Gemini
  (`intel.report_model`, default `gemini-3.5-flash`; the task is structured extraction, so a Pro
  model is over-spec'd — override to `gemini-3.1-pro-preview` for max nuance / `gemini-3.1-flash-lite`
  for max savings). AI runs on the **global company key** and is logged to `ai_requests`
  (subject = the lead).
- **Heuristics** (`LeadHeuristics`, local/free) feed the report: MY phone area-code → home state,
  device brand/model → coarse income proxy, Facebook in-app browser detection, and the **DOSM
  area-income prior** (IP state → real state-median household income + bracket — see
  `areaIncomeFromGeo`). The report prompt is told to **weight** the Places `business` match (occupation)
  and the DOSM `area_income_median_rm` (income) above the weaker guesses.

The promoted columns (`confidence_score`, `recommended_action`, `estimated_occupation`,
`income_bracket`, `hometown`, `profile_summary`, the three `*_url`s) drive the
**Sales intelligence** card; the full report + candidates + discovery meta + scraped excerpts live in
the encrypted `intel` JSON. `recommended_action` label + colour come from
`LeadEnrichment::RECOMMENDED_ACTIONS`. Each stage fails soft and is config-gated
(`config/enrichment.php` `intel.*`).

### Setup notes
- **GeoLite2** (location) needs `GeoLite2-City.mmdb` (+ optional `GeoLite2-ASN.mmdb`) under
  `storage/app/geoip/` — free, needs a MaxMind licence key (`MAXMIND_LICENSE_KEY`). Until present,
  the IP provider degrades and the run is *Partial* with a clear `last_error` (the rest still
  resolves). Attribution: "This product includes GeoLite2 data created by MaxMind".
- **Web-search driver** (`enrichment.search.driver`, env `ENRICH_SEARCH_DRIVER`) — **`serper`**
  (Serper.dev) is the only driver; the old ScraperAPI driver was **removed** (it billed 25 credits per
  Google request — 15–40× Serper's ~$0.30–1.00 / 1,000 searches). Set `SERPER_KEY` (secret; `.env`,
  gitignored; 2,500 free queries at serper.dev). Drivers implement the `SearchProvider` contract and
  are bound in `AppServiceProvider`; a future `gemini` grounding driver is noted but needs tool/grounding
  support in the shared `AiClient` first. When Serper's credits run out it degrades to empty (logged);
  the Identity tab shows an "out of credits" hint. The **platform page-fetch** (mudah / carousell) uses
  the free `PageFetcher` (plain HTTP, no vendor). Toggles: `ENRICH_WEBSEARCH` / `ENRICH_PLATFORM` / `ENRICH_INTEL`.
- **AI report** needs a configured **global Gemini key** (Manage → AI Providers). Models are
  `config/enrichment.php` `intel.*_model` (default current Gemini 3.x — the 2.5 tier is deprecated
  ~2026-10-16).
- **Google Places** (business signal) ships **off** (`ENRICH_PLACES=false`, `PlacesPhoneProvider`
  soft-skips until a `GOOGLE_PLACES_KEY` is set). Places (New) gives 10,000 free Text Search
  calls/month before it bills; results are cached (`cache_ttl`).
- **DOSM area income** is offline — median household income by state ships in
  `config/enrichment.php` `income.state_median` (DOSM HIS 2022, CC BY 4.0). It needs no key; refresh
  the figures from [data.gov.my](https://data.gov.my) (OpenDOSM). Missing states resolve to `null`
  (no guess).
- **WhatsApp lookup** is **auto-on** when a QR (Bridge) channel is connected — `WhatsAppProvider`
  **spreads lookups across ALL connected active `WhatsappChannel`s** (a random pick among the channels
  still under budget; or a pinned `ENRICH_WHATSAPP_INSTANCE`) and calls the bridge's
  `GET /instances/{instance}/lookup?number=` endpoint (`onWhatsApp` existence probe + public avatar /
  about). ⚠️ **Ban risk is real**: bulk probing of unknown numbers is exactly what WhatsApp bans for,
  and the casualty is the phone number linked to the bridge — so lookups are **rate-capped**
  (`ENRICH_WHATSAPP_PER_MINUTE`, default 8; `ENRICH_WHATSAPP_DAILY_CAP`, default 250) and cached (a
  cache hit spends no budget). The caps are **PER CHANNEL** — each connected number has its own budget,
  so adding channels raises TOTAL capacity while keeping EACH number safe (it does **not** let one
  number do more); a lead with no channel under budget is skipped this run. **Start LOW on a
  new/unwarmed number.** Set either cap to **0** to switch WhatsApp lookups off entirely (kill switch);
  `ENRICH_WHATSAPP=false` disables the provider outright. **The bridge must be redeployed** for the new
  `/lookup` route to exist (`wa-bridge/`; systemd `baileys-wa-bridge`) — before that the lookup 404s
  and degrades to empty.
- **Backfill** existing leads from their stored IP/UA: `php artisan leads:enrich` (un-enriched only)
  / `--all` (everyone) / `--sync` (inline instead of queued).

## Reference usage

The canonical way to enrich a lead — go through `LeadEnrichmentService` (or the job), never call a
provider directly:

```php
// Off the request cycle (the normal path):
\App\Jobs\Lead\EnrichLeadJob::dispatch($lead);

// Synchronous (e.g. a console backfill or a test):
app(\Src\Lead\Enrichment\LeadEnrichmentService::class)->enrich($lead);
```

To add a new in-house signal: implement `EnrichmentProvider`, register it in
`LeadEnrichmentService`'s constructor + `config/enrichment.php` `providers`, and add its columns to
the migration + `LeadEnrichment` (`$fillable` / `$casts` / `toShowArray`) + `LeadEnrichmentRepository::FIELDS`.
If the signal is DERIVED from another provider's output (e.g. IP → area income), add it as a
`LeadHeuristics` method wired into `LeadEnrichmentService::heuristics()` instead — heuristics receive
the merged `$merged` array (with the resolved geo), while a provider only sees the raw `EnrichmentContext`.

To add a new **web-search driver**: implement `SearchProvider` and bind it in `AppServiceProvider`
under a new `enrichment.search.driver` value.

## Related files

**Backend — model + migration**
- [src/Lead/LeadEnrichment.php](/src/Lead/LeadEnrichment.php) — the profile model (status consts +
  metadata, `encrypted:array` raw, `toShowArray`).
- [database/migrations/2026_06_24_000001_create_lead_enrichments_table.php](/database/migrations/2026_06_24_000001_create_lead_enrichments_table.php)
  + [..._000002_add_intel_to_lead_enrichments_table.php](/database/migrations/2026_06_24_000002_add_intel_to_lead_enrichments_table.php) (Phase-2 intel columns)
  + [2026_07_14_000001_add_places_and_income_to_lead_enrichments_table.php](/database/migrations/2026_07_14_000001_add_places_and_income_to_lead_enrichments_table.php) (Places business + DOSM area-income columns)
  + [2026_07_21_000002_add_hometown_note_to_lead_enrichments_table.php](/database/migrations/2026_07_21_000002_add_hometown_note_to_lead_enrichments_table.php) (`hometown_note` TEXT — the free-text reasoning behind the hometown call; `hometown` itself stays a SHORT place name because it renders in a one-line stat cell)
  + [2026_08_11_300001_add_agent_and_fake_signals_to_lead_enrichments.php](/database/migrations/2026_08_11_300001_add_agent_and_fake_signals_to_lead_enrichments.php) (`is_property_professional` / `prof_evidence` / `fake_score` / `fake_reasons`)
  + [2026_08_18_100001_add_prof_source_to_lead_enrichments.php](/database/migrations/2026_08_18_100001_add_prof_source_to_lead_enrichments.php) (`prof_source`, see the trust-boundary section above).
- [src/Lead/Lead.php](/src/Lead/Lead.php) — `enrichment()` relation + delete cascade.
- [src/Lead/Support/LeadQuality.php](/src/Lead/Support/LeadQuality.php) — the shared state resolver (see the section above; full detail in the [leads handbook](/docs/modules_handbook/manage/leads/readMe.md)).

**Backend — service + providers + repository**
- [src/Lead/Enrichment/LeadEnrichmentService.php](/src/Lead/Enrichment/LeadEnrichmentService.php) — orchestrator (staged: signals → validate → heuristics → intel); `mergeOwnBioProf()` / `heldProfBeforeIntel()` / `profOutranks()` — the own_bio raise-only merge, seeded from the persisted row on a re-run.
- [src/Lead/Enrichment/Contracts/EnrichmentProvider.php](/src/Lead/Enrichment/Contracts/EnrichmentProvider.php),
  [SearchProvider.php](/src/Lead/Enrichment/Contracts/SearchProvider.php) (web-search driver contract),
  [EnrichmentContext.php](/src/Lead/Enrichment/EnrichmentContext.php),
  [EnrichmentResult.php](/src/Lead/Enrichment/EnrichmentResult.php)
- [src/Lead/Enrichment/Providers/](/src/Lead/Enrichment/Providers/) — `IpGeoProvider`,
  `DeviceProvider`, `PhoneProvider`, `WhatsAppProvider`, `GravatarProvider`, `PlacesPhoneProvider`.
- [src/Lead/Enrichment/Providers/Search/](/src/Lead/Enrichment/Providers/Search/) — `SerperSearch`
  (the `SearchProvider` driver; bound in `AppServiceProvider`).
- [src/Lead/Repositories/LeadEnrichmentRepository.php](/src/Lead/Repositories/LeadEnrichmentRepository.php)
- [config/enrichment.php](/config/enrichment.php)

**Backend — Phase-2 intelligence (web search + AI)**
- [src/Lead/Enrichment/LeadIntelService.php](/src/Lead/Enrichment/LeadIntelService.php) — discover (cascade) → disambiguate → refine → platform → report.
- [src/Lead/Enrichment/Support/PageFetcher.php](/src/Lead/Enrichment/Support/PageFetcher.php) — free plain-HTTP page fetch + html→text for the platform deep-fetch stage.
- [src/Lead/Enrichment/Support/DisposableEmail.php](/src/Lead/Enrichment/Support/DisposableEmail.php) · [LeadHeuristics.php](/src/Lead/Enrichment/Support/LeadHeuristics.php) — validation + heuristics (incl. DOSM `areaIncomeFromGeo`).
- AI prompts: [config/ai_prompts.php](/config/ai_prompts.php) (`PROMPT_LEAD_DISAMBIGUATION` / `PROMPT_LEAD_INTEL`) + [resources/prompts/lead_disambiguation.md](/resources/prompts/lead_disambiguation.md) · [lead_intel.md](/resources/prompts/lead_intel.md). Calls go through the shared [AiClient](/docs/modules_handbook/shared/ai/readMe.md).

**Backend — job + dispatch + command + controller**
- [app/Jobs/Lead/EnrichLeadJob.php](/app/Jobs/Lead/EnrichLeadJob.php) — queued, unique per lead.
- [app/Actions/RegisterLeadAction.php](/app/Actions/RegisterLeadAction.php) — dispatches after sign-up.
- [app/Console/Commands/EnrichLeads.php](/app/Console/Commands/EnrichLeads.php) — `leads:enrich` backfill.
- [app/Http/Controllers/Manage/Leads/LeadsController.php](/app/Http/Controllers/Manage/Leads/LeadsController.php) —
  `show` passes `enrichment`; `enrich` re-runs (route `manage.leads.enrich`).

**Frontend (Vue)**
- [resources/js/Pages/Manage/Leads/Partials/Tabs/IdentityTab.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/IdentityTab.vue) — the tab.
- [resources/js/Pages/Manage/Leads/Show.vue](/resources/js/Pages/Manage/Leads/Show.vue) — hosts the Identity tab.

**Spec** — none. This handbook *is* the design document; the `docs/specs/lead-enrichment.md` it used
to defer to was deleted in `8d1eea9d` (2026-06-29) when this handbook absorbed it — `docs/specs/`
no longer exists at all. Cost / ban-risk / model choices are all argued inline above (see
[Setup notes](#setup-notes)), so record future-phase decisions here rather than starting a side spec.
