Skip to content

Person & Account Identity (Staff)

How Rose figures out which company a visitor is from (account identity) and who the visitor is (person identity), how the two are stored as evidence, and how the staff Person identity comparison page judges whether the signals agree.

Staff-only diagnostics. Backed by the person_identity_comparison view and rendered by PersonIdentityDiagnosticsPage.tsx.

For how the account cascade and sources actually run, see Visitor Enrichment.

Two independent pipelines

Account identity (company) Person identity (who)
Question Which company is this visitor from? Who is this individual?
Providers browser_reveal, snitcher_radar, rb2b, enrich_so (+ first_party_email, hubspot) vector, rb2b_person
Runs as Cascade in unified_enricher.py (stops at first match) Per-provider waterfall in person_identity.py + Vector webhook
Writes to visitors.enrichment_data, accounts, visitor_account_identifications visitor_identifications

The account cascade resolves a visitor's IP to a company and stops at the first source that matches:

redis_cache → supabase_lookup → browser_reveal → snitcher_radar → rb2b → enrich_so

Short-circuit is per enrichment run. A returning visitor (or a different visitor on the same account) can be resolved by a different source on a later run, so over time one account accumulates claims from several providers.

Evidence tables

Each provider claim is stored as one row, so nothing is lost when sources disagree:

  • visitor_account_identifications — one row per (visitor_id, provider), the company each account source claims.
  • visitor_identifications — one row per (visitor_id, provider), the person each person source claims.
graph TD subgraph Account["Account identity"] IP[Visitor IP] --> CAS[unified_enricher cascade] CAS -->|first match wins| ED[visitors.enrichment_data + accounts] ED --> VAI[visitor_account_identifications] end subgraph Person["Person identity"] PW[person_identity waterfall + Vector webhook] --> VI[visitor_identifications] end VI -->|matched + has company domain| FOLD[_fold_into_account_identity] FOLD --> VAI VAI --> SEL[reselect_visitor_account_identity] SEL -->|marks canonical| ACC[visitors.account_id] VAI --> CMP[person_identity_comparison view] VI --> CMP CMP --> UI[Person identity comparison page]

Person feeds the account list

When a person provider identifies someone at a company, that company joins the account evidence — the same way the Vector webhook already does. So the account cell lists every company implied by both account-side and person-side providers. The canonical selector reranks all evidence by provider priority, so a person provider can also promote its company to the visitor's main account.

sequenceDiagram participant V as Visitor participant PI as person_identity waterfall participant PT as visitor_identifications participant AE as account_identity (fold) participant SEL as reselect RPC V->>PI: IP / email PI->>PT: upsert person evidence (rb2b_person) alt matched and business company domain PI->>AE: upsert_account_identity_evidence(provider, tier=person_identity) AE->>SEL: reselect canonical account SEL-->>AE: best provider wins visitors.account_id end

Agreement: per-cell consistency

The comparison view compares like with like, each cell on its own key:

  • Account cell lines agree on the same company domain.
  • Person cell lines agree on the same person (email, falling back to name).

A single provider line cannot agree with itself, so two or more provider lines in one cell are required to judge.

graph TD START[Row] --> Q1{Account cell: >=2 lines?} Q1 -->|yes| Q2{>=2 distinct domains?} Q2 -->|yes| DIS[disagree] Q2 -->|no| AGR[agree] Q1 -->|no| Q3{Person cell: >=2 lines?} Q3 -->|yes| Q4{>=2 distinct people?} Q4 -->|yes| DIS Q4 -->|no| AGR Q3 -->|no| NC[not_comparable]

Only real provider lines count. The canonical-visitor-email fallback (the visitor's own email surfaced as the person) is not an independent source, so a row with one account line and only that fallback is not_comparable, not agree.

Account enrichment is account-scoped (the fallback rule)

accounts.enrichment_data is shared by every visitor on the account, so it is not a per-visitor lookup. The seed backfill in 20260617184057_person_identity_waterfall_evidence.sql inherits the account's provider into a visitor's account evidence only when the visitor has no enrichment of its own:

AND v.enrichment_data ->> 'enrichment_source' IS NULL

Without this guard, the shared account provider was echoed onto every visitor — manufacturing a second account line (e.g. apple.com (snitcher) echoed onto a visitor that rb2b actually resolved) and a false agree. With it, visitors with their own signal use only that; account-only visitors keep the inherited line as a single, non-corroborating line.

Where things live

Concern Location
Account cascade + sources backend/packages/ixchat/ixchat/enrichment/unified_enricher.py, source_config.py
Account source: rb2b (IP→company) enrichment/rb2b_enrichment.py
Person waterfall (Vector skipped here; written by webhook) enrichment/person_identity.py
Person→account fold packages/ixdata/ixdata/clients/person_identity.py
Account evidence write + canonical selector packages/ixdata/ixdata/clients/account_identity.py, reselect_visitor_account_identity
Comparison view person_identity_comparison (migrations 20260617184057, 20260619105932)
Diagnostics page frontend/client-backoffice/src/pages/PersonIdentityDiagnosticsPage.tsx