# Owner Listing (Manage)

**Nav:** Hub → *Owner Listing* suite → one sidebar entry, **Owner Search**, fronting a two-tab
strip ([`SectionTabs`](/resources/js/Components/SectionTabs.vue) section `owner-listing`):
**Search** · **Check a registration**.

> ⚠️ **Not the Subsale Suite's "owner listing".** That one — `Src\FacebookLeadGenerator`,
> routes `manage.subsale-projects.owner-listing.*`, tables `flg_owner_listing_*` — is a
> per-project list you **upload** and then **message** for outreach. This is the searchable
> **archive** you look one person up in. Different data, different database, different job.
> See [*Naming*](#naming) below.

## What it does

Looks a property owner up before you call them, and tells you how much of the match can be
stood behind.

Two questions, running in opposite directions:

- **Search** — you already have a person (a name, a mobile, an email) and want to know what the
  archive holds against them: how many units across how many properties, in which areas, rented
  or owned, and from which source archive.
- **Check a registration** — a webinar signup just arrived and you want to know whether it is a
  property owner at all. Returns a **band** (very high → no match), a score, and a sentence
  saying what that means for the call.

The data is an imported third-party archive: **3,647,827 records** covering **1,325,116 owners**
across **38,766 properties**, assembled from four sources (`VP_2026_June`, `NEW_LISTING_2021`,
`NewListing_areas`, `TNB_Listing`). Nothing in the application writes it.

### The three rules the module is built around

1. **A phone number is the identity key, and it is not a verified identity.** Two people sharing
   a line look like one owner; one person with two numbers looks like two. An owner's panel lists
   other numbers seen at the same units so a reader can spot the second case.
2. **Mobile is the only strong key. A name-only hit never reports a holding count.**
   `TAN CHEE KEONG` maps to 129 different phone numbers. Where the archive cannot tell which
   person it found, it says so and lists the candidates — it does not print a number somebody
   will then quote on a call.
3. **A number on more than ten properties is a switchboard, and its figures are withheld.**
   Almost always a developer, agency, management office or telco. The count arrives as `null`
   (never `0`) with the reason on screen.

## How it works

### Its own database

`owner_search` on **local MySQL**, reached over the `owner_listing` connection
([`config/database.php`](/config/database.php)) and **never joined to the platform database**.
Two connections onto one schema:

| Connection | MySQL user | Rights | Used by |
|---|---|---|---|
| `owner_listing` | `owner_read` | `SELECT` on the schema, `INSERT` on `owner_lookups` only | the app |
| `owner_listing_admin` | `owner_admin` | everything, on this schema only | migrations + `owner:import` |

So the web path cannot change or drop the archive — only read it and record that it did.

Local rather than Cloud SQL on purpose: a lookup is a socket read instead of a network hop, the
PII stays out of the instance's automated backups and out of the prod→dev refresh, and the whole
schema is rebuildable from the source archive. Moving it to a managed server is the `OWNER_DB_*`
env block plus a re-import — no code reads a host or a database name.

### Import, in two steps

```bash
# 1. records  — needs the source .db; shells out to the python exporter because
#               PHP on this host has no sqlite driver
php artisan owner:import /path/to/owner_search.db

# 2. index    — profiles + name tokens, built in PHP by the same classes the
#               search reads them with
php artisan owner:build-index
```

`owner:import` drops the secondary indexes for the load and rebuilds them after, verifies the
row count against the export manifest, and refuses a half-finished export (the manifest is
written last). The exporter only moves bytes — **every decision about the data is made in PHP**,
so there is exactly one implementation of each and the index cannot mean something different
from the search that reads it.

### The three derived tables, and why each exists

- **`owner_profiles`** (one row per phone) — because the honest answer to "how many properties"
  is not `COUNT(DISTINCT property_key)`. The four sources spell the same building differently
  (`PANGSAPURI CENGAL` / `Pangsapuri Chengal`) and some rows carry only a `TOWN 12345` fallback,
  so near-duplicate labels for the same unit have to be **merged per owner, in code**
  ([`OwnerProfileBuilder`](/src/OwnerListing/Services/OwnerProfileBuilder.php)). Doing that on
  every keystroke would mean reading every holding of every result. The **same builder** runs
  again when one owner is opened, so the count on the card and the rows beneath it cannot
  disagree.
- **`owner_name_tokens`** (our own inverted index) — because MySQL's
  `innodb_ft_min_token_size` is **3**, and a `FULLTEXT` index would have silently dropped every
  two-letter Malaysian surname: NG, LO, OH, YU, GO, SO. A confident empty result is the worst
  failure a lookup tool has. Prefix matching is a range scan (`token >= 'tan' AND token < 'tan~'`),
  intersected in SQL so a common first word never ships megabytes to PHP.
- **`owner_lookups`** (the access trail) — see below.

### The access trail

Every search, every registration check and every owner opened appends one row, through
[`LookupAuditor`](/src/OwnerListing/Services/LookupAuditor.php). It never throws at its caller:
the access has already happened by the time it runs, so throwing would lose the reader's result
and still not prevent it.

**The search term is not stored — only a SHA-256 of its normalised form.** That stops the log
becoming a second copy of the personal data (a leaked audit log would otherwise be a list of
exactly the people somebody cared about) while still answering the question that matters: hash a
complainant's phone number and the log says who looked them up and when. Phone numbers are
normalised *before* hashing, so `012-345 6789` and `+60123456789` produce the same hash.

For the same reason, `manage/owner-listing*` is on the private-path list in
[`LogRequest`](/app/Http/Middleware/LogRequest.php): without it, a GET search term would be
written into `laravel.log` in the clear and the hashing above would be pointless.

### Access control

**One permission, `view-owner-listing`,** and it is a heavy one — holding it means being able to
search a million people and see their mobile, email and home unit numbers. There is no read-only
tier beneath it, because there is nothing useful to show somebody who may not see a contact
detail: the whole tool *is* the contact detail.

Only **super-admin** holds it after a seed. It is deliberately excluded from the legacy `admin`
role's `syncPermissions` in [`RolesSeeder`](/database/seeds/RolesSeeder.php) — the same treatment
the journey permissions get — so a broad legacy role does not quietly acquire the archive. Grant
it per person on **Manage → People → Roles**.

Routes are additionally rate-limited (`throttle:60,1`): the suite is for looking one person up,
and the limit is what keeps it from being walked.

### What never reaches a URL

- **An owner is addressed by surrogate id** (`?owner=123`), never by phone. A phone number in a
  query string ends up in browser history, the referer header and every access log en route.
- **The registration check is a POST that redirects back** with its verdict as a one-shot flash,
  so the name, mobile and email typed into it never appear in a URL at all.
- **The result list shows no unit numbers or addresses.** Those appear only when a reader opens
  one owner — clicking the row opens a modal with that owner's portfolio, and that is the access
  the trail records. A list that printed home units would put fifty people's addresses on screen
  for a reader who wanted one.

  The modal opens IMMEDIATELY on click and fills when the `only: ['owner']` partial reload lands,
  rather than waiting for it: a click that appears to do nothing for 200ms reads as a broken
  button and people click again. While it is loading the previous owner is deliberately NOT shown
  — rendering one person's holdings under another's name is worse than a skeleton.

## What it costs to run

Measured on this host against the full archive (warm, best of three):

| Query | Latency |
|---|---|
| Phone, exact | ~6 ms |
| Phone, wrong country code (the `phone_tail` fallback) | ~5 ms |
| One owner in full (holdings + other numbers) | ~11 ms |
| A distinctive full name | ~30 ms |
| Email, exact / prefix / `@domain` | 25–65 ms |
| `tan wei`, `ng siew` — the commonest prefixes in the country | 200–360 ms |
| A registration check | 1–14 ms |

The name figures are the honest ceiling: `TAN*` alone is 49,826 token rows and `WEI*` a further
19,798, so a two-word search unions ~70k index entries, groups them and takes the top 50. That
is the price of prefix-matching every word, and it is what makes partial names work at all.

**One figure here was a bug, and it is worth knowing why.** An email substring miss originally
took **15.8 seconds**. `whereNotNull('email')` is not redundant beside a `LIKE`: without it the
optimiser prefers the *phone* index (from the `whereNotNull('phone')` on the same query) and
scans 1.6M rows, when only 53,829 rows carry an email at all. With it, the email index is chosen
and the same query is 64 ms. If email search ever goes slow again, look there first.

## Reference usage — the Leads panel

The archive has one consumer outside its own pages: **Leads → Intelligence → Owner archive**
([`Src\Lead\Services\LeadOwnerMatch`](/src/Lead/Services/LeadOwnerMatch.php)). It is the
worked example for reading this archive from a feature, and it is where the cross-database rule
is actually enforced.

- **The two databases meet in ONE class.** A name, a mobile and an email go in; a verdict comes
  out. Nothing joins, and nothing can — they are on different servers.
- **It answers a question the CRM cannot ask itself.** A lead already carries two property
  counts, and both are what the PERSON said: `properties_owned` (declared) and
  `webinar_property_count` (a three-bucket poll). This is the independent one, so the panel shows
  it BESIDE them and flags a disagreement rather than quietly becoming a third number.
- **Lazy, via `Inertia::optional`.** It is not computed on page load. Every read of the archive
  is recorded against the reader, so computing it for every lead view would log accesses nobody
  made and bury the real ones.
- **`view-owner-listing` is checked in the service**, which returns `null` — not an empty result
  — so the section does not render. Hiding it in Vue is presentation; that null is the control.
  A lead page is seen by far more staff than the Owner Listing suite is, which is the whole
  reason the gate is repeated here.
- **Presentation is shared**, through
  [`VerdictPresenter`](/src/OwnerListing/Services/VerdictPresenter.php). It decides whether a
  holding figure may be shown at all, and two consumers each presenting their own would
  eventually disagree about a switchboard.

Measured on this install: **27.3% of leads that have a phone number match the archive**, and
about a third of those hold more than one property.

### The lead page shows the properties themselves

Founder request, 2026-09-18: *"at lead page when show the owner listing, dun just show number and
confidence level but also show the details of the properties"*. The Intelligence panel now renders
the full holdings — unit, property, area, type, occupancy and which archive each came from —
through the SHARED [`Components/HoldingsPanel.vue`](/resources/js/Components/HoldingsPanel.vue),
the same table the Owner Listing modal uses, so one person's holdings cannot be presented two ways.

Two rules hold here that do not hold on the list:

- **Unit numbers ARE shown.** This is one owner, opened deliberately, and the read is audited. The
  list's hover withholds them because that payload covers every matched row on the page.
- **Only for a reportable verdict.** An ambiguous or switchboard match has no holdings it can stand
  behind, so `LeadOwnerMatch::holdings()` returns null and nothing is listed — listing buildings
  under a count the same panel refuses to print would show exactly what was withheld.

The panel also carries a line about the archive's known over-count (the same building spelled
differently across sources survives the merge as two rows when the areas also disagree). That
caveat lives in the Owner Listing pages' *How to read these results* panel, which the lead page
does not have — without it a reader takes two near-identical lines for a portfolio.

