# Lead Linking — `LeadLinker` (shared service)

**Portals:** every one · **Namespace:** `Src\Lead\Services\LeadLinker` + `Src\Lead\Support\*` · **Consumers (live):** Zoom (**three** linkers — poll respondents, webinar registrants, webinar attendees), WhatsApp (every contact link), CSV import (members **and** legacy bookings), Manage (the manual *New Lead* modal). **Still to migrate:** Meta lead-gen, walk-in check-in, Stripe, owner-listing, public registration, and the call module's import-time phone matching (`LeadMatcher`).

> **Not to be confused with the identity MATRIX.** `/register`, the public Property Match quiz and the public Rental Estimate form do **not** come through this service — they run [`PasswordlessAuth::completeRegistration`](/app/Services/Auth/PasswordlessAuth.php), which is the only thing in the codebase that can **merge accounts by itself** (see below, and [Users · Leads · Admins · Login · Register · Merge](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md) §9). This service links; it never merges.

> Read this **before** writing any code that links or creates a lead. It exists because 26 places already do, and each invented its own rules.

## What it does
Turns "here is an email and/or a phone — give me the one lead behind them" into a single call that also decides, uniformly, **how far the phone can be trusted**, **what attribution to record**, and **what outcome to report**.

It **wraps** the identity gate ([`LeadRepository::firstOrCreateForIdentity`](/src/Lead/Repositories/LeadRepository.php)) and never reimplements it. The gate already unified *who-is-who* — tolerant phone matching, email-wins conflict, staff→null, progressive backfill. A 2026-07-17 census of all **26** lead-linking entry points found everything *around* the gate was un-unified: 4 incompatible outcome vocabularies, an implicit 5-level phone-trust policy, only 5 of 26 writing a `lead_funnels` attribution row, and 4 hand-rolled copies of the gate itself. That drift had already produced duplicate people and blank-source leads. See the [Leads](/docs/modules_handbook/manage/leads/readMe.md) module for the gate's own semantics.

## How it works

Three calls, one classifier — so a dry run and a real run can never disagree:

| Call | Behaviour |
|---|---|
| `link()` | Reuse the person if they exist, else create account + lead. |
| `linkExisting()` | Reuse **only**; never create. Short-circuits unless a **lead** already exists (a matched account with *no* lead would otherwise get a lead, an AI-credit grant and a role stamp — the opposite of what this mode promises). |
| `preview()` | Classify read-only. Writes nothing at all. |

Each takes a [`LinkRequest`](/src/Lead/Support/LinkRequest.php) (named args), and returns a [`LinkResult`](/src/Lead/Support/LinkResult.php) carrying one `LeadLinker::OUTCOME_*` value.

