# MDM — entity resolution + identity verification

Master Data Management is the platform's entity-resolution layer.
It binds every inbound row to a canonical **subject** record (the
platform's one identity dataset, the legal entity) so
that downstream datasets, workflows, and agent prompts reference
a single, deduplicated identity rather than the raw inbound
identifiers each upstream system carries.

SDK namespace: `api.mdm`. Today the wire exposes a single verb:

```
   api.mdm.verify(tenantSlug, datalakeSlug, body)
```

Other MDM operations (subject resolution during ingestion,
record merging, conflict reconciliation) run inside the platform
on consumer-supplied inputs but are not directly SDK-callable —
see §5 Lifecycle.

MDM is **Datalake-DB-resident** — the parent datalake must be
`status: 'ready'` before any MDM verb is called, since
resolution / verification both look up against the datalake's
master records.

## 1. Wire shape — `.verify`

Verify that a person on the other end of a workflow action or a
form behind an authenticated link matches an existing subject in the
datalake. Used at action time (workflows confirming an SMS
recipient) and at runtime (the host behind `link_prefix` confirming
the form submitter before posting tracking updates).

Every MDM subject is ultimately a **person** or a **company**,
and the request type is that ontology's identity vocabulary. You
send `legal_entity_id` plus the identity fields you hold.

There is ONE field set. GH-859 removed data domains, and with
them the per-domain verifiers that each knew only their own
vocabulary — `given_name`, `family_name`, `birth_date` and
`gender` are gone, not renamed variants to pick between.

```
   MDMVerifyRequest       every field the request takes
   ────────────────       ─────────────────────────────
   required               legal_entity_id
   individual             first_name middle_name last_name
                          preferred_name date_of_birth
                          citizenship_country nationality
   business               business_name doing_business_as_names
                          date_formed jurisdiction_country
   either                 legal_entity_type phone email
                          identifiers addresses
```

Not every field is evidence on its own (§2):

```
   can stand alone        first_name last_name preferred_name
                          date_of_birth business_name date_formed
                          phone identifiers addresses
   only beside one        legal_entity_type middle_name
   of those               citizenship_country nationality
                          doing_business_as_names
                          jurisdiction_country email
```

An individual example:

```typescript
import type {
  MdmVerifyRequest,
  MdmVerifyResponse,
} from '@alvera-ai/platform-sdk'

const { data: result } = await api.mdm.verify(
  tenantSlug,
  datalakeSlug,
  {
    legal_entity_id: legalEntityUuid,  // required
    first_name: 'Priya',               // fuzzy
    last_name:  'Anand',               // fuzzy
  },
)
```

And one keyed on an external identifier:

```typescript
const { data: result } = await api.mdm.verify(
  tenantSlug,
  datalakeSlug,
  {
    legal_entity_id: legalEntityUuid,
    last_name:     'Garcia',                          // fuzzy
    first_name:    'Maria',                           // fuzzy
    date_of_birth: '1978-11-03',                      // component-fuzzy
    identifiers: [                                    // exact
      { system: 'http://hospital.org/mrn', value: 'MRN12345' },
    ],
  },
)
// result.status          === 'verified'   — the only success body
// result.legal_entity_id === body.legal_entity_id
// result.verified_at     — ISO 8601 timestamp
```

`verified` is the **only** success body. A subject that does not
verify is not a body you read — the call throws, like every other
route that answers 4xx:

```
verified           → 200 { status: 'verified', legal_entity_id, verified_at }
did not verify     → throws AlveraApiError, _httpStatus 422
                       { errors: [ { title: 'Invalid value',
                                     source: { pointer: '/<field>' },
                                     detail: '<why it failed>' } ] }
                       one entry per field that did not match
subject not found  → throws AlveraApiError, _httpStatus 404
```

So `data` is only ever the verified answer, and every failed check
reaches you in `catch`, in the same envelope every other 422 uses
(see `errors.md`). Read which fields failed from each entry's
`source.pointer`:

```typescript
import type { AlveraApiError } from '@alvera-ai/platform-sdk'

try {
  const { data } = await api.mdm.verify(tenantSlug, datalakeSlug, {
    legal_entity_id: '00000000-0000-0000-0000-000000000000',
    last_name: 'Garcia',
    addresses: [{ postal_code: '94110', country: 'US' }],
  })
  // data.status === 'verified' — act on this person
} catch (err) {
  const status = (err as { _httpStatus?: number })._httpStatus
  if (status === 422) {
    for (const e of (err as AlveraApiError).errors) {
      // e.source.pointer — '/last_name', '/addresses', '/base', …
      // e.detail         — 'does not match any known name', …
    }
  } else if (status === 404) {
    // no legal entity has this id
  } else {
    throw err
  }
}
```

A 401 (missing or wrong key) throws too.

### One subject: the legal entity

Every datalake resolves against the same canonical subject — the
**legal entity**. `legal_entity` and `beneficial_owner` are the
only identity datasets the platform ships; everything else a
build needs is a generic table it authors itself.

That is the whole of it now. There is no per-domain subject to
look up, no `patient` / `party` / `customer` / `consumer` /
`trading_account` to map onto, and no table of which domain
resolves to which entity. GH-859 removed the domains and the
subjects with them.

Generic-table rows carry `legal_entity_id`, populated by MDM
resolution when the row's contract supplies an `mdm_input_config`
— not a per-domain foreign key like `patient_id`. Querying that
column scopes any custom dataset to resolved entities. You do not
declare the column; every generic-table row struct already has it.

## 2. Rules the type cannot encode

### `legal_entity_id` is required; at least one field that stands alone with it

`legal_entity_id` (the master record to verify against) is the
only required field. Every other property is optional, and the
verifier requires **at least one field that can stand alone as
evidence** at runtime: `first_name`, `last_name`, `preferred_name`,
`date_of_birth`, `business_name`, `date_formed`, `phone`,
`identifiers` or `addresses`. The other seven (`legal_entity_type`,
`middle_name`, `citizenship_country`, `nationality`,
`doing_business_as_names`, `jurisdiction_country`, `email`) are
accepted, but count only beside one of those nine.

- a body with only `legal_entity_id`, or with only fields from the
  seven → 422, one entry with `source.pointer: '/base'` and
  `detail: 'at least one verification field is required'`
- an address alone is enough evidence; if it does not match, the
  422 carries the `/addresses` entry and nothing else

A body carrying the retired vocabulary — `given_name`,
`family_name`, `birth_date`, `gender` — falls into that same 422,
and this is the trap worth knowing: unknown keys are **ignored,
not rejected**. Nothing tells you the field was dropped. Send
only those and you get the at-least-one error for a request that
looks full; send one of them alongside a known field and it
verifies on the known field alone, silently ignoring the rest.

### Verification is per-field

Each supplied identity field is checked independently against
the legal entity's master record.
String names are fuzzy-matched, dates component-fuzzy-matched,
and structured identifiers exact-matched:

```
name fields      Jaro-Winkler fuzzy match (thresholds ~0.80-0.85)
date fields      component-wise fuzzy match ≥ 0.80
identifiers      exact (system, value) match
addresses        per-component exact, case- and whitespace-
                 insensitive; at least one address must match
```

All supplied fields must pass for the call to return
`'verified'`. Each field that fails adds one entry to the thrown
422, pointing at the field you sent:

```
source.pointer      detail
/last_name          does not match any known name
/first_name         does not match any known name   (see below)
/business_name      does not match any known name
/date_of_birth      does not match
/date_formed        does not match
/identifiers        no matching identifier found
/phone              does not match any known phone number
/addresses          no matching address found
/addresses/country  can't be blank                  (an address sent without a country)
/base               at least one verification field is required
```

First, middle and preferred names are checked together as one
bag of given names, so they fail as one entry. It points at a
given-name field you actually sent: `/first_name` if you sent one,
else `/middle_name`, else `/preferred_name`.

Two fields failing is two entries; nothing is summarised into a
single verdict. The mock answers its wrong-details case with one
`/base` entry, `detail: 'Verification match failed'`; the platform
itself names the field.

### `legal_entity_id` is the legal entity's own id

`legal_entity_id` is `legal_entities.id` — the same value every
generic-table row carries as its own `legal_entity_id`.

There is one lake row per entity now, so there is no public
handle and regulated twin to choose between: GH-859 collapsed the
two-tier lake, and masking is infrastructure — the tokenized and
redacted lakes — rather than a second row with its own id. The
old failure mode where passing the regulated twin's id returned a
404 cannot arise.

If the supplied `legal_entity_id` doesn't resolve, the call throws
a 404 envelope (`title: 'Not Found'`, `detail: 'Subject not found'`,
empty `source.pointer`), not a 422. The distinction matters:

- 422 -> "entity exists, identity fields didn't match"
- 404 -> "entity doesn't exist at all"

A consumer that conflates the two will mis-route an identity
challenge as a missing-record error.

### One label per kind of identifier a subject holds

An identification attaches to a legal entity under a label — its `uri` —
alongside an `id_type`. The pair `(uri, id_type)` is a **slot** on that
subject, and a subject holds each slot once. Two identifications under the
same pair are refused, not merged and not resolved to a winner.

So a subject that carries more than one identifier needs a label per kind.
Jane, from a Shopify store, with an email and a phone:

```json
"identifications": [
  { "id_type": "digital_identifier",
    "uri": "shopify",
    "id_number": "jane@example.com" },
  { "id_type": "digital_identifier",
    "uri": "shopify::phone",
    "id_number": "+15551234" }
]
```

Written with `"uri": "shopify"` on both, the second is refused — the database
enforces it as `legal_entity_identifications_entity_uri_type_uk`.

The convention: the bare source identifies the **subject**; anything else the
subject carries gets its own label beneath it.

**Do not re-label identifications that already exist.** Changing the `uri` on a
recipe that has already run sends the next ingest to a DIFFERENT slot, which
adds a second identification rather than updating the first — the original is
orphaned, silently, and both then answer to the same person.

### What a resolve does to the slots it does not name

A resolve speaks only about the slots it names:

| the slot you send | what happens |
|---|---|
| named, already filled | **overwritten** |
| named, empty | **added** |
| **not mentioned** | **kept, untouched** |

The third row is the one worth designing around, and the one nothing else
states. Several lanes can each resolve the same subject knowing only their own
identifiers, without destroying each other's work: a marketing lane sending
only an email and a billing lane sending only a phone both survive, in either
order.

Two limits that follow from the same rule:

- **A slot cannot be emptied through a resolve.** Omitting it means "leave it";
  there is no value that means "remove it".
- **Two values under one `(uri, id_type)` in a single payload are refused**
  rather than resolved to a winner — the platform does not pick.

## 3. Field ownership

**Server-derived (Response-only).**

```
status         enum   — 'verified', its only value
verified_at    string — ISO 8601 timestamp of the verification
```

There is no `errors` field on the response: a failed check is a
thrown 422 (§4), never a body.

**Caller-supplied (round-trip in the verified answer).**

```
legal_entity_id  required UUID — the master record to verify against
<identity>       optional     — any field from the §1 set: person fields
                                (first/middle/last/preferred names,
                                date_of_birth, citizenship_country,
                                nationality), business fields
                                (business_name, doing_business_as_names,
                                date_formed, jurisdiction_country), and
                                fields either kind carries
                                (legal_entity_type, phone, email,
                                identifiers, addresses)
```

**Write-only (Request-only).** None.

## 4. Error envelopes

Every error `.verify` returns is the standard JSON:API envelope
per `errors.md`, thrown as `AlveraApiError`. There is no second
shape. Common rejections:

| HTTP | `source.pointer`          | Cause |
|------|---------------------------|-------|
| 422  | `/<field>`                | a field you sent did not match the master record — one entry per field (§2 lists the pointers and details) |
| 422  | `/base`                   | no field that can stand alone as evidence was supplied (§2) — including a body of only `email`, `middle_name` or the other five that count only beside one, or only retired fields (`given_name`, `family_name`, `birth_date`, `gender`), which are ignored |
| 422  | `/legal_entity_id`        | `legal_entity_id` missing from the body |
| 422  | `/identifiers/0/system`   | missing system on an identifier entry |
| 422  | `/identifiers/0/value`    | missing value on an identifier entry |
| 404  | `""` (empty)              | no legal entity has this id — `title: 'Not Found'`, `detail: 'Subject not found'` |

A subject that does not verify and a body the platform refuses
arrive the same way: a 422 whose entries all carry
`title: 'Invalid value'`. Read the field from `source.pointer`.
There is no `errors` map keyed by field name, and a failure body
carries no `status`, `legal_entity_id` or `verified_at`.

`system` and `value` on an identifier are required by the request
schema, so a missing one is refused before verification runs, and
its pointer carries the entry's index. An embed the verifier
itself rejects points at the embed's field without an index: an
address sent without a country is `/addresses/country`, not
`/addresses/0/country`. `id_type` and `country` on an identifier
are optional. Either way it is a 422, never a 500.

Branch on `_httpStatus`: 422 for a failed check or a refused body,
404 for a subject that does not exist (§6 gotcha 2).

## 5. Lifecycle

### `.verify` is synchronous

The verify call returns the resolution outcome in the response —
no async polling, no job id. The platform performs the fuzzy
match against the master record inline.

### Other MDM operations are platform-internal

Beyond `.verify`, MDM also runs at two points without a direct
SDK surface:

- **During data activation ingestion.** Each inbound row that
  carries an `mdm_input_config` on its bound interoperability
  contract gets routed through the platform's resolution
  pipeline before the dataset upsert. The pipeline matches the
  rendered MDM input against existing subjects (or creates one
  if no match) and produces an `mdm_output` carried forward to
  the legal-entity upsert. Consumers configure this implicitly via
  the contract's `mdm_input_config` — see
  `interoperability_contracts.md` §2 and `data_activation_clients.md`
  §6.

- **Conflict reconciliation.** When two master records turn out
  to represent the same subject (e.g. an MRN-only record and a
  demographics-only record that operators later confirm are the
  same person), the platform supports merge / unmerge operations.
  These are administrative actions surfaced in the admin console,
  not SDK-exposed verbs.

### Read shapes

```
.verify(tenantSlug, datalakeSlug, body)    Single round-trip; see §1
```

Today there is no `.list`, `.get`, or `.metadata` on the MDM
namespace — subjects themselves are queried through the dataset
search surface (see `data_activation_clients.md` §6.6), not
through `api.mdm`.

## 6. Gotchas

1. **A subject that does not verify throws; there is no
   `not_verified` body.** `data` is only ever the verified answer.
   Every failed check is a thrown `AlveraApiError` with
   `_httpStatus === 422`, one entry per mismatched field. Code
   written for the old `{ status: 'not_verified', errors: {…} }`
   body never runs: its branch is unreachable and the 422 lands in
   `catch`. Don't let generic 4xx handling swallow it — read each
   entry's `source.pointer` to learn which fields failed.

2. **Subject missing → 404, identity mismatch → 422.** Both
   throw, in the same envelope. A verify call against a deleted
   or never-existed entity id throws a 404 (`title: 'Not Found'`,
   empty pointer), not a 422. Branch on `_httpStatus` before
   reading pointers, per `errors.md`.

3. **At least one field that can stand alone is required.** A body
   with only `legal_entity_id` — or only fields that count just
   beside another (`email`, `middle_name`, `nationality`, …), or
   only retired fields like `given_name` or `birth_date`, which are
   ignored — throws a 422 with one
   entry: `source.pointer: '/base'`, `detail: 'at least one
   verification field is required'`. This is enforced at runtime
   rather than in the request schema, so the TypeScript type
   permits the body but the wire rejects it.

4. **Unknown fields are ignored, not rejected.** There is one
   field set (§1) and no domain scoping, but anything outside it
   — including the retired `given_name` / `family_name` /
   `birth_date` / `gender` — is silently dropped rather than
   erroring. Sending only those lands you in gotcha 3's
   at-least-one 422; sending one alongside a known field verifies
   on the known field alone and tells you nothing.

5. **MDM also runs at ingestion — and you don't call it
   directly.** The `mdm_input_config` on each interoperability
   contract feeds the platform's resolution pipeline at
   ingestion time, producing the `mdm_output` carried into the
   dataset upsert. There is no `api.mdm.resolve` SDK verb; you
   configure resolution by shaping the contract's
   `mdm_input_config`. See `interoperability_contracts.md` §2.

6. **`identifiers` matches are exact, not fuzzy.** Whitespace,
   case, and trailing slashes in `system` URIs all matter.
   Compare with the master record's identifier system strings
   character-for-character; a `'http://hospital.org/mrn'` vs
   `'http://hospital.org/mrn/'` mismatch fails the check with an
   `/identifiers` entry (`no matching identifier found`) even when
   the value is correct.
   `system` and `value` are the required pair. `id_type`
   (`'digital_identifier'`, `'us_ssn'`, `'lei'`) and `country`
   are optional — the per-domain rule that once made `id_type`
   mandatory went with the domains.

7. **The field names ARE the wire contract — there is no
   translation.** The platform stores and matches `first_name` /
   `last_name` / `date_of_birth`, matching `LegalEntity`, so a
   verify body must carry exactly those. The retired
   `given_name` / `family_name` / `birth_date` / `gender` are
   ignored and fall into gotcha 3's 422. §1's set is the contract,
   verbatim.

8. **There is no second id to confuse it with.** This gotcha used
   to warn that passing the REGULATED twin's id 404s while the
   person plainly exists. GH-859 collapsed the two-tier lake:
   there is one `legal_entities` row per entity, and masking is
   infrastructure — the tokenized and redacted lakes — not a
   second row with its own id. Kept as an entry because the old
   advice is still in circulation.
