Skip to main content

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 (claimed vs verified).

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.

FieldWhat
person_id / company_idThe pair this profile belongs to
company_nameSelf-reported employer for this tenant (may differ across tenants)
hubspot_contact_id / ghl_contact_idThis tenant's CRM contact ids (join keys for bidirectional sync)
crm_enrichmentCached 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_signalsMatchable 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_openTrue while a first-party identity conflicts with a real GHL contact awaiting admin review
first_seen_at / last_active_atPer-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)

  1. Visitor submits a form / shares an email, e.g. john@example.com.
  2. Backend normalizes and computes match_key.hash = sha256("john@example.com").
  3. PersonResolverService queries people.match_keys.hash for the hash.
  4. If found → reuse the Person; if not → mint one (but only when there's a re-findable identity — see edge cases).
  5. 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 Person
  • claimed_person_id / verified_person_id — "they typed an email" vs "the system trusts it"
  • identity_trustanonymous | claimed | verified
  • ip_shadow_person_id / device_shadow_person_idsuspected-identity shadow markers (cross-device IP / device-signature guesses); not a trust tier, rendered as the amber "Suspected" badge
  • widget_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 at claimed trust (device-signature matches to a unique fresh verified profile also link at claimed)

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_formPersonResolverService._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.js screen(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 in identity_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_signals cascade 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 / NameModeration validate and canonicalize.
  • Indexes: match_keys.hash on 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