# Merge Requests (Manage · Setting)

**Context:** Manage · **Route:** `manage.people.merge-requests.*` (`/manage/people/merge-requests`, `admin`) · **Nav:** Setting → **Merge Requests** ([`SettingTabs.vue`](/resources/js/Components/SettingTabs.vue))

## What it does
The queue of **one person who ended up with two accounts** — their email belongs to one and their
phone to the other. An admin reviews each pair, chooses which account survives, and merges. The
merge re-parents everything the retired account owned onto the survivor.

**Merging is the only resolution — there is no dismiss.** A pair is a statement that two rows describe
one human; leaving it open is the only alternative to fixing it.

> **Moved here 2026-07-29.** It used to be an amber banner + modal on the Leads index. That worked
> while pairs were rare leftovers. They are not any more: a public capture form whose typed phone
> already belongs to another account now raises one automatically (see
> [Lead Linking](/docs/modules_handbook/shared/lead-linking/readMe.md), scenario 5), so the queue fills
> itself and needed a real list, filters, and an audit trail. It is also not a per-lead action — it is
> identity governance, which is why it sits with Admins and Roles.

## How it works
- **Where pairs come from.** The `/register` identity matrix — which is **three** public surfaces,
  not one: `/register`, the Property Match quiz and the Rental Estimate form all run
  `PasswordlessAuth::completeRegistration` (a privileged side is never auto-merged, so it lands here),
  the CSV-import conflict tickbox, an admin edit, the **payment-import review screen** (a gateway buyer
  whose email and phone land on two accounts — see
  [Payment Links](/docs/modules_handbook/manage/payments/payment-links/readMe.md)), and — the volume
  driver — `RegisterLeadAction` when a public registration's phone is owned by a different
  email-holding account (`LeadRepository::createMergeRequest`). Full matrix in the
  [login handbook](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md).

  > ### The one pair that is ASSERTED, not detected (2026-08-07)
  >
  > Every filer above keys off a contact-key **collision** — this email is on one account, that phone
  > on another (`detectMergePair`). Two records for one human very often share **no key at all**: a
  > second email, a second number. The collision never fires, so the duplicate is invisible to the
  > entire machine and no automatic path can ever raise it. Only a person reading the two records can.
  >
  > So Leads carries a **Merge duplicate** action — a row action on the index and a header button on
  > the Show page (`isAdmin` only, matching this queue) → `POST manage/leads/{id}/merge-request`
  > ([`LeadsController::proposeMerge`](/app/Http/Controllers/Manage/Leads/LeadsController.php)) →
  > [`Partials/MergeLeadPickerModal.vue`](/resources/js/Pages/Manage/Leads/Partials/MergeLeadPickerModal.vue),
  > which searches through the existing `manage.leads.search` typeahead (already `LeadVisibility`-scoped).
  >
  > **It files the pair and STOPS.** The merge runs in the very same `MergeReviewModal` this page uses,
  > mounted straight onto the Leads page with the new pair's id — so an asserted merge gets the
  > identical preview, staff winner-lock, double-billing warning, irreversibility acknowledgement and
  > audit row as a detected one. **There is deliberately no second merge path.**
  >
  > Because it can name *any* two accounts, it carries its own guards: admin-only, both sides
  > visibility-checked (a uuid in a payload is a request, not a grant), same-account refused, an
  > already-retired account refused, two staff refused, and — since an asserted pair has no natural
  > orientation — an existing **reversed** pending pair is reused rather than opening a second row for
  > the same two humans. Pinned by
  > [`ProposeMergeFromLeadsTest`](/tests/Feature/People/ProposeMergeFromLeadsTest.php).
  >
  > One consequence for the copy: the column headers are **"One account" / "The other account"**, not
  > "Email account" / "Phone account", and the intro no longer claims every pair came from a key
  > collision — on an asserted row that would simply be false.

- **Which account survived, and is everything still on it? (2026-08-07)** A merged row badges the
  survivor **Kept** and the retired side **Retired**, and the survivor's name links straight to
  `/manage/leads/{uuid}` — the one screen where every re-parented record (bookings, payments,
  membership, WhatsApp) actually shows up, which is how an admin checks the merge afterwards.
  The direction is **not stored on the pair**: the merge stamps the retire pointer on the loser
  (`users.merged_into_user_id` = the survivor), and `MergeRequestsController::winnerSide()` reads it
  back. That is what makes it answerable for **every merge ever done**, including the automatic ones
  that were never pending and have no reviewer — no migration, no backfill. It is null-tolerant (a
  hard-deleted side), and falls back to "whichever side is not retired" for a **chain**, where the
  survivor was itself merged away later and now points at some third account.
