Handles
A handle is an identifier of a subject in some channel or system. It has atype, a value and, when the type needs one, a scope:
Values are classified by their format, never by the name of the field they came from, and normalized on the server, so the same phone number or e-mail address reaches the same handle through any channel:
- Phone: stored in E.164. A Brazilian number without a country code gets
55, also after a carrier code (0 21 11 98765-4321), and an old 8-digit mobile number gets its 9; a landline never does. Eleven digits without the mobile 9 are not a Brazilian number:14155550123is+14155550123. The1WhatsApp still puts in Mexican mobile numbers (521...) and the9of Argentine mobile numbers (549...) are dropped, so awa_idand the number a phone call shows are the same phone. A(0)after the country code (+44 (0) 20 ...) is dropped. - E-mail: lowercased, and nothing else changes: dots and + suffixes stay in the value (for Gmail they become a suggestion).
mailto:and the name inAna Silva <ana@acme.com>are dropped. - National ID: a person’s national ID and a company’s registry number go through the check digits of the country’s scheme. A Brazilian one that lost its leading zeros in a spreadsheet (
1234567890for012.345.678-90) gets them back, and the check digits still decide. Send the number itself, never a hash of it: the space keeps only a keyed HMAC with its own secret, for matching, and an encrypted copy, for export, and shows the number masked. A hash fails the check digits, and the item comes backinvalid_input. - Invisible marks (WhatsApp wraps a copied number in direction marks), full-width digits and a
tel:prefix are dropped from phones, e-mails and national IDs. Opaque ids (system_id,app_user_id, BSUID) stay as they came. - The id of a WhatsApp group, broadcast list or channel (
@g.us,@broadcast,@newsletter) is nobody’s and is refused withinvalid_input: send the participant, never the group.
subject_kind: person (the default), account or partner. org_registry_hmac and email_domain are organization handles from the start. See Accounts and partners.
Profiles and assertions
A profile is the customer as the memory sees them: the set of handles linked to one another. Each link is an assertion in an immutable log, with the method that produced it:
When two handles in one event resolve to different profiles, the merge happens outside the batch answer, under a lease. Memory stays attached to the handle each fact came from, so splitting two profiles merged by mistake is a real operation: facts, orders and invoices go back to the right owner on their own. When two profiles merge and each holds a value for the same profile field, the survivorship rule the space’s extraction schema gives that field decides:
latest (the newest, the default), most_frequent (what most sources state) or source_priority (the order of sources you list, by name or id). The merged profile keeps one row per handle and source, so a split gives each side back what was its own.
identify() is sent right away, outside the SDK queue: new handles join the profile in the same response, and when the handles already belonged to two profiles, the merge runs before the response, under both profiles’ lease and with the same safeguards as every merge. If a worker holds one of the profiles for more than 2 seconds, the merge is left to the identity task, which runs right after.
Safeguards against wrong merges
- Two profiles that already hold a handle at V3 or above are never merged automatically; only by a forced merge, with actor and reason.
- Test values and QA placeholders are blocked per type, from a fixed list you can extend.
- Each profile holds up to three phones per type, and the weakest is demoted when a fourth arrives; a national ID (one per country) and a
system_id(one per namespace) never repeat, and the second value stays in a profile of its own. Ingestion is never rejected. - A handle joined to three or more different ids of the same type within 24 hours, or to five within 30 days (a store tablet, a PBX number, a support WhatsApp), becomes
ambiguous: it merges nothing more, serves no reads, and the same step undoes what it joined: the handle leaves its profile and the people it bridged before it looked shared split apart, merge by merge. A person with two or three e-mails over a month keeps the number. - A number unseen for more than 180 days that comes back in an event may have changed hands, and so may a number the company’s systems tie to another customer’s record id while its profile holds a different one. The old handle stays suspended in the old profile (
suspected_recycled), with that person’s memory, and the number comes back as a new handle: the events of whoever holds the number now build a provisional profile of their own, and a read by that number carries only what that person said since, never what the previous holder told. Only a record id of the previous profile (system_id,app_user_idorgov_id_hmac), from the company’s systems, a login or an ordinary event of a system of record, with theexplicit_identify,loginorsystem_importmethod, returns the number to the old profile. A code sent to the number itself, or a typed e-mail, never brings it back: whoever holds the phone today would pass it. - A split holds. Two profiles someone split (with
unmerge, or by retracting the assertion that joined them) never merge again on their own: when the same record or the same event comes again with both handles, the merge becomes a suggestion withfeatures.after_split, and only a person who accepts it, asserts the pair or forces the merge joins them again. - A phone number or BSUID rotation creates an alias, never an automatic merge:
identify()takes thechannel_rotationmethod to say so, and an assertion with that method never merges profiles. - A person and an organization are never merged. A link is what connects them.
- A handle that a claim (
declared, orexplicit_identifywithout a record id) joins to a profile is provisional (provisionalon the handle): it resolves to the profile, for continuity, but a read through it stays at V1 at most, withreason: unconfirmed_link, whatever the conversation proved on that handle, until the link is confirmed by a system event or aloginthat ties it to a confirmed record id of the profile, or by an operator who asserts the pair. An OTP never confirms: it proves possession of the number, not that the number is that customer’s. What later joins through a provisional handle is provisional too; a split that leaves only provisional handles in a profile lifts the cap. A claim never raises the level of the handles it names, provisional ones are the first demoted past a profile’s limit, and a claim that names nothing known binds nothing. Profiles joined by a system event (the CRM record naming phone, e-mail and ids) still join at once. - Memory that arrived through an unconfirmed link stays with that link. Every memory item is anchored on the handle it came through, and while the handle is provisional its items and the rest of the profile stay apart: a reader through the provisional handle sees its items and the confirmed ones (at V1); a reader through a confirmed handle, a read by profile id or by object, internal agents and source keys see only the items of the confirmed handles; in the Console, only the
adminandsecurityroles see everything, each item markedunconfirmed_link. This holds for the compiled pack and its reserve, the live turns, the slots and the settled values, search and timeline, MCP, the policy simulation and the export package; a pattern held up only by hidden items stays out, and the tail summaries are rebuilt from the visible conversations. A conversation on unconfirmed links alone reconciles, supersedes and closes only against its own items, and its extraction never sees the confirmed facts; a conversation on confirmed handles never touches the items of an unconfirmed link. A confirmation makes the handle an ordinary member and everything is shared again; a split takes the items away with the handle. A conversation’s pinned bytes change once, at a join, a confirmation or a split; a profile with no provisional handle reads as it always did. What the profile derives follows the same rule: patterns, warnings and their webhooks, Insights (the pseudonym lists the unconfirmed handle apart, inhidden_handle_ids), the stored summaries and the agents’ distilled notes count the confirmed handles only; a confirmation, a merge or a split recomputes all of it right away, and a split takes the items along.
ambiguous, blocked or suspected_recycled), leads to no profile: the read is a first contact, with an empty context and a receipt, never an error, and nothing of the previous owner shows. When in doubt, the memory reveals nothing; the agent reads again by a handle that is not in doubt.
What a warm read sees
Acontext() read with a warm context runs no database statement: the read process keeps, between reads, who a handle or a profile id resolves to and what a conversation proved. Each space database keeps a change log, written by triggers in the writer’s own transaction on handles, profiles, links and sessions, and the read process polls it every 2 seconds, together with the privacy feed. A merge, a split, a handle that became ambiguous or left the profile, an erasure and the end of a conversation reach warm reads at the next poll, within about 2 seconds; a read right after the write may still see the earlier state in that interval. A verify counts at once: a held proof serves only the reads that ask for no more than it proved, and a read that asks for more goes to the database. A handle the read does not know, or one in doubt, is never kept: it goes to the database every time. A space whose log was not polled in the last 5 seconds serves nothing from the process’s memory, and a pause of more than 30 seconds between two polls drops what it held.
Governing identity
Your team corrects identity through the governance routes, with a Console person holding thesecurity or integration role, or a source key with the admin scope. Every write requires an Idempotency-Key header and is applied under the profile lease, in order. A write that is queued answers 202 with the operation id; one that needs no queue answers 200: an assertion between handles of the same profile, an assertion between a person and an organization, which becomes a link, and a block on a value not seen yet. Creating a link answers 201.
Niadra also proposes links it cannot prove, such as two Gmail addresses that differ only by a dot or by a + suffix, two profiles an
identify without a record id (declared or explicit_identify) tried to merge (features.agree carries the method), or two profiles a split keeps apart (features.after_split). The score belongs to the pair of profiles and adds up what their handles agree on: the same e-mail local part on different domains is not enough alone, nor are the same last 8 digits of a phone number; the two together are. A dismissed pair of profiles does not come back for review, whatever pair of handles agrees later. It never applies them on its own: GET /v1/identity/suggestions lists them with a score and the features behind it, accept turns one into an accepted_suggestion assertion, and dismiss closes it.
Every handle of a profile says how much it weighs, in weight: hint (an e-mail domain, an anonymous id), declared, verified (it proved V2 or V3), authenticated (V4) or system (the id of a system of record or an organization’s registry), derived from the type and the highest level the handle ever proved. To find a profile from a handle, use POST /v1/profiles/search: the value goes in the body and is never logged. GET /v1/profiles/{profile_id} returns its handles, links, subject kind and pseudonym, masked for the role of whoever reads. To browse without knowing a handle, GET /v1/profiles lists the profiles of the space by latest activity, a page at a time, with the pseudonym, the kind and up to three handles that are always masked, whatever the role; an erased profile is not listed, and each page leaves a receipt.
Verification levels
The same customer gets different context depending on how much the conversation proved who they are:
Your policy sets the minimum level per category of memory. In a category with the mirror on, what the customer said themselves comes back to them from V1, whatever the minimum; what the company holds about them keeps the category’s level. The context reports in
withheld how many items were held back: the agent knows that verifying releases more, without seeing the content, and at V0 the context itself tells it to ask for identity verification before answering about them. no_customer sits outside the scale and is only accepted from sources with the internal_agent audience; that context never reaches the end customer.
Effective level
The level that applies is the lowest of three: what you requested, the source ceiling and what this conversation proved. The ceiling is an attribute of the source, set when it is created: V2 by default, at most V3 for a source that serves customers (the OTP it can run), V4 only for sources with thehuman or internal_agent audience, and no_customer only for internal_agent. A source with a ceiling above that is refused with 422 verification_not_allowed. A leaked agent key cannot declare V4.
The proof belongs to the conversation, not to the handle. What counts is the highest level, still within its validity, that the conversation or task proved. The number’s past never counts: the next call from the same number, which may be spoofed or come from whoever inherited the line, starts over at what it proves itself. Without conversation_id or task_id, nothing was proved, and the read stays at V0.
The context response shows both sides in verification: requested, effective and, when effective is lower, reason (source_ceiling, not_proven or unconfirmed_link, when the read went through a provisional handle).
Raising the level
A conversation’s level goes up in two ways, never by inference. The first is the channel itself: a customer turn that arrives with averification_hint proves up to V2 for that conversation, never above the source ceiling, and the network attestation of a call (STIR/SHAKEN and equivalents) proves V2 at level A and V1 at levels B and C. A hint on an agent turn or on an outbound message proves nothing. The second is the verify event, for a challenge, a login or a check: it needs a conversation_id or task_id, holds only there and may expire (valid_until). Once it expires, the proof is gone, and the level goes back to what a new customer turn proves through the channel. A proof belongs to the conversation, not to one open session: a session nobody ended closes after 3 quiet minutes so its turns become memory, and its proof holds while a new turn would still reopen that session (the channel’s inactivity since its last turn, about 20 minutes on WhatsApp), or until its valid_until if that comes first. A call proved at its start and read for minutes without a turn keeps its level, its episodes and the items it opens. A conversation ended with conversation.ended or handed off proves nothing after its end, and neither does one quiet for longer than the channel’s inactivity. A lower verify never erases a higher proof that is still valid. After it, request a new context(): the SDK has already dropped the cached context of that conversation.
otp_whatsapp, otp_sms, login, kba, network_attestation and human_agent. A verify above the source ceiling comes back in the 207 with the code verification_not_allowed.
Beneficiaries
A person buys for a dependent, asks about a plan for their parents, picks a gift. In the customer’s signals, every interaction says who it is for (for): self, the customer themselves; beneficiary:<id>, a person under their profile, such as a dependent, with an id your company gives and Niadra never resolves to a profile of its own; or gift, a present. Signals are kept apart per beneficiary: the size of whoever will wear it decides the search, what the customer buys for a dependent never becomes their own taste, and a gift goes to a disposable bucket that never becomes anybody’s attribute. The beneficiary is the customer’s data, not a profile: erasing the customer takes the signals of every beneficiary of theirs, and the subject’s package carries them.
A beneficiary is never guessed: a delivery to another address, by itself, creates none, and the constraints block asks for_whom when the conversation leaves the doubt. This differs from a link between a person and an organization, which joins two profiles with a role.
Next steps
Context and views
what each level releases in the context.
Accounts and partners
organizations and role-based links.
List assertions
the API of the identity log.
Identity suggestions
links to review, never applied on their own.

