# Users · Leads · Admins · Login · Register · Merge (shared foundation)

**Portals:** Manage (admin) + Main (user) · **Auth:** PASSWORDLESS for customers (6-digit OTP by email or phone SMS/WhatsApp); admins additionally keep a password · **Routes:** `login`, `manage.login` (+ `manage.login.store`), `auth.passwordless.request`, `auth.login-link`, `auth.otp.verify`, `register.show`, `auth.register.start` / `auth.register.verify`, `auth.magic`, `logout` · **Guard:** single `web` session

This is the **identity foundation** every other module sits on. Read it before touching anything
that creates accounts, checks "who is this user", or reads membership. It answers three questions
the rest of the codebase assumes you already know:

1. **A lead *is* a user.** There is no duplicate lead per user — `leads.user_id` is **unique** (1:1).
2. **`lead_id` is the canonical foreign key** for everything the main-portal user owns (wealth plans,
   concierge requests, property analyses, AI keys/credits, membership subscriptions, Zoom meetings).
3. **Login knows what you are from your role** (`super-admin` / `admin` / `member` / `non-member`),
   and a member's **membership(s)** are read live from their **lead's active subscriptions**.

---

## What it does

One shared `User` account serves **both portals**; which portal you land in is decided by your
**role**, not by a separate guard or table. Identity is split across small, single-purpose tables:

| Table | Model | Holds | Key |
| --- | --- | --- | --- |
| `users` | [`Src\People\User`](/src/People/User.php) | auth only: `email`, `password`, `status` | `id` (PK) + `uuid` (public) |
| `user_profiles` | [`Src\People\UserProfile`](/src/People/UserProfile.php) | the **person**: `full_name`, `phone`, ID docs, DOB, gender | `user_id` (1:1) |
| `admins` | [`Src\People\Admin`](/src/People/Admin.php) | **admin-only** data: `position`, `employee_no`, `notes` | `user_id` (1:1, unique) |
| `leads` | [`Src\Lead\Lead`](/src/Lead/Lead.php) | the **main-portal user as a CRM record** + ad/source attribution | `user_id` (1:1, **unique**) |
| `member_subscriptions` | [`Src\Membership\MemberSubscription`](/src/Membership/MemberSubscription.php) | a lead's enrolment(s) in memberships | `lead_id` → `membership_id` |
| `memberships` | [`Src\Membership\Membership`](/src/Membership/Membership.php) | the catalogue (tiers): price, terms, benefits | `id` |
| `roles` / `model_has_roles` | [`Src\Auth\Role`](/src/Auth/Role.php) (spatie) | the role that decides portal + "is a member" | pivot on `users.id` |

**Rule of thumb:** `users` = login, `user_profiles` = the human, `admins` = back-office staff data,
`leads` = the customer hub (everything they own hangs off `lead_id`), `member_subscriptions` =
what they've paid for.

### Admin + Lead are HATS, not exclusive roles (2026-07-14)
A `User` is the person; `admins` and `leads` are both 1:1 facets they can wear **at the same time**
(both tables key on a unique `user_id`, so nothing stops one `user_id` from having a row in each).
The rule is **one-way by privilege**:

- **Admin hat = manage-portal access.** Only ever granted **deliberately** (a super-admin creates or
  *promotes* an admin) — never inferred. A plain customer can never reach the back office.
- **Lead hat = the person's own portal data** (AI, wealth, membership, …). **Every admin also
  carries one**, flagged **`leads.is_staff = true`**, so staff can use — and **write** — the
  member-portal features exactly like a customer (their data resolves `user()->lead`). It is created
  at admin creation / promotion (`UserRepository::ensureStaffLead`, backfilled for existing admins).

**An admin is a FULL lead** — visible everywhere a lead is (the Leads list, counts, pipeline), just
**badged "Admin"** so you can tell them apart (`is_staff` is a **label**, not a filter). Promoting a
customer to admin therefore **keeps all their records** (engagements, bookings, membership, attribution
stay put and visible); it only adds the admin hat + the Admin label. **Portal writes** are open to
admins too: [`EnsureUserIsMainUser`](/app/Http/Middleware/EnsureUserIsMainUser.php) allows a write when
the user is a main-portal role **or** holds a Lead facet, so an admin creates/edits their own wealth
plans, AI chats, concierge requests, etc.

> The CRM identity gate (`firstOrCreateForIdentity`) still returns **null** for a staff account (a
> marketing touch never *fabricates* a sales lead for staff), but the staff person's existing lead is
> reachable and fully visible. See [Admins](/docs/modules_handbook/manage/people/admins/readMe.md) for
> the create/promote flow.

---

## How it works

### 1. One account, two portals — the role decides

There is **one session guard** (`web`) and **one provider** for both portals — see
[`config/auth.php`](/config/auth.php). Portal access is enforced by **role middleware**, never by a
separate guard. Roles are [spatie/laravel-permission](/config/permission.php) and live in
[`Src\Auth\Role`](/src/Auth/Role.php):

- **Manage (admin) portal** → `super-admin`, `admin`  (`Role::manageRoles()`)
- **Main (user) portal** → `member`, `non-member`  (`Role::mainRoles()`)

