Person vs Profile
Audience: Engineering, QA, support (data-model literacy); management (compliance) · Where in app: Admin → Identity → Persons / PersonProfiles · Plan availability: All plans
The Identity Graph splits a visitor's data into two records: a Person (the global human, one row per real person) and a PersonProfile (one row per Person × Company). The Person lets multiple tenants recognize the same human; the PersonProfile keeps each tenant's knowledge of that human strictly sandboxed. A UserSession is a single visit that resolves to a Person and belongs to a Company.
Person = the human. PersonProfile = a tenant's memory of the human. UserSession = one visit.
What it does
- Person joins sessions across devices and tenants via hashed identifiers — without sharing PII between tenants.
- PersonProfile holds everything a single company knows about that person (CRM links, enrichment, captured forms, bookings, extracted facts, verification signals, audit trail).
- UserSession records a visit and carries its trust state (
claimedvsverified).
How it works
Person — the global human (people collection)
One document per actual human, tenant-agnostic, holding only basic identity.
| Field | Meaning |
| -------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| _id | Mongo ObjectId |
| name / email / phone | Best-known identity (email lowercased, phone normalized to E.164 where a country code is known) |
| match_keys[] | { type: 'email' | 'phone', normalized, hash }—sha256 of the normalized value, indexed for O(1) lookup |
| first_seen_at | First identified anywhere |
| last_active_at | Latest activity across any tenant |
match_keys.hash is the join key: tenants identify the same human by matching hashes, never by exchanging the raw email/phone. (CRM contact ids are matched per-tenant on the PersonProfile, not stored as Person match-keys — verify if you see a crm match_key type referenced elsewhere; the model enum is email | phone.)
PersonProfile — the per-tenant view (person_profiles collection)
One document per (person_id, company_id) (unique index). Everything that company knows lives here.
| Field | What |
|---|---|
person_id / company_id | The pair this profile belongs to |
company_name | Self-reported employer for this tenant (may differ across tenants) |
hubspot_contact_id / ghl_contact_id | This tenant's CRM contact ids (join keys for bidirectional sync) |
crm_enrichment | Cached CRM snapshot { hubspot, ghl, refreshed_at } |
form_submissions[] | Captured forms (form_type, source, fields, submission_hash) |
extracted_facts[] | AI-distilled facts (type, text, importance 1–5, source, text_hash) |
bookings[] | Meetings (google/ghl/calendly/manual, time, contact, booking_hash) |
verified_signals | Matchable signals from verified sessions: user_session_ids, fp_hashes, soft_signatures, ips, ip_subnets (LRU-capped) |
identity_events[] | Audit trail of identity events (capped to last 50) |
reconcile_conflict_open | True while a first-party identity conflicts with a real GHL contact awaiting admin review |
first_seen_at / last_active_at | Per-tenant timestamps |
Why the split?
- Tenant isolation — Company A's view of John never leaks to Company B. The Person is global (so he's recognized as the same human), but per-tenant context is sandboxed. When building the graph for Company A, the system only ever reads the PersonProfile where
company_id = A. - Compliance — each tenant owns its data on its PersonProfile; a deletion request scopes to a single PersonProfile, not the global Person.
- Performance — hash lookups are O(1); per-tenant slices keep query scopes small and indexed.
How a Person is created (resolution)
- Visitor submits a form / shares an email, e.g.
john@example.com. - Backend normalizes and computes
match_key.hash = sha256("john@example.com"). PersonResolverServicequeriespeople.match_keys.hashfor the hash.- If found → reuse the Person; if not → mint one (but only when there's a re-findable identity — see edge cases).
- Upsert the PersonProfile for
(person_id, current_company_id).
Phone works the same way (sha256 of E.164-normalized digits). CRM landings (HubSpot hubspotutk / GHL contact_id) resolve by matching the per-company CRM id on existing profiles.
UserSession's role
UserSessionModel carries:
person_id— the resolved Personclaimed_person_id/verified_person_id— "they typed an email" vs "the system trusts it"identity_trust—anonymous|claimed|verifiedip_shadow_person_id/device_shadow_person_id— suspected-identity shadow markers (cross-device IP / device-signature guesses); not a trust tier, rendered as the amber "Suspected" badgewidget_user_session_id(kk_user_session_id) — the cross-visit string from localStorage; on session create it passively re-links the session to its Person atclaimedtrust (device-signature matches to a unique fresh verified profile also link atclaimed)
Configuration & options
This is the data model, not a settings surface — but two operational levers touch it:
- Admin → Identity → Persons / PersonProfiles — super-admins browse and inspect records, and (Person Detail) merge duplicates.
- Identity maintenance sweeps (Admin → Identity → Maintenance) — cleanup, normalization, name-moderation, and IP-signal backfills, each runnable dry-run first.
Behaviors & edge cases
- No orphan minting. A name-only signal with no re-findable identifier (no email/phone hash, no CRM id) does not create a Person — the name is written onto the session/chat, and a later email/phone is what actually creates the Person.
- First-party corrects, enrichment fills. High-trust sources (
form,crm,inbound_call,booking,chat_form—PersonResolverService._isHighTrustSource) may overwrite canonical email/phone/name (_mayOverwriteCanon); everything else (chat/call extraction, enrichment) only fills an empty field. Enrichment can never overwrite a self-provided name. - Name moderation.
app/lib/NameModeration.jsscreen(name)(obscenity package + denylists) rejects junk/profane names before they persist; the UI falls back to "Unknown Visitor" (getSessionName). - Conflict rejection. A verified session won't be flipped by an identifier matching a different Person — the claim is rejected and audited; a freshly-minted ghost Person is rolled back.
- Fan-out. When canonical contact changes, the new values fan out to the person's other sessions and their chats so every surface reflects the latest.
- Deduplication & merges. Forms (
submission_hash), bookings (booking_hash), and facts (text_hash) dedup themselves. Duplicate Persons are merged by an admin in Person Detail, recorded inidentity_events[]. - Deletion cascade respects isolation. Deleting a tenant's PersonProfile removes only that company's view; the global Person stays. A Person is deleted only when it has zero remaining PersonProfiles (cleanup job). A tenant can never delete a Person globally.
person_ip_signalscascade on person delete and also TTL-expire after 90 days.
Plan & limits
All plans. The data model itself isn't metered. Retention: per-person IP signals expire after 90 days (PII retention); audit identity_events[] capped at 50; verified_signals arrays LRU-capped (~20 each).
Technical implementation
- Models:
PersonModel(people),PersonProfileModel(person_profiles),PersonIpSignalModel(person_ip_signals, reverse IP→person index with 90-day TTL),UserSessionModel. - Service:
PersonResolverService.resolve()is the single resolution entry point (match keys, CRM-id match, anchor-on-session, orphan linking, conflict guard, fan-out).IdentityNormalize/NameModerationvalidate and canonicalize. - Indexes:
match_keys.hashon Person; unique(person_id, company_id)plus CRM-id and device-signature indexes on PersonProfile.
See Identity Graph (subsystem) for the resolution-chain wiring and IP-assisted resolution.
What Nox can tell you
- The full Person + PersonProfile for any visitor
- Whether a Person is known to other tenants (yes/no only — never the other tenant's data)
- Form/booking/extracted-fact history and the audit trail
Related
- Identity Graph Overview
- Verification Policy — how
claimedbecomesverified - Cross-Session Context — what's read off the PersonProfile for the AI
- Form Intercept
- Admin Controls
- Identity Graph (engineering subsystem)
- CRM Integration