- **The list.** `MergeRequestsController@index` → `Pages/Manage/People/MergeRequests/Index.vue`, built
  on the shared DataTable foundation (GUIDELINES §14): `QueryRequest` filters (status multi-select +
  date range), `ResolvesListQuery` for whitelisted sort / per-page, `FilterDrawer` + `ActiveFilterChips`.
- **Pending by default, and it says so.** With no status filter the builder adds
  `where('status', STATUS_PENDING)` and the page prints *"Showing pending requests only — use Filters
  to include ones already merged"*. Merged rows are **history, not clutter**: a merge re-parents
  customer data system-wide, so "what happened to that account, and who did it" has to stay answerable
  (`reviewed_by` / `reviewed_at` are columns on the table).
  ⚠️ The default is applied to the **builder**, not by seeding the input bag — the query request reads
  its own filters, so a `merge()` onto the HTTP request would not be seen.

  > ### ⚠️ An empty queue does NOT mean no merges happened
  >
  > Of the five paths that merge, **four never file a PENDING row at all** — they call
  > `createMergeRequest` and `mergeVerifiedPair` back to back, so the pair is **born already MERGED**
  > and is invisible on the default view: `/register` dual-key auto-merge
  > ([`PasswordlessAuth::autoMergeVerifiedPair`](/app/Services/Auth/PasswordlessAuth.php)), the
  > CSV-import conflict tickbox ([`BulkMemberImportAction::mergeConflictPair`](/app/Actions/BulkMemberImportAction.php)),
  > and the Lead / Admin **edit-confirm** dialogs
  > ([`LeadsController::update`](/app/Http/Controllers/Manage/Leads/LeadsController.php) ·
  > [`AdminsController::update`](/app/Http/Controllers/Manage/People/AdminsController.php)). Only this
  > page's own review modal starts from a row that was ever pending.
  >
  > **To audit merges, filter Status → Merged.** The `note` column is what tells them apart —
  > `auto: register (dual-key verified)` / `auto: import (conflict tickbox)` / `admin: edit-confirmed`,
  > or blank for a merge done here; `reviewed_by` is null exactly when nobody chose (the `/register`
  > auto-merge). This is why every merge stamps a provenance note: without it an automatic merge and a
  > hand-reviewed one are indistinguishable after the fact.
  >
  > **And the queue is not an indelible log.** A pair row can be deleted afterwards — the merge itself
  > drops **other** pairs touching the retired account (`mergeVerifiedPair` step E), a failed
  > admin-edit merge discards the row it just filed (`discardMergeRequest`), a supersede sweeps pairs
  > whose key snapshot went stale (`sweepStalePendingPairs`), and a lead purge deletes them with the
  > accounts (`purgeRows`). The evidence that always survives is on the account itself:
  > **`users.status = STATUS_MERGED` + `users.merged_into_user_id`** (the survivor), plus the
  > `Account merge completed.` log line. ⚠️ Nothing in the UI surfaces those two columns today —
  > `User::isMerged()` / `mergedInto()` exist but no page calls them, so "where did this account go?"
  > is currently a DB/log question.
- **Searching** matches `email` **or** `phone` — the two keys that produced the pair are what a customer
  quotes when they write in, never a pair id.
- **Null-tolerant rows.** Either side may have been hard-deleted since the pair was raised, so
  `email_user` / `phone_user` can be null and the row renders "account removed" instead of 500-ing.
- **Who may merge (2026-08-06).** `isAdmin()` only — everywhere, not just here. The two
  edit-modal shortcuts (Lead edit, Admin edit) sit behind `permission:manage-leads` /
  `manage-admins`, which sales-agent, sales-leader and group-super-admin all hold by default, so
  they used to merge accounts this page 403s them for. Both now check `isAdmin()` 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). A non-admin who hits a real
  duplicate gets it **filed as a pending request** instead — the work still lands here.
- **The edit shortcut is scoped to the record on screen.** The merge keys off the two values
  TYPED into the form, so a mis-pasted spreadsheet row used to merge two uninvolved strangers
  while the lead being edited was left untouched. `OffersIdentityMerge` now requires the pair to
  include the edited account; anything else falls through to the ordinary "already belongs to
  another account" errors. Merging two arbitrary accounts is still possible — from this page,
  by an admin, on purpose. Pinned by
  [`MergeConfirmedGuardTest`](/tests/Feature/People/MergeConfirmedGuardTest.php).