The `User` model exposes the canonical "what am I" helpers — **always use these, never inspect roles
by hand** ([`src/People/User.php:238-281`](/src/People/User.php#L238-L281)):

```php
$user->isSuperAdmin();  // hasRole('super-admin')
$user->isAdmin();       // hasRole(['super-admin','admin'])      → Manage portal
$user->isMember();      // hasRole('member')
$user->isNonMember();   // hasRole('non-member')
$user->isMainUser();    // hasRole(['member','non-member'])       → Main portal
$user->roleName();      // the primary (first) role's name, for display
```

A user holds **one** portal role at a time (`UserRepository::changeRole()` / `create()` call
`syncRoles([$role])`, which replaces all roles).

### 2. The person lives on `user_profiles`, not `users`

`users` carries **no name**. Display name is always
`$user->profile->full_name` with an email fallback (GUIDELINES §4.8):

```php
$user->profile ? $user->profile->full_name : $user->email;
```

`User` eager-loads `profile`, `roles`, `admin` by default (`$with` in
[`src/People/User.php:70-74`](/src/People/User.php#L70-L74)), so these are free to read.
`user_profiles.phone` is stored digits-only (country code, no symbols) and is **unique**, which is
what lets login resolve a phone number back to an account.

### 3. Admin identity = a manage role **plus** an `admins` row

"Admin" is two things working together — and **only the role classifies you**:

- **Authorization (the source of truth)**: the `super-admin` / `admin` role. `isAdmin()` and the
  `admin` middleware check the **role only** — never the `admins` row. (An `admins` row without the
  role would *not* grant portal access, and vice-versa.)
- **Admin-only data (not a classification)**: a 1:1
  [`admins`](/database/migrations/2026_06_03_000002_create_admins_table.php)
  row (`user_id` unique) holding the admin's **`position`** — a constant on the model
  ([`Admin::POSITIONS`](/src/People/Admin.php#L21-L33): Caller / Appointment / Closer / Marketing /
  Normal). Position is **not** a role; it once lived on `users.position` and was moved to `admins`
  (migration `…000004_drop_position_from_users_table`).

Position is **sales-workflow metadata only — it no longer gates Zoom (or anything else).** The
former position-based Zoom gate (`Admin::ZOOM_POSITIONS` / `Admin::requiresZoom()` /
`User::canUseZoom()` / `User::isCloser()` and the `zoom` route middleware) was **removed** in the
Server-to-Server Zoom refactor (2026-06-23); Zoom access is now *"is this email a user on the
connected Zoom account"* — `ZoomServerService::isAccountUser($email)`, independent of position. See
the [Zoom](/docs/modules_handbook/manage/zoom/readMe.md) doc. Super-admins still have **no** `admins`
row by default (created on first need via `UserRepository::ensureAdmin()`).

### 4. A lead **is** the main-portal user (the hub)

[`Src\Lead\Lead`](/src/Lead/Lead.php) (`leads` table) is the **CRM record for a main-portal user** —
it is the same person as the `User`, viewed from the funnel/customer side. The link is strict 1:1:

```php
// User → Lead  (src/People/User.php:125)
public function lead(): HasOne { return $this->hasOne(Lead::class, 'user_id'); }
// Lead → User  (src/Lead/Lead.php:87)
public function user(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); }
```

**"No duplicate lead per user" is enforced in the schema** — `leads.user_id` is
`nullable()->unique()`
([`…042762_create_leads_table.php:20`](/database/migrations/2020_05_25_042762_create_leads_table.php#L20),
comment: *"Non-Member account (1:1)"*). The unique index is the hard backstop; every repository
entry point also guards with `firstOrCreate` keyed on `user_id`. Precise shape of the relation:

- It is **at-most-one (0..1)**, not a strict bijection. `user_id` is nullable, and a portal user who
  arrived outside the funnel may have **no lead until one is lazily created on first portal access**
  (`LeadRepository::firstOrCreateForUser`). MySQL allows many NULLs under a unique index, but **no
  code path creates a CRM lead with a null `user_id`** — every creation site sets it.
- **Staff are never *fabricated* into a CRM lead** — the identity gate
  (`firstOrCreateForPhone` → `firstOrCreateForIdentity`) returns `null` when the user has an `admins`
  row, so a marketing touch never mints a sales prospect out of a colleague. Their **own** Lead facet
  is created deliberately instead (`ensureStaffLead` at admin creation, `ensureLeadForPortal` on first
  portal access) and badged `is_staff` — see §4's HATS rule above.

**`lead_id` is the canonical owner key for portal-owned data.** Everything a main-portal user creates
hangs off the lead, not the user. There are two grades of `lead_id`:

- **NOT NULL owner** (the data belongs to the lead): `member_subscriptions` — literally re-keyed from
  `user_id` → `lead_id`
  ([`…000005`](/database/migrations/2026_06_13_000005_rekey_member_subscriptions_to_lead.php)) — plus
  `wealth_plans`, `wealth_whopay_reports`, `concierge_requests`, `property_analyses`, where the old
  `member_id` (a *user* id) was dropped and replaced by a **NOT NULL** `lead_id`
  ([`…000006`](/database/migrations/2026_06_13_000006_make_lead_id_owner_on_portal_tables.php)).
- **Nullable attribution** (links back to the lead but isn't owned by it): `ai_credentials`,
  `lead_ai_credits`, `event_registrations`, `zoom_meetings`, `zoom_recordings`, `call_recordings`,
  `ai_requests`, etc.

So when you need "the things this signed-in member owns", go **`$user->lead`** then the lead's
relations — see [`src/Lead/Lead.php`](/src/Lead/Lead.php). (`user_id` itself legitimately remains on
`leads` — the hub link — and on a few infra tables like `whatsapp_contacts` / `facebook_integrations`;
"prefer `lead_id`" is the rule for *customer-owned* data, not a ban on `user_id` everywhere.)

> ⚠️ **Two different "Lead" classes — do not confuse them.**
> - [`Src\Lead\Lead`](/src/Lead/Lead.php) (`leads`) — the CRM hub described here = the user.
> - [`Src\FacebookLeadGenerator\Lead`](/src/FacebookLeadGenerator/Lead.php) (`flg_leads`) — a **raw
>   Meta lead-gen webhook capture** (name/phone/email + ad payload). It is *not* a user. It becomes a
>   real CRM lead via [`SyncFlgLeadToCrmAction`](/app/Actions/SyncFlgLeadToCrmAction.php), which
>   creates the `User`+`Lead` and stores the link as `leads.flg_lead_id`.

### 5. Membership = a lead's active subscription (role is a synced cache)

A `Membership` is a catalogue entry (tier) with its own price/terms/benefits. A member enrols via a
`MemberSubscription` keyed on **`lead_id`** (not `user_id`). A lead may hold **several** active
subscriptions at once (e.g. *Elite + AI Basic*).

**The source of truth for "is a member" is the lead's active subscription**, *not* the role:

```php
// Lead (src/Lead/Lead.php:144) — the truth
$lead->hasActiveSubscription();   // any MemberSubscription with status = STATUS_ACTIVE
// User (src/People/User.php:136,147) — convenience over the lead
$user->hasActiveMembership();     // optional($this->lead)->hasActiveSubscription()
$user->activeMemberships();       // Collection of {id, code, name}, sorted
```

The `member` / `non-member` **role is a cache** kept in sync with that truth so the fast in-memory
gates (`member` middleware, `User::isMember()`) stay correct. After **any** subscription change
(enrol / cancel / expire) call
[`SyncMembershipRoleAction::forLead()`](/app/Actions/SyncMembershipRoleAction.php) — it promotes the
lead's user to `member` when an active subscription exists, demotes to `non-member` when none do
(idempotent; never touches admins). Enrolment itself is
[`EnrollMemberAction`](/app/Actions/EnrollMemberAction.php), which records the subscription **and**
calls the sync action.

**There is no auto-expiry.** Only the integer `status` gates "active" — there is no `expires_at` and
no scheduled job that flips `STATUS_ACTIVE` → `STATUS_EXPIRED`; cancellation/expiry are manual writes
(`paid_at` is the only date column). Because the role is a cache, `SyncMembershipRoleAction` is
**load-bearing**: skip it after a subscription change and the `member` role will silently diverge from
the real subscription state (mis-gating the portal). Always route subscription writes through the
actions, never raw.

> The schema was **flattened on 2026-06-16**: versioned terms (`membership_versions`) and upsells
> (`type` / `parent_id`) were removed — a membership is now a single editable record
> ([`…000003_simplify_membership_schema.php`](/database/migrations/2026_06_16_000003_simplify_membership_schema.php),
> `…000004_drop_membership_version_from_member_subscriptions.php`).

### 6. Login & session — how the app authenticates you

All auth routes are shared (single `web` guard) and declared in
[`routes/main.php`](/routes/main.php#L61-L103). The custom provider
[`Src\Auth\Providers\UserProvider`](/src/Auth/Providers/UserProvider.php) (the **`gsc`** driver) hydrates
`Src\People\User` **and rejects a banned / inactive account at session-resolve time** — so the status
gate holds even for the remember-me cookie, which no middleware could reach. (The old global
`EnsurePasswordReset` middleware was removed with the forgot-password flow — see
[`app/Http/Kernel.php:28`](/app/Http/Kernel.php#L28).)

**Customers are PASSWORDLESS.** No customer password exists — every auto-created account carries a
random 40-char password nobody holds, so the only way in is a **one-time 6-digit OTP** — emailed, or
sent to the phone over SMS / WhatsApp. **Admins additionally keep a password** so staff can still sign in when a mail / SMS gateway is
down. Both pages — `/login` (customer, `portal=main`) and `/manage/login` (admin, `portal=admin`) —
render the **same** [`Auth/Login`](/resources/js/Pages/Auth/Login.vue) →
[`LoginForm`](/resources/js/Components/Auth/LoginForm.vue) (same flow, different skin), served by
[`LoginController@create` / `createManage`](/app/Http/Controllers/Auth/LoginController.php#L36).

> **The `/register` dual-code UI is a SHARED component** —
> [`Components/Auth/ContactVerification.vue`](/resources/js/Components/Auth/ContactVerification.vue)
> (email + phone + channel + two-code entry, its own step machine + Inertia `useForm`s, posting to
> `startUrl`/`verifyUrl` props). [`Auth/Register.vue`](/resources/js/Pages/Auth/Register.vue) and the
> public [Property Match](/docs/modules_handbook/main/property-match/readMe.md) quiz both mount it, so a
> change to the verification UX reflects in both at once. Property Match reuses the same OTP engine but
> links a lead instead of signing in (2026-07-23).

The **six** ways a session is established:

- **Passwordless email OTP** — `@request` with `via=email` (`POST auth.passwordless.request`, throttled
  `6,1`) mails a 6-digit code and stashes the challenge id **server-side in the session**. Same code
  screen, same verify endpoint as the phone route below. *(It used to mail a magic LINK; changed
  2026-09-02 so the sign-in finishes in the tab the person started in — a link opened in a phone's mail
  app landed the session on the wrong device, and email/phone now behave identically.)*
- **Passwordless phone OTP** — the same `@request` with `via=phone` SMS/WhatsApp-es a 6-digit code the
  same way. Both are verified by
  [`@verifyOtp`](/app/Http/Controllers/Auth/PasswordlessLoginController.php) (`POST auth.otp.verify`,
  throttled `10,1`) → signs in. **Which key** the code proved is read from the challenge payload
  (exactly one of `phone` / `email` is filled), never re-trusted from the client, so an email challenge
  can never be replayed as a phone sign-in.
- **Legacy email magic-link** — [`@magic`](/app/Http/Controllers/Auth/PasswordlessLoginController.php)
  (`GET auth.login-link/{token}`). **Nothing issues new links any more**; the route + `sendEmailLink()`
  stay wired up only so links already sitting in an inbox work out their 20-minute TTL. Delete both once
  that window has long passed.
- **Admin password** — [`LoginController@storeManage`](/app/Http/Controllers/Auth/LoginController.php#L98)
  (`POST manage.login.store`, throttled `5,1` — **the one endpoint where a secret can be guessed**).
  Staff-only by construction: a non-manage account is refused **even with the correct password**, and
  every failure returns one identical message (never an oracle for who holds a privileged account).
- **Signed magic link** — [`MagicLoginController`](/app/Http/Controllers/Auth/MagicLoginController.php)
  (`GET auth.magic`, behind **`signed`**). Signs in **only** an active **main-portal** user (never an
  admin, even with a valid signature). **Two flavours, told apart by signed query params:**
    - *Plain* (`user` only) — the legacy emailed link. Still re-checks the full verification gate below.
    - *WhatsApp-delivered* (`ch=wa` + `ph=<digest>`) — minted per send by
      `FunnelWhatsappComposer::loginLink()` for the funnel welcome's `{{login_link}}`, carrying a digest
      of the **exact number the message went to**. Receiving it over WhatsApp is *network proof* the
      person holds that phone, so a click stamps `phone_verified_at` and signs them in **on that one key**
      — the email is then proven inside the portal (see the Extra Bonus overlay below). The digest must
      still match the account's CURRENT phone, so a forwarded link only ever verifies the number it was
      actually sent to, and correcting the phone silently retires every link issued before the change.
      Both params ride **inside** the HMAC, so neither can be forged or transplanted.

      **A tap always proves the PHONE. Whether it opens a SESSION is a separate question**, answered by
      a third signed param `s=1`, minted only when **no email-holding account existed** before the
      registration (`funnel_whatsapp_sends.allow_auto_login`):

      | | tap does |
      |---|---|
      | `s=1` — nobody else can claim this account | stamps the phone, signs in, → `/dashboard` |
      | no `s` — an email-holding account already existed | stamps the phone, **no session**, → `/verify-email` |

      Without that split, knowing a customer's email address would be enough to have their sign-in
      credential delivered to your own phone. With it, the real customer is one email code away and the
      stranger is stopped — see [Lead Linking](/docs/modules_handbook/shared/lead-linking/readMe.md).

- **`/verify-email`** — [`Auth\VerifyEmailController`](/app/Http/Controllers/Auth/VerifyEmailController.php),
  the step between "tapped the WhatsApp link" and "signed in" for an account that already had an email.
  It proves the address **on file** (`PasswordlessAuth::sendEmailOtp` + `peekChallenge`/`consumeChallenge`,
  `throttle:6,1` / `10,1`), stamps `email_verified_at`, then signs in. Identity is carried by the
  server-side session key `wa_verify_user` that the magic link wrote — **never in the URL** — so the page
  is a dead end without a tapped link (it redirects to `login`).
  ⚠️ There is deliberately **no change-email here**, unlike the in-portal Extra Bonus overlay: swapping
  the address *before* holding a session would hand the account to whoever asked, which is the exact
  takeover this step exists to stop. Someone locked out of that inbox goes through an admin
  (`services.whatsapp.sales` wa.me link on the page).
- **Logout** — [`LoginController@destroy`](/app/Http/Controllers/Auth/LoginController.php#L168): reads
  which portal you were in **first**, logs out, invalidates the session + regenerates the CSRF token →
  back to that portal's own sign-in (`manage.login` for staff, else `login`).

> **The customer verification gate runs on EVERY sign-in path** (§9): a customer signs in only once
> **both** email and phone are PROVEN (`User::hasVerifiedContact()`) — **except** the WhatsApp-delivered
> magic link above, which is itself proof of the phone and therefore mints a session on ONE proven key.
> That session is **held**, not free: `EnsureUserIsMainUser` now admits anyone with
> `User::hasAnyVerifiedContact()` (zero proven keys still bounce to `/register`), and the stacked
> **`contact.verified`** middleware confines a customer with an unproven email **or phone** to
> `/dashboard` until the **Extra Bonus overlay** proves the outstanding key. An unknown / unverified identity at
> `/login` gets one uniform "complete your registration" refusal and is sent to `/register`; **admins are
> exempt.** There is deliberately **no** customer password and **no** forgot-password — both would
> side-step the dual-key gate (the removed forgot-password flow was exactly that hole). `/register`
> dual-verification is the ONLY account creation / completion path.

#### `VERIFY_USERS` — switching the gate off (2026-09-11)

One `.env` switch, `VERIFY_USERS=on|off` (`config('auth.verify_users')`, read everywhere through
[`User::contactVerificationRequired()`](/src/People/User.php) — grep that name to find every gate).
`on` is the default and everything above. `off` changes exactly this:

| | `on` (default) | `off` |
|---|---|---|
| `/register` | email + phone → **two codes** → account resolved + keys **stamped** proven | email + phone → **signed straight in**, nothing stamped ([`RegisterController::start`](/app/Http/Controllers/Auth/RegisterController.php) branches to `signInWithoutVerification`; the page renders a plain form instead of `ContactVerification`) |
| `/login` | a code goes only to an account with **both** keys proven | a code goes to **any existing** account — the code IS the sign-in. An **unknown** identity is still refused (login never creates accounts) |
| WhatsApp magic link | `s=1` opens a session, otherwise → `/verify-email` | every tap opens a session (`MagicLoginController`) |
| `main` + `contact.verified` middleware, the Extra Bonus overlay | hold / confine an unproven account | **skipped**; `needs_*_verification` props are `false` |

**Which account may an unproven `/register` pair open while `off`?**
[`PasswordlessAuth::registerWithoutVerification`](/app/Services/Auth/PasswordlessAuth.php) never
stamps, writes, supersedes or merges: nobody owns either key → one thin account is **created**; one
account owns them and it is still unproven, non-staff and membership-free → sign into it (the lead a
funnel form created); **anything else is refused** to `/login` — two different accounts, a staff
account, an account somebody already **proved**, or a paying member. So a typed email can never open
a member's or an admin's account.

**Turning it back `on` re-imposes the gate on every account made meanwhile**: nothing was stamped,
so `main` bounces them to `/register`, whose dual codes land in the *"both keys → SAME account"* branch
and stamp them like anyone else. `off` is a *deployment* choice (a demo, a soft launch); it is not
per-user.

`on`/`off`/`yes`/`no`/`true`/`false`/`1`/`0` all parse; an unreadable value falls back to **on**
(`config/auth.php` — a typo must never open the gate).

#### Phone OTP delivery — WhatsApp first, SMS fallback (and how to debug it)

`sendPhoneOtp($phone, 'whatsapp')` tries the approved AUTHENTICATION template on a Cloud channel
([`WhatsappTemplate::otpLoginTemplate`](/src/Whatsapp/WhatsappTemplate.php#L358)) and **silently falls
back to SMS** whenever that gate is shut or the Graph call throws — the user is never told which channel
carried the code. The gate needs *all* of: template `category=AUTHENTICATION` + `status=APPROVED`, on a
channel that is `provider=CLOUD_API` + `is_active` + `status=CONNECTED`.

**The send is deliberately SYNCHRONOUS** (no queue, no worker). Not an oversight — two reasons it must stay
that way: the SMS fallback decision *needs* the WhatsApp outcome in the same request (a queued send has no
outcome to branch on), and login must never depend on a worker being alive, or a stalled Horizon silently
becomes "nobody can sign in" while the UI still asks for a code. Everything else WhatsApp-related is queued;
this one is not.

> **Local dev: SMS is log-only.** The real SMS360 gateway is IP-whitelisted and unreachable from a dev
> machine, so an SMS send would always fail there and the dual-code flows (`/register`, Property Match)
> would be untestable. In the **local** environment
> [`AppServiceProvider`](/app/Providers/AppServiceProvider.php) therefore binds `SmsSender` to
> [`LogSmsSender`](/src/Common/Sms/LogSmsSender.php) (logs + returns true) instead of `Sms360Sender`,
> unless `SMS_REAL_IN_LOCAL=true` — the SMS equivalent of `MAIL_MAILER=log`. With it (and mail already
> logging), both codes "send", are kept, and the local-only `devOtp` prop auto-fills them.

It is, however, **recorded like every other outbound message** —
[`recordOtpMessage`](/app/Services/Auth/PasswordlessAuth.php#L302) runs the normal
`findOrCreateByPhone` → `conversationFor` → `createOutbound` pipeline, then `recordSendResult` stamps the
wamid (or FAILED + the error). So an OTP appears in the inbox thread, and Meta's status webhook — which
looks a row up by `channel_id + provider_message_id` — can finally settle a send Meta *accepted* and then
failed to deliver. Two deliberate departures from a normal template send:

- **The code is never persisted.** `body` is a fixed redacted string and `meta.template` carries no
  `parameters`. A login code sitting in an inbox every staff member can read is a complete account-takeover
  path — request a code for a customer's number at `/login` (the challenge binds to the *requester's*
  session), read it out of the thread, sign in as them. The row is for auditability and the wamid, not the secret.
- **Nothing is dispatched to `SendWhatsAppMessage`** — the caller sends inline, so the row is never
  re-sendable, which is also what makes dropping the params safe.

Recording is best-effort: a bookkeeping failure logs and returns null, and the code still goes out.
Note `conversationFor` CRM-links the recipient (`ContactLinker`), so an OTP recipient can become a lead —
consistent with every other outbound path.

Because every failure mode degrades quietly, use the diagnostic command rather than reading the logs blind:

```bash
php artisan whatsapp:otp-test 60123456789 --dry-run   # diagnose only, send nothing
php artisan whatsapp:otp-test 60123456789             # + really send a code
php artisan whatsapp:otp-test 60123456789 --full      # exercise the real path, SMS fallback included
```

It prints, in order: phone normalisation (flagging a trunk-0 number that Graph accepts and never delivers),
the `/login` account gate for that number, the template gate **per condition per candidate**, the channel
credentials plus a **live Meta probe** of the phone number id and of the template's *current* status at Meta
(a template Meta has since paused still reads `APPROVED` in our DB), the exact Graph payload, and the real
send with Meta's full raw error body on failure.

The three log lines that matter: `WhatsApp Graph error` (Meta rejected it), `WhatsApp OTP send failed`
(the fallback fired), `Passwordless OTP issued` (`channel=whatsapp|sms` — what actually happened).

#### Redirect-by-DOOR after login (2026-07-28)

Where you **land** is decided by the **door you came through**, not by your role alone —
[`ResolvesPortalHome`](/app/Http/Controllers/Concerns/ResolvesPortalHome.php), the one trait every auth
controller (`LoginController`, `PasswordlessLoginController`, `RegisterController`) shares so the rule
cannot drift across copies:

```php
// $portal is User::PORTAL_MAIN | User::PORTAL_ADMIN (constants on Src\People\User)
$portal === User::PORTAL_MAIN || ! $user->isManageUser()
    ? route('main.dashboard')
    : route('manage.dashboard');
```

| Door | Customer | Admin |
| --- | --- | --- |
| `/login` (`PORTAL_MAIN`) | `main.dashboard` | **`main.dashboard`** — their own investor portal |
| `/manage/login` (`PORTAL_ADMIN`) | *can't get in* (§6) | `manage.dashboard` |

This follows straight from **Admin + Lead are HATS**: an admin owns the same lead-anchored records as
any customer, so the two doors are not "staff vs customer" but **"which side do I want right now"**.
Previously an admin was force-redirected to the manage Hub from *both* doors, leaving the user portal
reachable only by hand-typing the URL. Once on the user portal, [`AppLayout.vue`](/resources/js/Layouts/AppLayout.vue)
shows staff a one-click **"Switch to Manage Portal"**.

> **The switch is on BOTH sides (2026-08-07).** Because the two doors are "which side do I want right
> now", the crossing must be reachable from either side, from any page — not only from the portal.
> [`AppShell.vue`](/resources/js/Layouts/AppShell.vue) therefore renders a small bordered block at the
> top of the sidebar holding up to two links, and the two layouts configure it:
>
> | Sidebar | Back — the suite CHOOSER | Switch — straight into the work |
> |---|---|---|
> | [`ManageLayout`](/resources/js/Layouts/ManageLayout.vue) (`/manage/*`) | **Back to Hub** → `/manage/dashboard` | **Switch to User Portal** → `/dashboard` |
> | [`AppLayout`](/resources/js/Layouts/AppLayout.vue) (portal) | **Back to Hub** → `/manage/dashboard` | **Switch to Manage Portal** → `useOperationsLanding()` |
>
> Both rows on the portal side are **admin-only** (`auth.user.is_admin`) — a plain member has no manage
> side and would only get a 403.
>
> **The two portal links must not resolve to the same URL**, or one of them is dead weight — which is
> why the switch uses
> [`useOperationsLanding()`](/resources/js/composables/useOperationsLanding.js) rather than
> `/manage/dashboard`: `Back to Hub` is "pick which side of the business", the switch is "skip the
> chooser, put me back in the work". That composable resolves the first Operations page **this admin's
> permissions can actually open**, so the shortcut can never land on a 403 the way a hardcoded page
> would; when it resolves `null` (nothing in the suite is theirs) the switch **hides** and only the Hub
> link remains.
>
> On the manage side the **back** link matters because a suite's nav (`?suite=project|focus|other`)
> drops the `Hub` entry once you are inside it — the chooser was previously reachable only by typing the
> URL. The Hub page itself hides the sidebar entirely (`hideSidebar`), so that link never points at the
> page you are standing on.

Where each caller gets its `$portal`:

- **Login page bounce** (already signed in) — the page's own portal, so `/login` bounces an admin to the
  user portal.
- **Admin password** (`storeManage`) — always `PORTAL_ADMIN`; that endpoint only exists on the admin door.
- **Magic link / OTP** — from the **token / challenge payload** (`$data['portal']`), never the current
  request, which is why a link opened in a different browser still lands on the right side.
- **`/register`** — always `PORTAL_MAIN`: it is a customer-door page, so staff completing it land on the
  user portal.
- **Dev quick-login** (local only) — the login page posts its own `portal` alongside the email.

> **`redirect()->intended()` still wins.** A deep link (e.g. logged-out admin hitting `/manage/leads` →
> bounced to `/login`) is remembered and overrides the door default — the person returns to the page they
> actually wanted. Only a *bare* sign-in uses the table above.
>
> **Logout is still by ROLE, not by door** — `destroy()` sends staff to `manage.login` and customers to
> `login` (§6). An admin who signed in at `/login` therefore logs out to the admin page; harmless, but the
> one place the door is not remembered (it is not stored in the session).

In **local** dev the login page also offers one-click "quick logins" for the seeded demo accounts —
these post to `POST auth.dev-login`
([`LoginController@devLogin`](/app/Http/Controllers/Auth/LoginController.php#L141)), which **hard-aborts
(404) outside `local`**, so it is never a production backdoor (the old "seeded password = the email"
shortcut is gone).

### 7. Authorization — route middleware

Aliases registered in [`app/Http/Kernel.php:39-53`](/app/Http/Kernel.php#L39-L53):

| Alias | Middleware | Effect |
| --- | --- | --- |
| `auth` | Laravel `Authenticate` | must be signed in |
| `admin` | [`EnsureUserIsAdmin`](/app/Http/Middleware/EnsureUserIsAdmin.php) | `isAdmin()` else **403** — wraps all of `routes/web.php` (`/manage/*`) |
| `main` | [`EnsureUserIsMainUser`](/app/Http/Middleware/EnsureUserIsMainUser.php) | **three checks in one** — see the row below the table (check 2 is skipped under `VERIFY_USERS=off`, §6) |
| `contact.verified` | [`EnsureContactIsVerified`](/app/Http/Middleware/EnsureContactIsVerified.php) | stacked **after** `main`: a customer whose **email OR phone** is still unproven may hold a session but only see `/dashboard` (where the Extra Bonus overlay proves the outstanding key, §6) — every other portal page bounces back there. Keyed on `hasVerifiedContact()`, so a **missing** key counts as unproven and deleting one is never a way out. Staff are exempt, and so are the checkout return/cancel routes (`EXEMPT_ROUTES`), so a gateway redirect is never intercepted mid-purchase; **`VERIFY_USERS=off` exempts everyone** (§6). *(Was `email.verified` — the phone half was added 2026-08-07: each key is a sign-in route on its own, so an unproven one is an unclosed door)* |
| `member` | [`EnsureUserIsMember`](/app/Http/Middleware/EnsureUserIsMember.php) | `isMember()` else redirect (members-only areas) |
| `guest` | [`RedirectIfAuthenticated`](/app/Http/Middleware/RedirectIfAuthenticated.php) | bounce already-signed-in users |
| `signed` | Laravel `ValidateSignature` | guards `auth.magic` + other signed public links (e.g. the QR ticket page) |
| `check.abilities` | [`CheckAbilities`](/app/Http/Middleware/CheckAbilities.php) | fine-grained spatie permission gate |

**`main` does three things, in this order** ([`EnsureUserIsMainUser::handle`](/app/Http/Middleware/EnsureUserIsMainUser.php)) —
it is the portal's whole door, not a role check:

1. **Who may enter** — `isMainUser()` **or `isManageUser()`**, else back to `main.dashboard` with an
   error. Staff are admitted **on purpose**: an admin carries a Lead facet and owns the same
   lead-anchored records as any customer (§4), so the user portal is theirs too. *(This replaced the
   original `isMainUser()`-only rule whose comment read "keeps admins out of the user portal" — that
   line died with **Admin + Lead are HATS**.)*
2. **A HOLD, not just an entry check** — a **non-staff** user with `hasAnyVerifiedContact() === false`
   is redirected to `register.show`. The sign-in gates (§6/§9) only run when a session is *minted*, but
   a session outlives them: an admin editing this customer's email/phone **clears that key's proof**
   (the model hooks, §9), and a remember-me cookie would otherwise carry an unverified person on
   indefinitely. **One** proven key is enough to hold the session (the WhatsApp magic link proves the
   phone); the stacked `contact.verified` walks them to `/dashboard` until the second key is proven.
   Zero proven keys bounce. It runs on the portal only, so public pages never redirect-loop.
3. **Self-heals the Lead facet** — when `$user->lead` is null it calls
   [`UserRepository::ensureLeadForPortal($user)`](/src/People/Repositories/UserRepository.php) (a
   staff user's lead is stamped `is_staff`) and forgets the stale relation. Every portal write anchors
   on `lead_id`, so this is what stops a seeded super-admin — or anyone whose lead was purged — from
   hitting a null lead on their first portal page.

> The former **`zoom`** alias (`EnsureUserCanUseZoom`, a Caller/Closer position gate) was
> **removed** in the Server-to-Server Zoom refactor — Zoom routes now run on `auth + admin` only
> and gate inside the controller via `ZoomServerService::isAccountUser()`. See the
> [Zoom](/docs/modules_handbook/manage/zoom/readMe.md) doc.

- `/manage/*` → `['auth','admin']` ([`routes/web.php:5`](/routes/web.php#L5)).
- The user portal's investor area → `['auth','main','contact.verified']` (the one such group in
  [`routes/main.php`](/routes/main.php)); `/dashboard` + `main.profile.*` sit in the plain `auth`
  group above it, which is what makes `/dashboard` reachable while a key is still unproven — **and is
  load-bearing**, since `main.profile.*` holds the very endpoints that free a held customer.
- Finer control uses spatie **permissions** ([`Src\Auth\Permission`](/src/Auth/Permission.php):
  `view-/manage-` × `admins/members/roles`); `super-admin` gets all, `admin` gets the three `view-*`
  by default ([`RolesSeeder`](/database/seeds/RolesSeeder.php)).

### 8. What the frontend (Vue/Inertia) receives

Every page gets these shared props from
[`HandleInertiaRequests::share`](/app/Http/Middleware/HandleInertiaRequests.php#L38) — this is how the
Vue app knows who you are:

```js
usePage().props.auth.user   // { uuid, name, email, role, is_admin, position, avatar } | null
usePage().props.membership  // { is_member, memberships:[{code,name}] } | null   (lazy)
```

- `auth.user.role` = primary role name; `auth.user.is_admin` = `User::isAdmin()`;
  `auth.user.position` = the admin's position label (null for non-admins).
- `membership` is resolved **lazily** and is **null for guests and admins** (it short-circuits unless
  `isMainUser()`), so it costs nothing off the user portal. `is_member` mirrors the synced role;
  `memberships` is `User::activeMemberships()` (the live list from the lead's active subscriptions).
- **One-shot flash props for the passwordless / register / quiz UX** (all in `HandleInertiaRequests::share`):
  `devOtp` (LOCAL-only — the just-issued OTP codes, so the flow is testable without a live gateway),
  `registerSendFailed` (`'email' | 'phone' | 'both'` — a code that could not be delivered), and **`pm`**
  (the public Property Match quiz's one-shot: the saved submission uuid + the OTP send/verify outcome, so
  the client-side quiz can advance after each round-trip). The register + quiz pages read these to drive
  the shared [`ContactVerification.vue`](/resources/js/Components/Auth/ContactVerification.vue) component.

### 9. Duplicate identity & account merge (`/register` AUTO-MERGE + supersede)

Because a person can arrive by many entry points with only an **email**, only a **phone**, or both,
two separate accounts can end up describing **one human** ("two Violas"). The
[`/register`](/app/Http/Controllers/Auth/RegisterController.php) **dual-verification** page is where
this is resolved safely — the user enters **both** email and phone and verifies **each** with its own
6-digit code (email code + SMS/WhatsApp code) *before any record is touched*
([`PasswordlessAuth::completeRegistration`](/app/Services/Auth/PasswordlessAuth.php)). The resolution
matrix (only ever on **two proven keys**; reworked **2026-07-23** — the old
"record a pending request and block" flow is gone for customer pairs):

| Situation | Action |
| --- | --- |
| Neither key owned | Create **one** account with both keys |
| One side owned | **Apply** both proven keys to it ([`applyVerifiedContact`](/src/Lead/Repositories/LeadRepository.php) — a **differing stored key is SUPERSEDED**, see below) |
| Two different accounts own the two keys | **AUTO-MERGE** — richer side survives, proven keys become its current keys, person signs in |
| A matched account is a **paying member** matched **only on a key it never verified** | Held for **admin review** (`unverifiedMemberMatch`) — see the takeover note below |
| A **privileged** account is involved (manage role) | Fall back to a **PENDING merge request** ([`verified_identity_pairs`](/src/People/VerifiedIdentityPair.php)) an admin resolves |
| Either side is staff (`admins` row) | Sign in, but **never** write keys, merge, or record a pair |

> **THREE consumers, one matrix** (two at 2026-07-23; Rental Estimate joined later and this note was
> stale until 2026-08-07). This exact `completeRegistration` matrix — including the **auto-merge**, the
> supersede, and BOTH guards (paying-member-on-unverified-key → review, staff → never auto-merged) — is
> run by:
>
> | Surface | Signs in? | What it does with the resolved identity |
> |---|---|---|
> | **`/register`** | ✅ | the plain "complete / claim your account" flow |
> | public [Property Match](/docs/modules_handbook/main/property-match/readMe.md) buyer quiz | ❌ | links the lead to the submission + records `SOURCE_PROPERTY_MATCH` ([`PropertyMatchController::verify`](/app/Http/Controllers/Main/PropertyMatch/PropertyMatchController.php) → [`attachVerifiedResult`](/src/PropertyMatch/Repositories/PropertyMatchRepository.php)) |
> | public [Rental Estimate](/docs/modules_handbook/main/rental-estimate/readMe.md) form | ❌ | [`RentalEstimateLeadLinker::attempt`](/app/Services/RentalEstimate/RentalEstimateLeadLinker.php) attaches the lead to the submission, then runs the owner assigner |
>
> So "person signs in" in the matrix above is the `/register` outcome only; the two public forms get the
> identical identity result — **including a silent account merge** — minus the session. `/register` and
> Property Match post through the SAME shared
> [`ContactVerification.vue`](/resources/js/Components/Auth/ContactVerification.vue) component.
>
> ⚠️ **The consequence worth stating out loud:** a customer filling in a *public rental-estimate form*
> can auto-merge two of their accounts, with no admin anywhere in the flow. That is the intended design
> (the merge is authorised by the dual OTP, not by the surface), but it means **any new surface that
> calls `completeRegistration` inherits the power to merge accounts** — add it to this table when you
> build it.

> **The unverified-key takeover guard (2026-07-23).** [`matchUserByPhone`](/src/Lead/Repositories/LeadRepository.php)
> resolves an account by its *stored* phone **regardless of `phone_verified_at`** (and email match is
> exact-string), so a phone/email that was **imported, mistyped, or recycled** onto an account matches
> exactly like a real one. Auto-acting on such a match would let whoever now controls that number/inbox
> take over the account it is wrongly stored on. The guard: an **active-membership** account matched
> **only** on a key it never verified is **never auto-merged / auto-superseded** — it is held for a
> human ([`PasswordlessAuth::unverifiedMemberMatch`](/app/Services/Auth/PasswordlessAuth.php)). Everything
> else still auto-resolves (a member that already **verified** one of the two keys, or **both** keys
> resolving to the **same** account — proving both of an account's keys is the strongest possible signal —
> or any **non-member** duplicate, the common "two thin leads" case). This is a deliberate
> **security-vs-convenience** line (user decision): protect the accounts worth stealing, auto-merge the
> rest. SIM-recycling of an already-**verified** number remains a residual risk, as it is in any system.

**Why auto-merge is safe here and nowhere else:** the merge fires only at the **verify** step, after
the person answered a code sent to the email **and** a code sent to the phone **in one sitting** —
i.e. they demonstrably control both accounts' keys. That dual proof *is* the authorisation. There is
deliberately **no** duplicate check at `/register` **start** anymore: codes always go out (acting
before proof would both touch keys nobody proved and leak who is a customer — the old
identity-existence oracle this rework also closed).

#### SUPERSEDE — the freshly proven key REPLACES a differing stored one

[`LeadRepository::applyVerifiedContact`](/src/Lead/Repositories/LeadRepository.php) is the
`/register` writer (both the one-account branch and the post-merge step). Unlike the old
fill-blank-only backfill, a stored key that **differs** from the freshly proven one is **replaced**:
the proven key is the person's *current* one (think: Shawn changed his phone number). Rules:

- The replaced value is archived to **`contact_key_histories`**
  ([`Src\People\ContactKeyHistory`](/src/People/ContactKeyHistory.php)) — **audit-only; the identity
  gate NEVER matches against it** (carriers recycle numbers; matching history would bind a recycled
  number's new owner to the wrong person).
- Key + verification stamp are written **in the same save** (the model hooks' carve-out), so the
  stamp survives honestly.
- A key **owned by another account** is never taken (transfer is the merge's job) — logged instead.
- A **protected** (staff / privileged) account is conservative: matching keys are stamped, differing
  keys are never replaced.
- **Stale PENDING pairs** whose snapshot of the replaced key no longer matches are swept, so a dead
  number can never be merged back in later.
- A **security notice** ([`ContactKeyChangedMail`](/app/Mail/ContactKeyChangedMail.php)) goes to the
  *previous* email (masked values, best-effort) — the SIM-recycling / hijack tripwire.
- **"Did the key change?" uses the TOLERANT phone compare** (`sameNumberTolerant`), not strict —
  a stored bare-national / foreign shape re-proven with its country code must NOT read as a change
  (else the same real number is spuriously superseded + a false "your number changed" notice fires).
- The whole apply step is **fail-safe**: it runs through
  [`PasswordlessAuth::applyProvenKeys`](/app/Services/Auth/PasswordlessAuth.php) (a try/catch) and the
  archive write is `Schema::hasTable`-guarded + best-effort, so a supersede failure (deadlock, a
  not-yet-run migration) can **never** 500 `/register` or undo a committed merge — the keys just miss
  their stamp this pass and self-heal on the next register.

> This also fixed a real bug: the old backfill branch **dropped** a proven new phone yet stamped the
> stored, never-proven phone as verified — whoever held that number could then OTP-sign-in as the
> customer.

#### Worked example — the "Shawn" case, resolved in ONE pass

The DB holds **two** un-merged, non-staff accounts —

- **Account A (rich)** — email `shawn@gmail.com`, old phone `60108685352`, many records.
- **Account B (thin)** — phone `60167763663` (the number Shawn actually uses now), nothing else.

Shawn opens `/register`, enters `shawn@gmail.com` + `60167763663`, receives both codes, verifies:

1. `completeRegistration` resolves `byEmail = A`, `byPhone = B` (tolerant match,
   [`matchUserByPhone`](/src/Lead/Repositories/LeadRepository.php)) — two different, non-staff
   accounts, and **neither is a paying member matched on an unverified key** (the takeover guard
   above), so → [`autoMergeVerifiedPair`](/app/Services/Auth/PasswordlessAuth.php). *(Had B held an
   active membership and never verified `60167763663`, this would instead be held for admin review.)*
2. [`pickMergeSurvivor`](/src/Lead/Repositories/LeadRepository.php) picks **A** (richer; tie → older).
   `createMergeRequest` + [`mergeVerifiedPair`](/src/Lead/Repositories/LeadRepository.php) collapse B
   into A atomically (children re-pointed, B retired `STATUS_MERGED`, pair closed with
   `note = 'auto: register (dual-key verified)'`, `reviewed_by = NULL`).
3. `applyVerifiedContact(A)` then makes the **proven** keys A's current ones: phone
   `60108685352` → **archived** to `contact_key_histories`, `60167763663` written + stamped, email
   stamped. Old-phone login stops resolving; the new number resolves A.
4. Shawn is **signed in immediately** — flash: *"We found two of your accounts and combined them
   into one."* No admin, no re-register, nothing lost.

**Where the manual flow remains:** a pair involving a **privileged** account (a manage role without
an `admins` row; true staff already short-circuit to the `staff` outcome) is *never* auto-merged —
`completeRegistration` returns `outcome = pending_merge`, records the PENDING pair, and blocks with
the "we'll be in touch" notice **without signing in**. Those pairs surface in the
**Setting → [Merge Requests](/docs/modules_handbook/manage/people/merge-requests/readMe.md)** queue
(`manage.people.merge-requests.index`, `admin`-gated) →
[`MergeReviewModal.vue`](/resources/js/Pages/Manage/People/MergeRequests/Partials/MergeReviewModal.vue), where the
admin picks the survivor (a staff side is **locked** as the winner) and merges. An admin-reviewed
merge does **not** stamp verification (nothing was OTP-proven), so that person still completes
`/register` once after it — landing in the *"both keys → SAME account"* branch.

- **Verification gate**: a customer may only sign in once **both** keys were **PROVEN** by their owner
  (`User::hasVerifiedContact()` — `users.email_verified_at` + `user_profiles.phone_verified_at`, stamped
  ONLY by `LeadRepository::markContactVerified()` / `applyVerifiedContact()` from a successful
  dual-verification — `/register`, the public [Property Match](/docs/modules_handbook/main/property-match/readMe.md)
  quiz **or** the public [Rental Estimate](/docs/modules_handbook/main/rental-estimate/readMe.md) form, all three
  of which run `PasswordlessAuth::completeRegistration` (§9); plus
  `markPhoneVerified()` from a WhatsApp-delivered magic-link click, which proves the phone alone).
  *Having* an email +
  phone is not enough: a key captured by a funnel form / import / WhatsApp was never proven, and since
  login sends a code **to** the key, a mistyped phone would let whoever owns that number sign in as the
  customer. An unverified (or unknown) identity at `/login` gets one uniform "complete your
  registration" refusal and is sent to `/register`. **Admins are exempt.**
  > Existing accounts were deliberately left NULL (unverified) by
  > [`…000004_add_contact_verification_columns`](/database/migrations/2026_07_16_000004_add_contact_verification_columns.php),
  > so every pre-existing customer verifies once via `/register` before their next sign-in.

- **A changed key ALWAYS loses its stamp — enforced on the models, not the writers.**
  `User::booted()` and `UserProfile::booted()` each clear their stamp on any save where the value
  changed and the stamp was not explicitly set in the same save. This is the un-forgettable net: an
  admin edit, a CSV import, a self-service PUT, or a path added tomorrow all drop the proof for free.
  Without it, an admin typing any phone would make it "verified" — and since login sends the code TO
  the key, whoever owns that number could then sign in as the customer.
  The phone comparison uses the **strict** `PhoneNumber::sameNumber` (a reformat like
  `60123…` → `+60123…` keeps the proof; anything else clears it) — never `sameNumberTolerant`, which
  its own docblock forbids for identity binding.
- **Who may stamp**: only [`LeadRepository::markContactVerified()`](/src/Lead/Repositories/LeadRepository.php)
  (the /register dual code) and its single-key siblings `markEmailVerified()` / `markPhoneVerified()`,
  used by the self-service change flows after a code proves the NEW value. They are unconditional by
  design — a conditional stamp could never re-stamp after a clear, silently locking the person out.

### 10. Editing contact keys — admin forms vs self-service

| Surface | Email + phone | Verification |
| --- | --- | --- |
| **Lead / Admin create** ([StoreRequest](/app/Http/Requests/Manage/Leads/StoreRequest.php)) | **email OR phone** — at least one (`required_without`), not both | never stamped — an admin-typed key is unproven by definition |
| **Lead / Admin edit** | freely editable, badged **Verified** + warned when changing a proven one; a change that makes the two keys belong to **two different accounts** offers a **merge request** (below) instead of a hard block | changing clears the stamp; the person re-proves at `/register` |
| **Own profile** ([PersonalTab](/resources/js/Components/Profile/PersonalTab.vue)) | **display-only**; each changes via its own 6-digit code | the code proves the NEW value → it is stamped |

> **One key is enough to CREATE, both are needed to SIGN IN.** A lead / admin can be recorded with only
> an email **or** only a phone (`required_without` on each key, empty→null in `prepareForValidation`) — it
> just can't passwordless-sign-in until it holds **both**, proven (§6 / §9). A **phone-only admin** is
> created with a random unknowable password ([`AdminsController@store`](/app/Http/Controllers/Manage/People/AdminsController.php))
> and signs in by phone OTP.

Ownership is checked with the identity gate (`resolveUserByIdentity` / `phoneOwnedByAnother`), **never**
`unique:user_profiles,phone`: that index compares the exact string, so one person's `0123456789` and
`60123456789` both pass it while a real duplicate in the other shape slips through.

**The admin-edit path now merges IMMEDIATELY (2026-07-23).** When an admin's **Lead / Admin edit**
sets an email owned by one account and a phone owned by another (the same "two accounts, one person"
shape), the shared FormRequest trait
[`OffersIdentityMerge`](/app/Http/Requests/Concerns/OffersIdentityMerge.php)
(used by both [Leads](/app/Http/Requests/Manage/Leads/UpdateRequest.php) and
[Admins](/app/Http/Requests/Manage/People/Admins/UpdateRequest.php) `UpdateRequest`) returns a
`confirm_merge` error on the first save → the edit modal shows a **"Merge these two accounts?"**
confirm dialog → confirming re-submits with `merge_confirmed = true`, and the controller **executes
the merge on the spot** — but only within two bounds, added 2026-08-06 and enforced in the
FormRequest **and** again in the controller (`merge_confirmed` is read off raw input with
`boolean()`, so a hand-written PUT never sees the validator):

- **`isAdmin()` only.** These routes need `manage-leads` / `manage-admins`, which sales-agent,
  sales-leader and group-super-admin hold by default — so a non-admin could merge here what the
  `admin`-gated [Merge Requests](/docs/modules_handbook/manage/people/merge-requests/readMe.md)
  queue 403s them for. They now get the duplicate **filed as a pending request** instead.
- **The pair must include the record being edited.** The merge keys off the two values TYPED into
  the form, so pasting the wrong spreadsheet row into someone's edit modal merged two uninvolved
  strangers while the record on screen went untouched. An out-of-scope pair is a typo: it falls
  through to the ordinary "already belongs to another account" errors.

(`createMergeRequest` + `mergeVerifiedPair`, reviewer = the confirming admin,
`note = 'admin: edit-confirmed'`) — the confirming admin *is* the reviewer, so the old
file-a-request-then-visit-the-banner second step is gone. The survivor is the **staff side when one is
involved** (`forced_winner`), else the **richer** account (`pickMergeSurvivor`). **The edit itself is
not applied** — the merge is what combines the two accounts — and the admin path never supersedes /
stamps keys (nothing was OTP-proven).

**Admin-lead rule — a staff account is always the SURVIVOR.** Unlike the customer `/register` path
(which excludes staff outright), an **admin-lead may take part in an admin-filed merge, but only ever as
the survivor**, never retired. [`detectMergePair`](/src/Lead/Repositories/LeadRepository.php) flags each
side's protected status; `previewMerge` returns `winner_locked_id` = the staff side when exactly one side
is staff (the review modal **locks** the survivor to it), and `mergeVerifiedPair` **refuses to retire a
protected account** (the loser is always the non-staff side). **Two staff accounts cannot be merged** —
the edit is blocked with a clear message. When the survivor is staff, the loser's **customer roles are
dropped, not inherited** (`mergeUserChildren($winnerIsProtected)`), so an admin never silently gains a
`member` / `non-member` role.

- **Who merges what (since 2026-07-23):** customer pairs at `/register` **auto-merge** on dual proof
  (§9); the CSV-import conflict tickbox auto-merges customer pairs (`note = 'auto: import …'`); an
  admin-edit confirm merges **immediately**. The **Setting → Merge Requests queue**
  ([handbook](/docs/modules_handbook/manage/people/merge-requests/readMe.md),
  [`MergeRequestsController`](/app/Http/Controllers/Manage/People/MergeRequestsController.php),
  `manage.people.merge-requests.*`, `admin`-gated) handles everything the auto path refuses — privileged
  pairs, legacy rows, and (since 2026-07-29) **a public capture form whose typed phone already belongs
  to another account**, which is what turned this from a rare leftover into a queue that fills itself. There the admin picks the survivor (a staff side
  is locked as the winner). **Merging is the only resolution — there is no dismiss.** Every merge
  stamps a provenance `note` on the pair, so automatic and hand-reviewed merges stay distinguishable.

**The merge** ([`LeadRepository::mergeVerifiedPair`](/src/Lead/Repositories/LeadRepository.php)) is the
**exact inverse of `purgeRows()`**: wherever the purge *deletes* a `lead_id`/`user_id` child, the merge
*re-points* its owner key from the **loser** to the **winner** (survivor = richer account by default —
but a **staff side is forced to win**). **Both operations now read their table lists from ONE
declarative registry — [`Src\Lead\Support\IdentityChildMap`](/src/Lead/Support/IdentityChildMap.php)**
— which classifies *every* `*_lead_id` / `*_user_id` / `*_admin_id` column (repoint / dedupe / delete /
unlink / skip-with-reason). The schema census
[`IdentitySchemaCoverageTest`](/tests/Feature/People/IdentitySchemaCoverageTest.php) diffs the LIVE
schema against that map, so **a future migration adding a person-referencing column fails CI until the
column is classified** — the drift that let `lead_assignments` ship invisible to both operations can't
recur. The merge runs in **one transaction**, is **idempotent**, **never retires a staff account**, and:

- **Dedupes the UNIQUE children first** so no blind re-point hits a duplicate key: `lead_ai_credits`
  (balances **summed**), `lead_enrichments` (keep winner's), `engagements` (merge per `project_id`),
  `event_registrations` (dedupe per `event_id`), `lead_funnels` / `lms_lesson_progress` /
  `funnel_whatsapp_sends` (composite uniques), `ai_credentials` (per provider).
- **Re-points** every other `lead_id` child (subscriptions, bookings, appointments, AI, Zoom, calls,
  recordings, wealth, concierge, …) and `user_id` child (WhatsApp/Messenger **contacts** = the inbox
  RBAC gate — re-pointed, never deleted; addresses; role/permission pivots).
- **Retires the loser**: frees its unique keys (`email`/`profile.phone` nulled), sets
  `users.status = STATUS_MERGED` + `merged_into_user_id` (the survivor), and stamps the pair
  `resolved_at`. Conservative on conflicts (a winner's existing phone is **never** overwritten — it is
  flagged for the admin instead).

**A key the winner does NOT inherit is destroyed — so it is archived and warned about
(2026-08-07).** `consolidateWinnerContact` only ever fills a **blank**, so when both sides
hold an email (or a phone), the loser's is simply freed. For a phone that is worse than an
audit gap, because the two systems then disagree: `whatsapp_contacts.user_id` is re-pointed,
so **WhatsApp keeps routing that number to the survivor**, while no `user_profiles` row holds
it any more — `LeadMatcher` / `firstOrCreateForIdentity` resolve only through that column, so
the next **phone call or public form on that number mints a fresh duplicate lead**, and nothing
records it was ever this person's. Two additions, neither of which changes who wins:

- **Before the retire step**, whatever is still on the loser (i.e. what was *not* carried over)
  is archived to `contact_key_histories` with `SOURCE_MERGE`, filed against the **survivor** —
  the retired shell is not an account anyone browses. Audit-only as always: the identity gate
  never reads that table, because carriers recycle numbers.
- **`previewMerge` now answers it per direction** — `accounts.{email,phone}.releases_if_lost` is
  what THAT side gives up if it is the one retired, so the review modal re-reads the warning as
  the admin flips the survivor, *before* the irreversible click. The phone comparison is
  **tolerant**: two accounts holding one real number in different stored shapes (`60167763663`
  vs `0167763663` — the exact-string unique index is how the duplicate got in) lose nothing, and
  crying wolf there would train admins to ignore the warning that matters.

**It acts on LIVE state, never on the pair's snapshot (2026-08-06).** A PENDING pair can wait in
the queue indefinitely — `RegisterLeadAction` and the Stripe webhook file them and never merge —
and nothing re-validates it: `sweepStalePendingPairs` has a single caller, `applyVerifiedContact`,
so an **admin key edit in that window leaves the snapshot naming an address the person has moved
off**. Two consequences, both now closed:

- `consolidateWinnerContact` reads the keys off the **loser's current record**, not
  `$pair->email` / `$pair->phone`. Writing the snapshot back made an abandoned mailbox the
  survivor's live magic-link destination while the real key was nulled by the retire step with no
  `contact_key_histories` archive — the exact hazard `sweepStalePendingPairs`' own docblock
  describes. The pair's stored keys stay what they always were: the record of *why* it was filed,
  shown in the review modal.
- The **PENDING re-check, the staff check and both lead reads happen INSIDE the transaction, under
  a `lockForUpdate` on the pair row itself** — the serialisation point. The old status check read
  an in-memory model loaded before the call, and locking `users` alone only made a second merge
  *wait* before proceeding on post-commit state with pre-commit assumptions: two admins resolving
  one pair with **opposite survivors** retired BOTH accounts (nobody can sign in again —
  `UserProvider` refuses a `STATUS_MERGED` session) and re-pointed every child onto a lead id the
  first transaction had already deleted. The second merge is now a plain `noop`. It also refuses an
  account that is **gone** (`account_missing`) or **already retired** (`already_merged_account`) —
  `previewMerge`'s `blocked` flag only ever reached the browser. Pinned by
  [`MergeLiveStateTest`](/tests/Feature/People/MergeLiveStateTest.php).

> **Phase B scope note**: only the merge of an already-**proven** pair is built. Routing the two remaining
> bypass entry points (Portal invite, new GSA) through the tolerant identity gate, and a standalone
> "suspected duplicates" list, are still deferred.

---

## Cheat-sheet — "how do I know what this user is?"

| Question | Answer (server-side) | Frontend prop |
| --- | --- | --- |
| Admin portal? | `$user->isAdmin()` (role `super-admin`/`admin`) | `auth.user.is_admin` |
| Super admin specifically? | `$user->isSuperAdmin()` | `auth.user.role === 'super-admin'` |
| Main portal user? | `$user->isMainUser()` (role `member`/`non-member`) | `auth.user.role ∈ {member,non-member}` |
| Member vs non-member? | `$user->isMember()` (fast, role cache) | `membership.is_member` |
| Genuinely paid right now? | `$user->hasActiveMembership()` (live, via lead) | `membership.is_member` |
| Which membership(s)? | `$user->activeMemberships()` → `{code,name}` | `membership.memberships` |
| Admin's job function? | `$user->admin->position` (`Admin::POSITIONS`) | `auth.user.position` |
| The customer's owned data? | `$user->lead` → its relations (keyed by `lead_id`) | — |

---

## Lifecycle — how a user + lead come into existence

**There is no PASSWORD signup.** No "register with a password" form exists. There is, however, a
public **passwordless** [`GET /register`](/app/Http/Controllers/Auth/RegisterController.php) — the
dual-verification "complete/claim your account" page (§9): it verifies email **and** phone, then
creates-or-links an account, but never takes a password. (`POST register` remains the funnel lead
capture, throttled.) Accounts are born one of these ways:

1. **Landing-page registration** → [`RegisterLeadAction`](/app/Actions/RegisterLeadAction.php):
   find-or-create a **Non-Member** `User` (**random 40-char** password — sign-in is passwordless; the
   old email-as-password backdoor is gone) + `UserProfile`, anchor the person's single `Lead`
   ([`LeadRepository::firstOrCreateForUser`](/src/Lead/Repositories/LeadRepository.php), keyed on the
   unique `user_id`), then record the **per-funnel** registration with its own ad attribution
   (`LeadFunnelRepository::attach` on `lead_funnels`, idempotent per funnel — one person, **many**
   funnel registrations). *(Lead created eagerly.)*
   **Nothing is emailed here.** The old `WelcomeSignInMail` magic link was removed 2026-07-25 (the class itself deleted 2026-09-11): an
   emailed credential proves only the email, and it was the account's whole sign-in key. The
   credential now rides the funnel's **WhatsApp welcome** as `{{login_link}}`
   ([`DispatchFunnelWelcomeAction`](/app/Actions/DispatchFunnelWelcomeAction.php)), and whether that
   tap opens a **session** is decided by the action's `$allowAutoLogin` — true only when **no
   email-holding account existed beforehand** *and* the message is going to **this account's own
   stored phone**. That is the `s=1` split in §6; anyone else proves themselves at `/register` (or
   asks `/login` for a link).
2. **Meta lead-gen webhook** → [`SyncFlgLeadToCrmAction`](/app/Actions/SyncFlgLeadToCrmAction.php):
   same, seeded from a `flg_leads` row (marketing source = Meta, link kept via `leads.flg_lead_id`).
   **Requires an email** — phone-only FLG captures are skipped. *(Lead created eagerly.)*
3. **Phone-only customer** (e.g. matched from a call recording) →
   [`LeadRepository::firstOrCreateForPhone`](/src/Lead/Repositories/LeadRepository.php#L223): seeds a
   thin **email-less** `User` (random 40-char password) + profile + lead; returns `null` for staff.
   *(Lead created eagerly.)*
4. **Admin creation** → `UserRepository::create([...], Role::ADMIN)` (Manage › People › Admins) writes
   `user` + `user_profile` + `admin`, syncs the role **and creates the `is_staff` Lead in the same
   transaction** (`ensureStaffLead` — every admin is a full lead, §4). A manage account that somehow
   holds none — a seeded super-admin with no `admins` row, or one whose lead was purged — gets one
   lazily on first portal access (`ensureLeadForPortal`, §7). *(Before the HATS change on 2026-07-14
   an admin was created with **no** lead at all.)*

A `Lead`'s `created` hook grants free AI credits
([`src/Lead/Lead.php:75`](/src/Lead/Lead.php#L75)), so *when* the lead is created matters for credits.
**Becoming a member** → `EnrollMemberAction` records a `MemberSubscription` on the lead, then
`SyncMembershipRoleAction` flips the role `non-member` → `member`.

**Deletion** → hard-deleting a `User` cascades (the `deleting` hook in
[`User::booted()`](/src/People/User.php)): profile, `admins` row, **each** lead (per-instance so the
lead's own hook cleans up its AI keys/credits + subscriptions), addresses, and role assignments.
*(It no longer touches per-admin Zoom credentials — that table was dropped when Zoom moved to
account-level Server-to-Server OAuth, 2026-06-23.)*

---

## Gotchas & known gaps

- **Every auth endpoint is rate-limited.** `auth.passwordless.request` (`6,1`), `auth.otp.verify`
  (`10,1`), `manage.login.store` (`5,1`), `auth.register.start` (`6,1`), `auth.register.verify` (`10,1`),
  and the funnel capture `POST register` (`10,1`). Anti-enumeration lives in the controllers, not the
  service: the "code sent" / "credentials don't match" answers are **uniform** whether or not the
  identity exists, so the forms never become a who-is-a-customer oracle.
- **The `member` middleware alias is currently unused.** The investor portal gates on `main`
  (member **and** non-member), not `member` — the `member` alias exists for future members-only areas.
- **`guest` / `RedirectIfAuthenticated` is effectively vestigial** — it redirects to `/home` (not a
  defined route) and the auth routes aren't wrapped in it; `LoginController@create` does the
  "already signed in? bounce to your portal" check itself.
- **Account status is enforced at session-resolve, not by middleware** — `UserProvider` rejects a
  banned / inactive account when it hydrates the user, so the gate holds for the remember-me cookie too
  (the old `EnsurePasswordReset` global middleware was removed with the forgot-password flow).
- **Membership never auto-expires** (see §5) — `status` is the only gate, kept in sync by
  `SyncMembershipRoleAction`.

---

## Related files

**Models**
- [src/People/User.php](/src/People/User.php) — the account; status + role helpers; `profile`/`admin`/`lead` relations; cascade hook.
- [src/People/UserProfile.php](/src/People/UserProfile.php) — the person (name, phone, ID docs).
- [src/People/Admin.php](/src/People/Admin.php) — admin-only data; `POSITIONS` (sales metadata; no longer gates Zoom).
- [src/Lead/Lead.php](/src/Lead/Lead.php) — the main-portal user as a CRM hub; owns the `lead_id` relations.
- [src/FacebookLeadGenerator/Lead.php](/src/FacebookLeadGenerator/Lead.php) — raw Meta webhook capture (`flg_leads`), **not** a user.
- [src/Membership/Membership.php](/src/Membership/Membership.php) · [src/Membership/MemberSubscription.php](/src/Membership/MemberSubscription.php) — tiers + enrolments (`lead_id`).
- [src/Auth/Role.php](/src/Auth/Role.php) · [src/Auth/Permission.php](/src/Auth/Permission.php) — spatie role/permission models + constants.

**Auth / providers**
- [src/Auth/Providers/UserProvider.php](/src/Auth/Providers/UserProvider.php) — the `gsc` Eloquent provider.
- [config/auth.php](/config/auth.php) — single `web` guard + `users` provider. · [config/permission.php](/config/permission.php) — points spatie at `Src\Auth\Role`/`Permission`.

**Repositories / Actions**
- [src/People/Repositories/UserRepository.php](/src/People/Repositories/UserRepository.php) — create/update (user+profile+admin), `changeRole`, ban/unban, `ensureAdmin`, `ensureStaffLead` (the admin's `is_staff` Lead facet, written on create/promote), `ensureLeadForPortal` (the lazy self-heal `EnsureUserIsMainUser` calls), delete.
- [src/Lead/Repositories/LeadRepository.php](/src/Lead/Repositories/LeadRepository.php) — `create` (**`firstOrCreate`** on `user_id`, deliberately *not* `updateOrCreate`: re-ingesting a person must never reset a CONTACTED/QUALIFIED lead to NEW), `firstOrCreateForUser` (the anchor `RegisterLeadAction` uses), `firstOrCreateForPhone` (thin email-less account; null for staff), `changeStatus`.
- [app/Actions/RegisterLeadAction.php](/app/Actions/RegisterLeadAction.php) · [app/Actions/SyncFlgLeadToCrmAction.php](/app/Actions/SyncFlgLeadToCrmAction.php) — create user+lead.
- [app/Actions/EnrollMemberAction.php](/app/Actions/EnrollMemberAction.php) · [app/Actions/SyncMembershipRoleAction.php](/app/Actions/SyncMembershipRoleAction.php) — subscriptions ⇄ role cache.

**Controllers + service (auth)**
- [app/Http/Controllers/Auth/LoginController.php](/app/Http/Controllers/Auth/LoginController.php) — renders both login pages, **admin** password sign-in (`storeManage`), logout, dev quick-logins (`devLogin`).
- [app/Http/Controllers/Concerns/ResolvesPortalHome.php](/app/Http/Controllers/Concerns/ResolvesPortalHome.php) — the shared `homeFor($user, $portal)` / `portalFrom()`: **redirect-by-DOOR** (§6), used by all three auth controllers. Portal constants live on [`User::PORTAL_MAIN` / `PORTAL_ADMIN`](/src/People/User.php); pinned by [tests/Feature/Auth/PortalHomeRedirectTest.php](/tests/Feature/Auth/PortalHomeRedirectTest.php).
- [app/Http/Controllers/Auth/PasswordlessLoginController.php](/app/Http/Controllers/Auth/PasswordlessLoginController.php) — request + verify the email / phone OTP (customer AND admin); holds the anti-enumeration + verification gates. Also keeps the LEGACY magic-link consume path alive for links already sent.
- [app/Http/Controllers/Auth/RegisterController.php](/app/Http/Controllers/Auth/RegisterController.php) — the `/register` dual-verification (both codes on one screen) → create / apply-with-supersede / auto-merge.
- [app/Http/Controllers/Auth/MagicLoginController.php](/app/Http/Controllers/Auth/MagicLoginController.php) — legacy signed magic-link sign-in (main users only).
- [app/Services/Auth/PasswordlessAuth.php](/app/Services/Auth/PasswordlessAuth.php) — the core engine: issue/verify OTP codes (+ the legacy magic-link tokens) (Cache-backed, single-use, attempt-capped), resolve the account, the `completeRegistration` matrix, `autoMergeVerifiedPair` (+ pending fallback, old-key security notice). *(The old early-block `flagDuplicateAccounts` was removed 2026-07-23.)*
- [app/Mail/ContactKeyChangedMail.php](/app/Mail/ContactKeyChangedMail.php) — the "your contact details were updated" security notice sent to the previous email on a supersede.
- [src/Lead/Support/IdentityChildMap.php](/src/Lead/Support/IdentityChildMap.php) — the declarative registry of every person-referencing column; drives the merge + purge bulk steps; enforced by [tests/Feature/People/IdentitySchemaCoverageTest.php](/tests/Feature/People/IdentitySchemaCoverageTest.php).
- [src/People/ContactKeyHistory.php](/src/People/ContactKeyHistory.php) — audit archive of superseded keys (`contact_key_histories`); never consulted by the identity gate.
- [app/Console/Commands/TestWhatsappOtp.php](/app/Console/Commands/TestWhatsappOtp.php) — `whatsapp:otp-test {phone}`: replays the WhatsApp OTP pipeline loudly (phone normalisation → login gate → template gate per condition → live Meta probe → payload → real send with Meta's full error body). The way to debug "the code never arrives".
- **Removed:** `PasswordResetController` (public forgot-password) — it signed a user in on proof of the **email alone**, quietly reducing the dual-key gate to single-factor; an admin who forgets their password now uses the magic link on `/manage/login` and resets from Profile → Password.

**Middleware**
- [app/Http/Kernel.php](/app/Http/Kernel.php) — route-middleware aliases.
- [app/Http/Middleware/EnsureUserIsAdmin.php](/app/Http/Middleware/EnsureUserIsAdmin.php) · [EnsureUserIsMainUser.php](/app/Http/Middleware/EnsureUserIsMainUser.php) (portal entry **+ verification hold + lazy `ensureLeadForPortal`**, §7) · [EnsureContactIsVerified.php](/app/Http/Middleware/EnsureContactIsVerified.php) (`contact.verified` — confines a customer with an unproven email **or phone** to `/dashboard`) · [EnsureUserIsMember.php](/app/Http/Middleware/EnsureUserIsMember.php) · [RedirectIfAuthenticated.php](/app/Http/Middleware/RedirectIfAuthenticated.php)  *(`EnsurePasswordReset` was removed with the forgot-password flow)*
- [app/Http/Middleware/HandleInertiaRequests.php](/app/Http/Middleware/HandleInertiaRequests.php) — shares `auth.user` + `membership` to every page.

**Migrations**
- [database/migrations/2020_05_25_042751_create_people_tables.php](/database/migrations/2020_05_25_042751_create_people_tables.php) — `users`.
- [database/migrations/2020_05_25_042752_create_user_profiles_table.php](/database/migrations/2020_05_25_042752_create_user_profiles_table.php)
- [database/migrations/2026_06_09_000003_make_users_email_nullable.php](/database/migrations/2026_06_09_000003_make_users_email_nullable.php)
- [database/migrations/2020_05_25_042762_create_leads_table.php](/database/migrations/2020_05_25_042762_create_leads_table.php) — `leads.user_id` nullable+**unique**.
- [database/migrations/2026_06_03_000002_create_admins_table.php](/database/migrations/2026_06_03_000002_create_admins_table.php) · [2026_06_11_000001_add_unique_to_admins_user_id.php](/database/migrations/2026_06_11_000001_add_unique_to_admins_user_id.php) · [2026_06_03_000004_drop_position_from_users_table.php](/database/migrations/2026_06_03_000004_drop_position_from_users_table.php)
- [database/migrations/2026_06_02_104952_create_permission_tables.php](/database/migrations/2026_06_02_104952_create_permission_tables.php) — spatie roles/permissions.
- [database/migrations/2026_06_03_000005_create_member_subscriptions_table.php](/database/migrations/2026_06_03_000005_create_member_subscriptions_table.php) · [2026_06_13_000005_rekey_member_subscriptions_to_lead.php](/database/migrations/2026_06_13_000005_rekey_member_subscriptions_to_lead.php) · [2026_06_13_000006_make_lead_id_owner_on_portal_tables.php](/database/migrations/2026_06_13_000006_make_lead_id_owner_on_portal_tables.php) · [2026_06_16_000003_simplify_membership_schema.php](/database/migrations/2026_06_16_000003_simplify_membership_schema.php)
- **Passwordless / identity-merge (2026-07-16):** [2026_07_16_000004_add_contact_verification_columns.php](/database/migrations/2026_07_16_000004_add_contact_verification_columns.php) — `email_verified_at` / `phone_verified_at` (left NULL for existing accounts). · [2026_07_16_000001_create_verified_identity_pairs_table.php](/database/migrations/2026_07_16_000001_create_verified_identity_pairs_table.php) · [2026_07_16_000002_add_merge_workflow_to_identity_pairs.php](/database/migrations/2026_07_16_000002_add_merge_workflow_to_identity_pairs.php) · [2026_07_16_000003_reopen_dismissed_identity_pairs.php](/database/migrations/2026_07_16_000003_reopen_dismissed_identity_pairs.php) — the merge-request table + admin workflow.
- **Supersede archive (2026-07-23):** [2026_07_23_000001_create_contact_key_histories_table.php](/database/migrations/2026_07_23_000001_create_contact_key_histories_table.php) — `contact_key_histories`, the audit-only archive of replaced keys.

**Seeders**
- [database/seeds/RolesSeeder.php](/database/seeds/RolesSeeder.php) — 4 roles + permissions.
- [database/seeds/PeopleSeeder.php](/database/seeds/PeopleSeeder.php) — super-admins / admins / demo non-member (+ a lead for every main-portal demo).
- [database/seeds/MemberSubscriptionsSeeder.php](/database/seeds/MemberSubscriptionsSeeder.php) — demo members + their leads + enrolments.

**Routes**
- [routes/main.php](/routes/main.php) — landing, auth, magic link, password reset, the user portal.
- [routes/web.php](/routes/web.php) — the whole `/manage/*` admin portal (`['auth','admin']`).

---

## Related modules

- [SMS](/docs/modules_handbook/shared/sms/readMe.md) — the shared `SmsSender` that delivers every phone code here (and its IP-whitelist trap).
- [Landing & Lead Capture](/docs/modules_handbook/main/landing-lead-capture/readMe.md) — *captures* the lead (creates user+lead).
- [Leads (Manage)](/docs/modules_handbook/manage/leads/readMe.md) — *manages* leads in the admin portal.
- [Admins (Manage)](/docs/modules_handbook/manage/people/admins/readMe.md) · [Roles (Manage)](/docs/modules_handbook/manage/people/roles/readMe.md) — admin accounts + RBAC UI.
- [Members](/docs/modules_handbook/manage/membership/members/readMe.md) · [Memberships](/docs/modules_handbook/manage/membership/memberships/readMe.md) — enrolling members + tiers.
- [Profile](/docs/modules_handbook/shared/profile/readMe.md) — the shared profile foundation (`user_profiles` + addresses + ID upload), used by both portals.