- **Phone trust** (`LeadLinker::TRUSTS`) — `TRUST_NETWORK_VERIFIED` (WhatsApp `phone_e164`, a just-entered OTP) and `TRUST_TYPED` (an admin typing) may **resolve** identity; `TRUST_UNVERIFIED` (a public form, a free-text poll answer) may only **enrich**. `LinkRequest` **throws** if a phone arrives without a trust level, or with an unknown one — the policy cannot be forgotten or mistyped.
- **Outcome** (`LeadLinker::OUTCOMES`) — `MATCHED_EXISTING` / `CREATED` / `STAFF` / `WOULD_CREATE` / `UNLINKABLE`, classified *before* the write. The values deliberately match `ContactLinker::OUTCOME_*` so migrating WhatsApp later is a mechanical swap.
- **Attribution** ([`LeadAttribution`](/src/Lead/Support/LeadAttribution.php)) — declares the `lead_funnels` row, so a channel's leads stop showing a blank source on the Leads index. Defaults to `onlyOnCreate`, keyed on the **lead** being new (an account can exist with no lead — that lead genuinely did come from this source), so re-touching an existing lead never dilutes its real first-touch source.
- **Source** (`LeadLinker::SOURCES`) — every call names its entry point. It is echoed on `LinkResult`, **not** persisted; what actually records "where did this lead come from" is the attribution row.
- **Placeholder names are upgraded, real names never touched.** A lead seeded from a phone alone is *named after the number* (WhatsApp's `ContactLinker` does this, since a CRM row must not be nameless), so the CRM fills with rows called `+60123456789`. The gate's `fillEmptyName` cannot repair those — the name is not blank, it is a placeholder. So **any** source that later learns a real name (a CSV import, a webinar form, a WhatsApp pushname) upgrades it here, for every consumer, instead of each one remembering to. `isPhonePlaceholderName()` is strict on purpose — the value must be **nothing but** digits and phone punctuation and hold ≥ 7 digits — because a false positive would overwrite a customer's actual name. `"Ali 0123456789"` is a name; `"+60123456789"` is a placeholder; and a nameless WhatsApp touch (whose "name" IS the number) never swaps one placeholder for another.
- **Role — a linked lead can always sign in.** `LinkRequest::MAIN_PORTAL_ROLES` restricts `$role` to `MEMBER` / `NON_MEMBER` (the two `User::isMainUser()` accepts) and **throws** on anything else, so this service cannot mint an account that is unable to sign in to the portal it was just made a customer of — nor quietly turn a "lead" into staff. The gate fills a **blank** role set only, so a paying Member is never knocked back to Non-Member by a later touch. *(2026-07-17: this reversed a deliberate CSV-import rule — see `MemberImportTest::test_leads_mode_gives_a_matched_role_less_account_a_main_portal_role` for why the old "never re-role" premise no longer holds.)*

### ⚠️ The one rule you must not get wrong
**An untrusted phone is *structurally* withheld from the gate (`phone = null`) — never merely "vetted first".**

`PhoneNumber::canonicalDigits` is **not idempotent**: `000123456789` → `0123456789` → `60123456789`. So any check that inspects the *raw* phone while the gate resolves the *canonical* one is looking at a **different number**. Two real account-takeovers were reproduced on live code from exactly that split:

1. Vetting the raw phone, then handing it to the gate — the vet reported the number free, the gate bound its real owner, and the stranger's email was grafted onto that victim's blank-email account.
2. `classify()` resolving on the raw phone while the gate resolved the canonical one — `preview()` reported "new person", the Portal invite's duplicate guard stood down, the gate bound the victim, and a magic sign-in link was mailed to the grafted address.

Both are closed structurally: an untrusted phone never reaches the resolver, and `classify()` canonicalises **once** and uses that same string the gate will. Enrichment happens afterwards through `LeadRepository::backfillContact()`, which canonicalises *first* then vets, so it cannot disagree with itself.

Corollaries, both load-bearing:
- **Never use the strict `matchUserByPhone` as a refuse gate** — `phoneOwnedByAnother` is tolerant and fail-closed on purpose; its docblock explains why the strict form would let one real number land on two profiles.
- **Known latent, NOT fixed:** `canonicalDigits`' non-idempotence for the `00…` family still affects the trusted gate paths directly and keeps producing mixed stored shapes. Fixing `PhoneNumber` touches WhatsApp, calls and imports — a separate, wider change.

### ⚠️ This service LINKS — it never MERGES

`conflict` is the one flag on `LinkResult` that is not really an outcome: the email belongs to one
person and the phone to another, so the gate lets the **email win, drops the phone**, and returns
`conflict = true` ([`LeadRepository:386`](/src/Lead/Repositories/LeadRepository.php#L386) logs it). The
link *succeeds*. The **duplicate is left standing** — two accounts still describe one human, and nothing
in this service will ever collapse them.

That is deliberate. Collapsing two accounts moves a person's entire history and **retires a sign-in
key**, so it is authorised by **proof, not by a touch**: `/register` auto-merges only after the person
answered a code sent to the email *and* one sent to the phone in one sitting. A poll answer, a WhatsApp
message or a CSV row proves nothing of the kind, so the merge must never ride along with the link.

**But the duplicate still has to go somewhere, and that is the CALLER's decision.** Only three call
sites act on it today — all three *outside* this service:

| Caller | On conflict | Result |
|---|---|---|
| [`BulkMemberImportAction::mergeConflictPair`](/app/Actions/BulkMemberImportAction.php) | the admin ticks that row | merges on the spot, **customers only** (`note = 'auto: import (conflict tickbox)'`) |
| [`RegisterLeadAction`](/app/Actions/RegisterLeadAction.php) *(not yet migrated)* | typed phone owned by another email-holding account | files a **PENDING** pair — never merges |
| [`WebhookProcessor::fileMergeRequest`](/app/Services/Stripe/WebhookProcessor.php) *(not yet migrated)* | gateway buyer's two keys on two accounts | files a **PENDING** pair — never merges |

Every other consumer — both Zoom linkers, the webinar-attendee backfill, WhatsApp's `ContactLinker`, the
manual *New Lead* modal — gets `conflict = true` and **does nothing with it**. (The only one that even
reads the flag, [`ImportLegacyBookingsAction:609`](/app/Actions/ImportLegacyBookingsAction.php#L609),
uses it merely to avoid reporting one problem as two.) Those duplicates are therefore **invisible**: no
pair is filed, nobody is told, and the person keeps two accounts until they happen to complete
`/register`. Whether that is acceptable is a per-channel product call — but it must be a *decision*, not
something you discover later.

**Writing a new consumer, and a conflict matters to it?** File a pair and stop there —
`LeadRepository::detectMergePair($email, $phone)` → `createMergeRequest(...)` — which lands in the
**Setting → [Merge Requests](/docs/modules_handbook/manage/people/merge-requests/readMe.md)** queue for a
human. **Never call `mergeVerifiedPair()` from a link path.** That is the single function in the codebase
that retires an account, and it may only run behind dual-key proof (`/register`), an admin's explicit
tick (the import), or an admin's explicit confirm (the Lead / Admin edit dialog) — full matrix in
[Users · Leads · Admins · Login · Register · Merge](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md) §9.

### ⚠️ A name is *not* a key — the suggestion door

**`suggestByName()` proposes; only an admin's tick links.** A name is the weakest key imaginable (`Tan Wei Ming` is thousands of people) and, unlike a phone, it has no shape, no verification and no ownership to check. Wiring it into `classify()` would hand every caller the same takeover the trust ladder exists to prevent — so the gate stays **name-blind**, and the name gets a deliberately separate, read-only door:

| | What it is | Writes? |
|---|---|---|
| `suggestByName($name, $viewer, $limit)` | Candidate leads for a **human** to look at. Excludes staff; scoped to `$viewer`'s visible leads (a suggestion box must not leak names/emails from outside your scope). Matches the name as typed **and** with spaces squashed (`Hoonglim` finds `Hoong Lim`). | ❌ Nothing |
| `linkConfirmed($request, $lead)` | Links a lead the **admin picked** — an operator's decision, the same authority as typing the email by hand. Still routed through the service so it inherits the staff refusal, uniform attribution, guarded backfill and placeholder-name upgrade. | ✅ |

**Never auto-apply the top suggestion.** The caller is responsible for having visibility-checked the lead it hands to `linkConfirmed()` — a uuid from a request payload is a *request*, not a grant.

### Adopting a name — the one contact field this layer overwrites

Every other contact write here fills a **blank** only (`backfillContact`, `fillEmptyName`, `upgradeProfileName` all refuse a populated value). `adoptName(User, ?name, ?reviewerId)` is the single deliberate exception: it OVERWRITES a real name an admin explicitly chose from an import.

The exception is safe for the **name and only the name**, because a name is not an identity key — nothing signs in, resolves, or matches on it (the sole name lookup, `suggestByName`, is a human-confirmed suggestion). Email/phone have no `adopt*` method by design: overwriting a login credential in bulk is a different, higher-stakes decision that belongs on the per-lead edit form. `adoptName` writes through Eloquent (so model hooks fire), **throws on a staff account**, and **logs old→new + reviewer** — the only place the system overwrites a real name on an admin's say-so, so a wrong one is traceable.

### Placeholder email — the admin's escape hatch

The gate **refuses** a person with no email and no phone: there is no key to converge on, so it cannot promise one person = one account. A legacy record carrying only a name is exactly that. `LeadLinker::placeholderEmail($name)` manufactures the missing key so an admin can consciously file the row anyway.

**The per-person uniqueness is load-bearing, not cosmetic.** One shared address (`notfound@notfound.com` for everyone) would make every nameless row **match the first one through the gate** — silently collapsing N different humans into one lead with N bookings. So the token is derived from the *name*: `notfound+<sha1(name)[0:12]>@notfound.com`. Same name → same address → same lead (idempotent, and the only merge it can cause is the one the admin asked for by ticking). Different names → different leads.

`isPlaceholderEmail()` recognises them. **Anything that would SEND to a lead should check it** — these are stand-in keys, not mailboxes, and `notfound.com` is a domain someone else really owns. (Not currently enforced in the magic-link path; low risk, since sign-in is initiated by the person typing their own address. Switch `PLACEHOLDER_EMAIL_DOMAIN` to `notfound.invalid` if that ever stops being true.)

> **MINTING and RECOGNISING are separate lists (2026-08-07).** New placeholders are always minted under
> `PLACEHOLDER_EMAIL_DOMAIN` (`notfound.com`), but `isPlaceholderEmail()` checks
> **`PLACEHOLDER_EMAIL_DOMAINS`**, which also covers stand-ins that arrived with imported data and were
> never ours to mint — currently **`noemail.local`** (`booking_718@noemail.local`, from the legacy
> booking import). Until it was listed, nothing recognised it: every send path treated it as a real
> address and hard-bounced against it, and an account merge saw the survivor as "already has an email"
> and therefore **destroyed the other side's real one**. Adding a domain here is how you retire a
> stand-in convention you inherited — never by teaching `placeholderEmail()` to mint it.

## Reference usage

**[`Src\Zoom\Services\PollRespondentLinker`](/src/Zoom/Services/PollRespondentLinker.php)** is the canonical consumer — a domain linker that owns *only* its own domain and delegates every identity decision:

```php
return new LinkRequest(
    source: LeadLinker::SOURCE_ZOOM_POLL,
    email: $email,                                   // Zoom's registration-verified email
    phone: $this->phoneFor($email),                  // read out of a poll ANSWER…
    phoneTrust: LeadLinker::TRUST_UNVERIFIED,        // …so it may NEVER resolve identity
    name: $this->nameFor($email),
    attribution: LeadAttribution::source(LeadFunnel::SOURCE_OTHER),
);
```

What it keeps: reading a phone/name out of poll answers, and what counts as a usable email (Zoom writes the literal string `anonymous`). What it hands over: matching, trust, staff handling, attribution, outcome. Its `link()` / `linkExisting()` / `preview()` are thin wrappers that add only "attach the resolved lead to this respondent's answers".

The trust line is the point. Live poll data answers a *"Mobile"* prompt with `2222`, `Han` and `weij dksfd` — declaring `TRUST_UNVERIFIED` is what stops a mistyped digit handing one person's answers to whoever really owns that number.

**Contrast — the same number, three trust levels.** This is the whole point of `PhoneTrust`:

| Where `0123456789` came from | Trust | May it identify a person? |
|---|---|---|
| An inbound WhatsApp message | `TRUST_NETWORK_VERIFIED` | ✅ Yes — the network proved they control it, so it can bind an account **on its own** (an inbound message carries no email). |
| An admin typing a CSV / New Lead | `TRUST_TYPED` | ✅ Yes — the typist is trusted not to impersonate. |
| A poll answer | `TRUST_UNVERIFIED` | ❌ **No** — enrichment only. |

**Other consumers:**
- [`Src\Whatsapp\Services\ContactLinker`](/src/Whatsapp/Services/ContactLinker.php) — the phone-only, `TRUST_NETWORK_VERIFIED` case, and the one that needs `LinkResult::$user` for staff (it links `contact.user_id` to the admin account without fabricating a lead). Its `OUTCOME_*` constants are now **aliases** of `LeadLinker`'s, so the two can never drift; `OUTCOME_ALREADY` stays its own because "this contact row is already linked" is a question about the contact, not the person.
- [`Manage\Leads\StoreRequest`](/app/Http/Requests/Manage/Leads/StoreRequest.php) — the **preview-inside-validation** shape, and the one that must ask `personIsKnown()` rather than `wasMatched()`: it has to refuse an existing **account**, even one that has no lead yet.
- [`Manage\Leads\ResolveRequest`](/app/Http/Requests/Manage/Leads/ResolveRequest.php) + `LeadsController@resolve` — the shared **lead ComboBox**'s create hatch, on all 13 admin lead pickers. Same gate, one branch different: a person the admin may see is MATCHED and handed back rather than refused, because a picker's job is to end up holding a lead. `TRUST_TYPED`, attribution `SOURCE_OTHER` (an admin typed it) unless the surface overrides. See [shared/lead-combobox](/docs/modules_handbook/shared/lead-combobox/readMe.md) — read it before adding a 14th picker.

> **Removed 2026-07-17 — the Portal Engagement invite form.** It was redundant with the Leads page's *New lead* modal, which mints the same account + lead and additionally records a source. Gone: the `users.store` / `users.resend` routes, `UsersController::store/resendInvite/magicLink/sendSignInEmail/waLink`, `InviteRequest`, `InviteModal.vue` and `PortalInviteTest`. `Manage\Portal\UsersController` is now **read-only** (index + export). **Accepted loss:** nothing surfaces a magic sign-in link for an admin to copy or share on WhatsApp any more. (`WelcomeSignInMail`, which at the time still fired from `RegisterLeadAction`, was itself dropped from the flow on 2026-07-25 and deleted 2026-09-11 — the credential now rides the funnel's WhatsApp welcome as `{{login_link}}`.)

## Related files

**Backend**
- [src/Lead/Services/LeadLinker.php](/src/Lead/Services/LeadLinker.php) — the service; `OUTCOME_*`/`OUTCOMES`, `TRUST_*`/`TRUSTS`, `SOURCE_*`/`SOURCES`, `trustResolves()`; `link()` / `linkExisting()` / `preview()`; the name door `suggestByName()` / `linkConfirmed()`; `placeholderEmail()` / `isPlaceholderEmail()`.
- [src/Lead/Repositories/LeadRepository.php](/src/Lead/Repositories/LeadRepository.php) `adoptName()` — the one contact write that OVERWRITES (name only; staff-throw; logged). Every other contact write in this file fills blanks only.
- [src/Lead/Support/LinkRequest.php](/src/Lead/Support/LinkRequest.php) — immutable input; validates trust + source, refuses a phone without a trust level.
- [src/Lead/Support/LinkResult.php](/src/Lead/Support/LinkResult.php) — outcome + lead + conflict + source; `wasCreated()` / `wasMatched()` / `isStaff()` / `hasLead()` / `outcomeLabel()`.
- [src/Lead/Support/LeadAttribution.php](/src/Lead/Support/LeadAttribution.php) — declares the `lead_funnels` row; `source()` shorthand; `onlyOnCreate`.
- [src/Lead/Repositories/LeadRepository.php](/src/Lead/Repositories/LeadRepository.php) — the wrapped gate (`firstOrCreateForIdentity`, `resolveUserByIdentity`, `backfillContact`, `phoneOwnedByAnother`).
- [src/Lead/Repositories/LeadFunnelRepository.php](/src/Lead/Repositories/LeadFunnelRepository.php) — `attach()`, how attribution is written.
- [src/Common/Support/PhoneNumber.php](/src/Common/Support/PhoneNumber.php) — `canonicalDigits` / `candidates` / `sameNumber` / `sameNumberTolerant`.

**Consumers (migrated — every import / sync path)** — trust level and attribution per consumer:
- [src/Zoom/Services/PollRespondentLinker.php](/src/Zoom/Services/PollRespondentLinker.php) — see *Reference usage*. `TRUST_UNVERIFIED` (a poll phone is free text) · attribution `LeadAttribution::source(LeadFunnel::SOURCE_OTHER)`.
- [src/Zoom/Services/WebinarRegistrantLinker.php](/src/Zoom/Services/WebinarRegistrantLinker.php) — mints/matches a lead for every Zoom **webinar registrant** when an externally-scheduled webinar is adopted as a funnel session ("Link Zoom webinar" → `ImportWebinarRegistrants`). Email-keyed; the registration form's self-typed phone is offered as **`TRUST_UNVERIFIED`** (enrich-only — the takeover boundary), none → `TRUST_NONE`. `SOURCE_ZOOM_WEBINAR` · attribution `LeadAttribution(SOURCE_OTHER, funnelId: the adopting session's funnel, onlyOnCreate)`.
- [src/Zoom/Services/WebinarAttendeeLinker.php](/src/Zoom/Services/WebinarAttendeeLinker.php) — mints a lead for every Zoom **webinar attendee** the read-only `WebinarAttendeeMatcher` left unmatched: `linkByEmail()` inside `ReconcileWebinarAttendance` (each future webinar as it ends) **and** the account-wide `link`/`linkExisting`/`preview` backfill (`zoom:link-webinar-attendees`, `--dry-run` first) over the history. Email-keyed; a webinar attendee carries no proven phone so **none** is offered (`TRUST_NONE`) — nothing to withhold, no takeover surface; email-less participants (a display name is not an identity key) and staff are skipped. `SOURCE_ZOOM_WEBINAR` · attribution `LeadAttribution(SOURCE_OTHER, funnelId: the webinar's funnel — or null for the un-funneled backfill, onlyOnCreate)`.
- [src/Whatsapp/Services/ContactLinker.php](/src/Whatsapp/Services/ContactLinker.php) — covers **7 WhatsApp call sites** (inbound auto-link, CTA capture, outbound bootstrap, connect-time history settle, the sync-card, `whatsapp:link-contacts`, proactive flow start). `TRUST_NETWORK_VERIFIED` (the network proved control of the number) · **no attribution** — it passes `null`.
- [app/Actions/BulkMemberImportAction.php](/app/Actions/BulkMemberImportAction.php) — the CSV lead/member import. **Only its identity call** was moved: it still owns everything about a CSV *row* (the changed-contact diff → `OUTCOME_NEEDS_REVIEW`, the two modes, the admin's per-row confirmation), because those questions are richer than `OUTCOME_*` and flattening them would destroy the confirmation flow. `resolveOrCreateLead()` replaced three different repository entry points with one linker call. `TRUST_TYPED` (an admin typed it) · attribution `LeadAttribution::source(LeadFunnel::SOURCE_OTHER)`. **Note the asymmetry:** only `apply()` routes through the linker; `preview()` still classifies with the action's own private `classify()`, so a dry run and the write do **not** share this classifier here. **2026-07-21:** a leads-mode conflict row (email/phone owned by two accounts) can be **auto-merged on the admin's explicit tick** — the action calls the account-merge machinery (`detectMergePair` → `pickMergeSurvivor`, richer side survives, **customers only** — a staff pair is refused) → `createMergeRequest` → `mergeVerifiedPair`, then re-resolves the row as a normal match on the survivor. Same `verified_identity_pairs` audit trail as the review modal.
- [app/Actions/ImportLegacyBookingsAction.php](/app/Actions/ImportLegacyBookingsAction.php) — the legacy property-bookings CSV → per-`(lead, project)` engagements/bookings, and the **heaviest** consumer: it uses five surfaces — `preview()`, `link()`, `linkConfirmed()` (for a row the admin picked), `suggestByName()`, and the static `placeholderEmail()` (to file a booking known only by a name). `TRUST_TYPED` · attribution `LeadAttribution::source(LeadFunnel::SOURCE_OTHER)`.
- [app/Http/Controllers/Manage/Leads/LeadsController.php](/app/Http/Controllers/Manage/Leads/LeadsController.php) `store()` — the admin's manual **New Lead** modal. Its [`StoreRequest`](/app/Http/Requests/Manage/Leads/StoreRequest.php) previews with the **same** call, so validation and the write share one classifier: it used to resolve on the RAW phone while the gate resolves on the canonical one, and would wave through a person the write then recognised. `TRUST_TYPED` · attribution `new LeadAttribution(marketingSource: (int) $request->input('source'), onlyOnCreate: false)` — the **only** call site that passes `onlyOnCreate: false`, because the admin explicitly chose the source.

**Not yet migrated** — each is its own careful change:
- **The remaining inline gate copies** — `SyncFlgLeadToCrmAction` (Meta lead-gen webhook) and `CheckInController` (event walk-in): they hand-roll resolve→create *outside one transaction*, set `password = email`, and store non-canonical phones.
- Stripe (`WebhookProcessor` — payment-link / portal buyers; still not routed through this service, **but since 2026-08-01 it enforces the same trust boundary inline**: the checkout's self-typed phone is filtered through `LeadRepository::selfTypedPhoneForGate` — resolve only as an email-fallback and only an email-less record, withhold + auto-file a merge request when the number belongs to an email-holding account. Migrating it here would still add attribution + placeholder-name handling), the call module's import-time matching (`Src\Call\Support\LeadMatcher` — a *different* class, not this service), `ProcessOwnerListingJob`, and `RegisterLeadAction` (**email-keyed** — a typed phone may never resolve an account that already has an email).

  > ### The public capture form's five identity shapes (2026-07-29)
  >
  > Two rules, deliberately separate — conflating them is what made this dangerous. Pinned end-to-end in [`tests/Feature/Main/LeadCaptureIdentityScenariosTest.php`](/tests/Feature/Main/LeadCaptureIdentityScenariosTest.php).
  >
  > **Rule 1 — who is this?** The EMAIL decides, with ONE exception: a record with **no email of its own** may be claimed by the phone (`LeadRepository::emaillessOwnerOfPhone`), because such a record has no sign-in key to take from anybody. A phone never resolves a record that already has an email.
  >
  > **Rule 2 — may the welcome carry an auto-sign-in link?** Only when **no email-holding account existed beforehand** *and* the delivery number is the account's own stored phone. This is about the account's PRIOR state, not the phone: "the phone is already on the account" looks equivalent but is not — once a stranger's number has been adopted it *is* already on the account, so a later welcome backfill would hand out the credential after all.
  >
  > | Typed email / phone vs. what is on file | Outcome |
  > |---|---|
  > | Neither on file | New account with both keys · auto-login ✅ |
  > | Email matches, phone slot **blank** | Phone filled · auto-login ❌ |
  > | Phone matches a record with **no email** | Email adopted onto it, no duplicate · auto-login ✅ |
  > | Email matches, stored phone **differs** | New phone **adopted** (`adoptPortalPhone`) unless the stored one is **verified** · auto-login ❌ |
  > | Phone belongs to a **different email-holding** account | New account, phone **not taken**; bonus still delivered via the send's `deliver_to`; a **pending merge** is raised for admin (`createMergeRequest`) · auto-login ❌ |
  >
  > Overwriting a stored phone (row 4) is a deliberate product decision — a customer who changed numbers must not need a support ticket. It is only safe *because* of Rule 2: re-pointing the number can no longer re-point a credential with it. The two halves must be changed together or not at all. See [Landing & Lead Capture](/docs/modules_handbook/main/landing-lead-capture/readMe.md).

**Password:** a lead minted through this service gets `Str::random(40)` (`LeadRepository:294`), auto-hashed by `User::setPasswordAttribute`. Nobody — including the system — knows it, so these accounts sign in by **magic link only**. That retires the `password = email` convention on every migrated path; `CheckInController` and `AdminsController` still carry it.

**Attribution status, honestly:** Zoom polls, both CSV imports (members + legacy bookings) and the manual New Lead declare one. **WhatsApp passes `null`**, preserving today's behaviour — so WhatsApp leads still show a **blank source** on the Leads index. Filling that is a product decision (which `LeadFunnel::SOURCE_*` label each channel carries — today Zoom polls and both CSV imports all land on `Other`, so they are indistinguishable there), not a code one.

**Tests**
- [tests/Feature/Lead/LeadLinkerTest.php](/tests/Feature/Lead/LeadLinkerTest.php) — the guarantees: both takeover regressions, the trust distinction, conflict, attribution, preview/apply agreement.
- [tests/Feature/Zoom/PollRespondentLinkerTest.php](/tests/Feature/Zoom/PollRespondentLinkerTest.php) — the reference consumer.

**Cross-links:** [Leads](/docs/modules_handbook/manage/leads/readMe.md) (the gate + lead model) · [Users · Leads · Admins · Login](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md) (the identity foundation) · [Merge Requests](/docs/modules_handbook/manage/people/merge-requests/readMe.md) (where a conflict this service refuses to resolve goes) · [Zoom](/docs/modules_handbook/manage/zoom/readMe.md) (the poll backfill that consumes this).