- **Every row says WHO raised it, because the evidence is not comparable (2026-08-07).**
  Six filers land in one queue: `/register`'s dual OTP means the **customer answered a code sent to
  the email AND one sent to the phone in one sitting**; a public capture form means a **stranger typed
  somebody else's number**. On screen those looked identical, so an admin could merge the second
  believing it carried the proof of the first — retiring a real customer's account into the stranger's,
  which is the exact takeover the identity design exists to prevent, performed by hand through this UI.
  `verified_identity_pairs.source` now records it ([`VerifiedIdentityPair::SOURCES`](/src/People/VerifiedIdentityPair.php)),
  the list carries a **Raised by** badge and the review modal a green/amber banner. **`proven` is the
  only flag the UI may branch on** — it is true for `SOURCE_REGISTER` alone. The column is **nullable**:
  rows raised before it existed have no recoverable provenance, so they read *"Unknown"*, never
  "verified" — inventing one would be worse than admitting it.
- **Pick the survivor, then pick its DETAILS (2026-08-07).** Keeping an account and keeping its contact
  details are different questions, and forcing one answer was losing real data. The survivor defaults to
  whichever account holds more records — very often the one an **import** created, so it carries a
  manufactured placeholder email (`booking_…@noemail.local`) while the thin account holds the address
  the customer actually reads. The modal now has a **name / email / phone** row under the survivor
  choice, each defaulting to the survivor's value **except where that value is missing or a
  placeholder**, where the real one is pre-selected. Rules that make it safe:
    - **A pick is not proof.** A value keeps its `verified` stamp only when the account it came FROM had
      proved that exact value (carried in the same save — the one carve-out the model hooks honour).
      Everything else lands unverified and the customer re-proves it at `/register`.
    - **Whatever loses is archived first** — on *either* side. A pick can replace a value the survivor
      already had, and that one is destroyed just as surely as the loser's.
    - **A pick for a side that has nothing falls back to the other.** A merge must never leave the
      survivor with less than doing nothing would have.
    - **A staff survivor's name is never overwritten** (`adoptName` refuses staff; the merge skips it
      rather than aborting).
    - **Only this screen sends picks.** Every automatic path — `/register`, imports, the Stripe webhook —
      has no human watching, so it keeps the conservative rule: the survivor's own value stands and it
      only inherits what it lacks.
  Pinned by [`MergeFieldChoiceTest`](/tests/Feature/People/MergeFieldChoiceTest.php) and
  [`MergeProvenanceTest`](/tests/Feature/People/MergeProvenanceTest.php).
- **Review + merge.** The row action opens
  [`Partials/MergeReviewModal.vue`](/resources/js/Pages/Manage/People/MergeRequests/Partials/MergeReviewModal.vue)
  straight into the detail for that request (`startId` prop; the table is the list now, so "back" closes).
  It fetches the heavy per-pair preview from `@show` on demand, defaults the survivor to the richer
  account, **locks** the survivor when one side is staff, and requires an explicit acknowledgement
  because the merge is irreversible.

## Nothing may be left behind

The loser account is **retired** — nobody can sign into it again — so any row still pointing at it is
data the customer has silently lost. Two things enforce that:

- **[`IdentityChildMap`](/src/Lead/Support/IdentityChildMap.php)** is the single source of truth for
  every column that points at a person and what the merge must do with it (`REPOINT` / `SPECIAL` /
  `SKIP`). The merge LOOPS over it (`mergeRepointLeadColumns()` / `mergeRepointUserColumns()`), so a
  column added to the map is re-pointed automatically — no second list to keep in sync.
- **[`IdentitySchemaCoverageTest`](/tests/Feature/People/IdentitySchemaCoverageTest.php)** censuses the
  live schema against that map and fails while any `*_lead_id` / `*_user_id` / `*_admin_id` column is
  unclassified. An unclassified column is data that *will* be stranded.
- **[`MergeMovesEverythingTest`](/tests/Feature/People/MergeMovesEverythingTest.php)** proves the loop
  actually works: it seeds a row against the loser in every re-pointable table it can build one for,
  merges, and asserts not one row still points at the retired account — plus named cases for the two a
  customer would actually feel (their **payment link**, and the **people they introduced**).

> ### ⚠️ The census only sees `*_lead_id` / `*_user_id` / `*_admin_id`
>
> A child reached through a **different parent's id** is invisible to it, so nothing fails when one
> is stranded. The merge hard-deletes two such parents, and each needs its own hand-written step:
> - the loser's **engagement**, when both accounts hold one on the same project —
>   `bookings.engagement_id` and `appointments.engagement_id` are re-pointed at the SURVIVING
>   engagement first, or the booked sale disappears from the sales board and its edit/cancel routes
>   403 for good ([`MergeEngagementCollisionTest`](/tests/Feature/People/MergeEngagementCollisionTest.php));
> - the loser's **lead**, whose `lead_action_item_assignees` hang off `action_item_id` — swept in
>   `purgeRows` next to `engagement_assignments`.
>
> Adding a table keyed on a non-person parent? Write the step and a test; the census will not
> remind you.