### The Leads LIST columns

The same archive also fills a **PROPERTIES OWNED** band on the Leads table: **Archive** (what the
archive holds) beside **Told us** (the webinar poll's bucket). Two columns, never reconciled into
one figure — they are different claims with different standing, and merging them would hide the
disagreement worth calling about.

- **One batched query per page**, not one per lead (`LeadOwnerMatch::forPage`) — 50 leads resolve
  in ~90ms against a second database.
- **Mobile only.** A name-only hit never reports a count anywhere in this system, so resolving a
  page by name could only fill the column with figures that must then be withheld, at the cost of
  a prefix scan per row. No phone means a dash, which is the truth.
- **One audit row per list view** (`OwnerLookup::ACTION_LIST`, with `scanned_count` as the
  denominator), never one per lead — 50 rows per pagination click would bury every deliberate
  lookup until the trail was unreadable.
- **Neither column sorts.** The archive is on another server, so there is no column to ORDER BY,
  and a sort arrow that silently ordered only the current page would be a lie.
- **The hover NAMES the properties**, one line per property (not per unit), so the number of lines
  always equals the figure on the cell. Costs one extra query per page, built by the same
  `OwnerProfileBuilder` that produced the stored counts. Switchboards are excluded — their figures
  are withheld, so listing their buildings would show what the count refuses to.
- **No unit numbers in the list payload.** A unit is somebody's home address, and this data is
  sent for every matched row on the page. Units appear only on the single-owner views (the lead's
  panel, the Owner Listing modal), which are one deliberate, audited look at one person.
- **Property labels are shown as the archive has them.** Flagging the ones that look like a bare
  address (`485 - 47800 BU`) as "no premise name" was tried and backed out: the same rule catches
  `23 avenue, PJU 3, Sunway Damansara` and `jalan haji Abdul manan, 41050 klang (semi detached
  factory)`, which are among the most informative labels in the set. Only 1.8% of labels match,
  and a messy real label beats a tidy placeholder that throws information away.

### The match rate, and the day it lied

**16.2% of leads that have a phone match the archive** (measured 2026-09-18 over a random 400).

An earlier figure of 27.3% here was wrong, and how it went wrong is worth keeping. The AE/FLG
owner-listing import was creating a CRM lead per owner — 792 of them on 2026-09-18 — so the
archive appeared to confirm them at "Very high 95" every time. The matches were genuine; they
were just circular, because the lead and the archive record came from the same `VP_2026_June`
intake. Those leads have since been deleted and the importer now LINKS ONLY
(`ProcessOwnerListingChunkJob::resolveLeadId`), so an owner being in the archive says nothing
about them being a lead — which is the honest state, and 16.2% is the honest rate.

**The lesson outlives the incident:** an archive match is only independent evidence when the lead
did not arrive from the same source. Anything built later that scores a match — a routing rule, a
lead grade — has to account for that, and a sudden wall of 95s should be read as an import before
it is read as a discovery.

### ⚠️ `properties_owned = 0` is not a declaration

521 of the 906 leads holding both facts carry `properties_owned = 0` while the webinar poll says
they own one or more: the member importer writes 0 as its default. So the Leads panel treats a 0
as *not recorded* rather than as a claim, and never counts it as disagreeing with the archive —
otherwise the warning would fire on the majority of matched leads and be trained out of readers
within a week. A POSITIVE value is a real declaration.

## Naming

The phrase "owner listing" was already taken in this codebase when this module was built, by the
Subsale Suite's per-project outreach lists (`Src\FacebookLeadGenerator`). The two do not collide
technically — different routes, tables, controllers and database — but they do collide in
conversation, and anyone searching the codebase for "owner listing" will find both.

Worth resolving one way or the other. The natural relationship is that **this archive could
become the source of those lists**, replacing the spreadsheet upload with a query — at which
point one of the two names should change.

## Related files

### Backend

| File | Role |
|---|---|
| [`src/OwnerListing/OwnerRecord.php`](/src/OwnerListing/OwnerRecord.php) | one (person, property) sighting; status + source vocabularies, the switchboard threshold |
| [`src/OwnerListing/OwnerProfile.php`](/src/OwnerListing/OwnerProfile.php) | one owner = one phone; `countsAreReportable()` |
| [`src/OwnerListing/OwnerNameToken.php`](/src/OwnerListing/OwnerNameToken.php) | the name index; `prefixCeiling()` |
| [`src/OwnerListing/OwnerLookup.php`](/src/OwnerListing/OwnerLookup.php) | the access trail; `hashTerm()` |
| [`src/OwnerListing/Support/PhoneNormaliser.php`](/src/OwnerListing/Support/PhoneNormaliser.php) | typed number → stored E.164 form |
| [`src/OwnerListing/Support/NameTokeniser.php`](/src/OwnerListing/Support/NameTokeniser.php) | name → identifying words; name-vs-name agreement |
| [`src/OwnerListing/Services/OwnerProfileBuilder.php`](/src/OwnerListing/Services/OwnerProfileBuilder.php) | the property merge — the one place "how many properties" is answered |
| [`src/OwnerListing/Services/OwnerSearchService.php`](/src/OwnerListing/Services/OwnerSearchService.php) | kind detection + the three search paths + one owner in full |
| [`src/OwnerListing/Services/RegistrationMatcher.php`](/src/OwnerListing/Services/RegistrationMatcher.php) | the bands; `isReportable()` |
| [`src/OwnerListing/Services/VerdictPresenter.php`](/src/OwnerListing/Services/VerdictPresenter.php) | one owner / one verdict as a screen receives it — shared, so consumers cannot disagree |
| [`src/OwnerListing/Services/LookupAuditor.php`](/src/OwnerListing/Services/LookupAuditor.php) | the only writer in the module |
| [`src/Lead/Services/LeadOwnerMatch.php`](/src/Lead/Services/LeadOwnerMatch.php) | the Leads consumer — the one place the two databases meet |
| [`app/Http/Controllers/Manage/OwnerListing/OwnerListingController.php`](/app/Http/Controllers/Manage/OwnerListing/OwnerListingController.php) | both pages; withholds a switchboard's figures |
| [`app/Http/Requests/Manage/OwnerListing/SearchRequest.php`](/app/Http/Requests/Manage/OwnerListing/SearchRequest.php) | the search box |
| [`app/Http/Requests/Manage/OwnerListing/CheckRequest.php`](/app/Http/Requests/Manage/OwnerListing/CheckRequest.php) | at least one of name / mobile / email |
| [`app/Console/Commands/OwnerListing/ImportOwnerArchive.php`](/app/Console/Commands/OwnerListing/ImportOwnerArchive.php) | `owner:import` |
| [`app/Console/Commands/OwnerListing/BuildOwnerIndex.php`](/app/Console/Commands/OwnerListing/BuildOwnerIndex.php) | `owner:build-index` |
| [`scripts/owner-listing/export-sqlite.py`](/scripts/owner-listing/export-sqlite.py) | the archive → TSV; moves bytes only |

### Frontend

| File | Role |
|---|---|
| [`Pages/Manage/OwnerListing/Index.vue`](/resources/js/Pages/Manage/OwnerListing/Index.vue) | the search box, the result list, and the owner modal (shared `Modal`, which teleports to body — a hand-rolled overlay inside AppShell's `@container` centres on the document, not the viewport) |
| [`Pages/Manage/OwnerListing/Check.vue`](/resources/js/Pages/Manage/OwnerListing/Check.vue) | the registration check; band + note |
| [`Components/OwnerSignals.vue`](/resources/js/Components/OwnerSignals.vue) | the chips; suppresses the rest when a number is a switchboard. SHARED — the Leads panel shows the same signals |
| [`Pages/Manage/Leads/Partials/Tabs/OwnerArchiveSection.vue`](/resources/js/Pages/Manage/Leads/Partials/Tabs/OwnerArchiveSection.vue) | the Leads → Intelligence panel |
| [`Pages/Manage/Leads/Partials/OwnerArchiveCell.vue`](/resources/js/Pages/Manage/Leads/Partials/OwnerArchiveCell.vue) | the Leads list's Archive column + its hover |
| [`Pages/Manage/Leads/Partials/WebinarPropertyCell.vue`](/resources/js/Pages/Manage/Leads/Partials/WebinarPropertyCell.vue) | the Leads list's Told us column + its hover |
| [`Pages/Manage/OwnerListing/Partials/HoldingsPanel.vue`](/resources/js/Pages/Manage/OwnerListing/Partials/HoldingsPanel.vue) | the holdings table, emails, other numbers — the body of the owner modal |
| [`Pages/Manage/OwnerListing/Partials/HowToRead.vue`](/resources/js/Pages/Manage/OwnerListing/Partials/HowToRead.vue) | the five ways this archive misleads, on the page |
| [`Components/SectionTabs.vue`](/resources/js/Components/SectionTabs.vue) | section `owner-listing` |
| [`Layouts/ManageLayout.vue`](/resources/js/Layouts/ManageLayout.vue) | `ownerListingNav`, the Hub card |
| [`composables/useSuite.js`](/resources/js/composables/useSuite.js) | the `owner` suite + its owned path |

### Migrations

All in [`database/migrations/owner_listing/`](/database/migrations/owner_listing/) — a separate
path, so `php artisan migrate` does not see them. See that directory's
[`README.md`](/database/migrations/owner_listing/README.md).

| File | Table |
|---|---|
| `2026_09_17_190000_create_owner_records.php` | `owner_records` (+ the `name(64), unit(32)` prefix index) |
| `2026_09_17_190100_create_owner_profiles.php` | `owner_profiles` |
| `2026_09_17_190200_create_owner_name_tokens.php` | `owner_name_tokens` |
| `2026_09_17_190300_create_owner_lookups.php` | `owner_lookups` |
| `2026_09_17_190400_add_phone_tail_to_owner_records.php` | `phone_tail` generated column + index |
| `2026_09_18_030000_add_scanned_count_to_owner_lookups.php` | `scanned_count` — the denominator of a list access |

### Seeders / config / routes

- [`database/seeds/RolesSeeder.php`](/database/seeds/RolesSeeder.php) — `view-owner-listing`,
  withheld from the legacy `admin` role.
- [`src/Auth/Permission.php`](/src/Auth/Permission.php) — `VIEW_OWNER_LISTING`, group
  *Owner Listing*.
- [`config/database.php`](/config/database.php) — `owner_listing`, `owner_listing_admin`.
- [`config/features.php`](/config/features.php) — `owner_listing_enabled`
  (`FEATURE_OWNER_LISTING_ENABLED`); off unless the archive has been imported on this host, so a
  deployment without the data never offers a search that can only answer "no match".
- [`routes/web.php`](/routes/web.php) — `manage.owner-listing.*`.

## Not built (and why it is not a gap)

- **Bulk CSV enrichment** — upload a registration list, get an enriched file back
  (the source archive's `matcher.py --csv`). The matcher is already a service, so this is a
  queued job, an uploads table and an export class.
- **A backfill / routing rule.** The panel below answers one lead at a time, on demand. Scoring
  every lead into a stored column (`properties >= 3 and confidence >= 70 → priority A`) is the
  next piece, and it needs a decision first: a stored score is a copy of the archive inside the
  CRM, which is a different privacy question from reading it live.
- **Bulk CSV enrichment** — see above.
