Identity Graph
The identity graph turns anonymous visitor sessions into resolved, enriched
people — the backbone of leads, cross-session context, and CRM sync. It lives
primarily in backend, with ms-ai consuming it (via a gRPC
client) for AI context.
The model
| Entity | Meaning |
|---|---|
UserSessionModel | one raw visitor session (device, fingerprint, IPs, URLs) |
PersonModel | a resolved distinct visitor (may span many sessions/devices) |
PersonProfileModel | enriched identity for a Person (name, email, company, B2B data) |
PersonIpSignalModel | IP→person signals used for IP-assisted resolution |
Person is the identity; PersonProfile is what we know about them. A session
resolves to a Person; multiple sessions/devices can collapse into one Person as
evidence accumulates.
The resolution chain
Wired in backend/app/hooks.js boot() as an ordered subsystem:
IdentityGraphRenderer → IdentityGraphConfig → SubscriptionGate
→ IdentityGraph → Verifier → PersonResolver → PersonWatch
- IdentityGraphConfig — per-company verification policy (permissive/moderate/strict/regulated) controlling how aggressively sessions are merged.
- SubscriptionGate — gates identity features by plan.
- Verifier — applies the policy to candidate matches.
- PersonResolver — the core resolution: match a session to an existing Person or create a new one, using fingerprint, IP signals, and form captures.
- PersonWatch — drives the Nox "visitor watch" / auto-engage-on-return behavior.
Trust model (post-overhaul, 2026-07)
user_sessions.identity_trust is anonymous | claimed | verified. Provenance is
enforced by source-set helpers in PersonResolverService.js, not a constants file:
_isHighTrustSource()—form,crm,inbound_call,booking,chat_form. Only these may overwrite canonical name/email/phone (_mayOverwriteCanon); everything else fills empty fields only.GENUINE_USER_SOURCES(in_decideTrust) — high-trust ∪chat,call. Only genuine sources can verify; a first profile minted by a non-genuine source stays unverified (first_profile_untrusted_source). Enrichment can never mintverifiedor overwrite a self-provided name.- Direct-trigger reasons:
first_party_form,first_party_booking(bookings auto-verify),crm_tracked_landing,caller_id_match, and chat/call self-identification →verify_chat/ reasonchat_identification. - Name moderation —
app/lib/NameModeration.jsscreen(name)(obscenity + denylists) rejects junk/profane names before persisting; the frontend falls back to "Unknown Visitor" (getSessionName,frontend/src/common/helpers/session.js). - AI prompts get verified names only:
IdentityGraphRendererServiceemits a name only whenname_addressable(verified). The Apollo colleague list (company_contacts) is excluded from the default section set — opt-in viaidentity_graph_config.enabled_sections.
Resolution order on session create
UserSessionController.create resolves internally before spending enrichment
quota: _applyWidgetUserSessionId() → _backfillPersonFromPriorVisit()
(widget-id/cookie link at claimed) → personResolver.wouldClaimDevice()
(device-signature match at claimed). Snitcher/Apollo only fire when
!internally_resolved && user_ip && suspected_bot !== true — returning
identified visitors don't burn quota. When the client's own IP lookup is
ad-blocked, the server fills user_ip from x-forwarded-for / x-real-ip.
Suspected identity — cross-device guesses are shadow markers, not a trust
tier: ip_shadow_person_id / device_shadow_person_id on UserSessionModel.
IdentityGraphService._buildSuspectedIdentity renders an owner-facing
"⚠ Suspected Identity (UNCONFIRMED)" block; the frontend shows an amber
SuspectedIdentityBadge on live/recorded session rows. Session display naming
(getSessionName) tiers: person → self-provided → suspected → Apollo person →
Snitcher company (business type) → email → Snitcher domain (non-business, ⚠).
Signals feeding resolution
- Fingerprint (FingerprintJS) + device/browser attributes.
- IP signals (
PersonIpSignalModel,IpShadowOutcomeModel,app/lib/ipSignals.js) — IP-assisted resolution, shadow scoring, and backfill. - Form captures — emails/names auto-captured from forms (HubSpot/GHL/Calendly/
Typeform interception) attach identity to a session; the widget pre-chat form
posts
POST /api/intakewithsource: 'form'(direct verification trigger). - B2B enrichment — Snitcher (reverse IP → company) then Apollo (domain →
person) fill
PersonProfile. Clearbit's live API call is disabled (cache-read only); HappierLeads/RB2B remain as legacy/webhook paths. The enrichment-guess CRM push job (LeadContactSyncJob) is disabled globally (commented out inSchedulerService). Enrichment is consent-gated when the tenant enables consent gating (identification category).
Enrichment quota
UserSessionController.getSnitcherData: daily cap company.snitcher_allowed,
counted in company-local timezone; 00:00–06:00 local spends only yesterday's
leftover; bots never spend quota. Apollo has no separate quota — it only runs
after Snitcher resolves a domain. Apollo error responses are never cached
(error-cache poisoning fix); POST /migrations/cleanup-apollo-records (and the
admin wrapper POST /admin/identity/maintenance/cleanup-apollo-records) purges
poisoned ApolloRecordModel rows so domains retry.
Admin surface
Super-admins manage identity via frontend/src/repositories/admin/identity.js
against backend's adminIdentityRoutes.js: list persons/profiles/form-
submissions, session detail (fingerprint/verified-signal display), the identity
graph config (moved admin-side: GET/PUT /admin/identity/config/defaults,
PUT /admin/identity/config/company/:id), maintenance panels (trim session
events — NDJSON progress, retry poisoned Apollo domains, unlink enrichment
identities, backfill chat human-message flag, IP-signal backfills), and the GHL
reconcile-conflicts queue.
Consumers
- AI agents — cross-session context is injected into chat/voice prompts so the AI knows a returning visitor.
- Nox — visitor lookups, presence, and watch directives.
- CRM sync — resolved identities + outcomes flow to GHL/HubSpot. See CRM Integration.
Reliability
Identity resolution has had a dedicated reliability effort (IP-assisted resolution, form-intercept fixes, calls/read-gate/reconcile work). Treat resolution changes as high-blast-radius: they affect lead counts, dedup, and CRM records. Validate against real session samples before shipping.