> ### Classified 2026-08-06 — the census was RED on HEAD
>
> Six live columns were unclassified, so the merge stranded their rows on a hard-deleted lead and
> the purge could never delete them either: `lead_action_items` (an OPEN follow-up task — the
> Discussion tab's whole content), `video_watch_events`, `lead_project_views`,
> `lead_floor_plan_views`, `funnel_automation_sends` (the email/SMS de-dupe ledger, `to` column
> holds PII) and `zoom_meetings.suggested_lead_id`.
>
> **Three of them could not be added as `MERGE_REPOINT`** despite the map's own "add it as REPOINT"
> instruction: they carry composite uniques, so a blind re-point throws a 1062 mid-transaction and
> aborts the *whole* merge for anyone affected — turning a stranded-row bug into "this customer can
> never be merged". `funnel_automation_sends` is deduped like its WhatsApp twin; the two **view
> rollups are COMBINED, not deduped** (`mergeInterestRollup` — counts summed, `first_viewed_at`
> earliest, `last_viewed_at` latest), because dropping the loser's row would throw away the counts
> and dates that are the row's entire content. Same call `lead_ai_credits` makes when it sums
> balances.

> **Classified 2026-07-29**, when the queue started filling itself:
> `payment_links.lead_id` (REPOINT / UNLINK — the link belongs to the business, `lead_id` is only an
> optional buyer lock, so a purge releases it rather than destroying the seller's record),
> `leads.referred_by_lead_id` (REPOINT — otherwise an introducer's referral chain dead-ends at a
> retired account), `notify_destinations.user_id` (SKIP — an admin's own alert destination, staff
> config), and `flg_webhook_events.flg_lead_id` (SKIP — Meta's lead id on the raw webhook envelope,
> **not** one of ours; the census matches on column NAME, so it has to be declared to be declared
> harmless).

## Related files
**Controller** — [app/Http/Controllers/Manage/People/MergeRequestsController.php](/app/Http/Controllers/Manage/People/MergeRequestsController.php) (`index` / `show` / `merge`; `winnerSide()` reads the survivor back off the retire pointer) · [LeadsController::proposeMerge](/app/Http/Controllers/Manage/Leads/LeadsController.php) (`POST manage/leads/{id}/merge-request` — the admin-asserted pair)
**Requests** — [app/Http/Requests/Manage/People/MergeRequests/QueryRequest.php](/app/Http/Requests/Manage/People/MergeRequests/QueryRequest.php) · [MergeRequest.php](/app/Http/Requests/Manage/People/MergeRequests/MergeRequest.php) (authorises the chosen winner)
**Views** — [Pages/Manage/People/MergeRequests/Index.vue](/resources/js/Pages/Manage/People/MergeRequests/Index.vue) · [Partials/MergeReviewModal.vue](/resources/js/Pages/Manage/People/MergeRequests/Partials/MergeReviewModal.vue) (mounted here **and** on both Leads pages — one merge UI) · [Partials/AccountCell.vue](/resources/js/Pages/Manage/People/MergeRequests/Partials/AccountCell.vue) (Kept / Retired + the survivor's lead link) · [Leads/Partials/MergeLeadPickerModal.vue](/resources/js/Pages/Manage/Leads/Partials/MergeLeadPickerModal.vue)
**Model** — [src/People/VerifiedIdentityPair.php](/src/People/VerifiedIdentityPair.php) (`STATUS_PENDING` / `STATUS_MERGED`)
**Repository** — [src/Lead/Repositories/LeadRepository.php](/src/Lead/Repositories/LeadRepository.php) (`createMergeRequest`, `previewMerge`, `mergeVerifiedPair`)
**Nav** — [resources/js/Components/SettingTabs.vue](/resources/js/Components/SettingTabs.vue) (`isAdmin`-gated, matching the route's `admin` middleware — a tab that 403s is a bug)
**Tests** — [MergeRequestsPageTest](/tests/Feature/People/MergeRequestsPageTest.php) · [ProposeMergeFromLeadsTest](/tests/Feature/People/ProposeMergeFromLeadsTest.php) (the asserted pair + the survivor read-back) · [MergeConfirmedGuardTest](/tests/Feature/People/MergeConfirmedGuardTest.php) · [MergeLiveStateTest](/tests/Feature/People/MergeLiveStateTest.php) · [MergeEngagementCollisionTest](/tests/Feature/People/MergeEngagementCollisionTest.php)
**Migrations** — none new (the table predates this page).

**See also:** [Users · Leads · Admins · Login](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md) (where pairs come from) · [Lead Linking](/docs/modules_handbook/shared/lead-linking/readMe.md) (the capture form's five identity shapes) · [Leads](/docs/modules_handbook/manage/leads/readMe.md)
