People Intelligence Database
The shared people intelligence layer: one global, org-free record per person, keyed on LinkedIn URL because employers are time-bound facts, not identity. Headhunter research and Hunter email lookups read it first and write back with provenance. A job change closes an employment edge, kills its work email, and becomes the highest-value signal we have. Erasure is first-class: hard delete plus a hashed suppression list.
Two layers, one seam
tl;dr Today every org pays full research price for every candidate, even one another org researched yesterday: crm_people_v2 and workflow_run_people are org-scoped, so knowledge never crosses tenants. Same split as the company layer: a global intelligence layer that improves with network effects, and the tenant-private CRM that never leaks into it. The person-shaped differences: identity that survives job changes, employment as append-only edges, and a GDPR erasure path.
People intelligence
A flat canonical profile with per-tier freshness stamps, append-only employment edges carrying work emails, and erasure tombstones. Provenance reuses the shared intel_sources lineage and people events land in the unified intel_signals store. Written by headhunter runs, scrapers and agents. Read by every tenant's runs.
CRM workflow
Ownership, lists, stages, notes, shortlists, outreach and run snapshots. Each org keeps its own copy of a person; intel only pre-fills the enrichment fields.
System design
before / after Two interactive entity diagrams. Production today is the org-scoped world: crm_people_v2 and workflow_run_people live per tenant, employer is a free-text column, and every org re-researches the same person and re-pays Hunter. The intel layer design adds a global, org-free intel_person_* layer keyed on LinkedIn URL, with employment as append-only edges that join the company layer by domain. Toggle the two, then click any table to trace its relationships and columns.
The new design: a global people intel layer keyed on LinkedIn URL, employment as append-only edges, joined to the company layer by domain. Hover or click a table.
Production stores employer as a free-text column and the work history as a JSONB blob - impossible to diff for a move and impossible to join to the company layer. The new intel_person_employments edge fixes both: a job change appends an edge, the work email dies with the edge it sat on, and company_domain joins straight into intel_companies. Same person facts, a join and a signal source the old shape could not express.
Data flow
interactiveHow a single request moves through the new design. Pick a path - a cache hit, the people-specific job-change partial hit, or a full miss - and the diagram lights the nodes and edges it touches, in order. Click any node for detail.
Fresh hit: the lookup finds the person by LinkedIn URL with a current employment edge and a verified work email inside TTL, served straight into the run. No Exa, Firecrawl or Hunter call.
Click any node for detail.The middle path is what makes the people layer different. A move keeps the profile a hit and re-runs only Hunter, at the new employer domain - the cheapest possible refresh. The same write-back that heals the cache also mints the highest-value signal we have, because a new employment edge appearing is the job-change event. The pre-fill seam stays one-way: nothing tenant-derived travels back up.
Read-through cache
interactiveSame mechanic as the company layer, with one person-shaped twist: the middle scenario. A job change is not a cache failure - it is a partial hit that re-finds only the email and mints the best signal we have. Pick a scenario and watch the lookup path.
Today the Exa → Firecrawl → Hunter chain runs per org, per run, and Hunter results are cached only within a single run. With the shared cache in front, a person researched by any tenant is free for every other tenant - and the email cache key is the employment edge, so a stale email can never outlive the job that made it valid.
Privacy boundary and erasure
gdpr Person data is personal data, so the seam is enforced three times: by policy (only publicly observable professional facts plus discovered work emails enter the shared layer), by mechanism (client roles get RLS deny-all on intel_person_*; the ac-python-api service role is the only door), and by erasure (hard delete plus a hashed suppression list that blocks re-ingestion).
intel_peopleintel_person_employmentsintel_person_suppressionsintel_sourcesintel_signalsProfiles, employment edges, emails and signals flow down into headhunter results and tenant CRM copies.
Research and Hunter output discovered during a run flows up with provenance, after the suppression check.
Tenant workflow state, candidate ratings and who-targeted-whom never enter the shared layer.
crm_people_v2workflow_run_peoplenotes · shortlists · outreach- Name, public title, employer history, location
- Skills, languages, education, certifications
- Public posts, speaking, publicly stated mandates
- Discovered work emails with confidence and verified-at
- Avatar and public social URLs
- CRM stage, owner, notes, tasks, outreach history
- Candidate ratings, shortlists, rejection reasons
- Whether any tenant targeted, contacted or hired a person
- Personal phone numbers sourced from tenant interactions
- Anything derived from tenant activity, even aggregated
Erasure request received
A person (or a tenant relaying one) asks for removal. The intel_sources rows this person references (via intel_people.source_ids, the employment edges and their signals) are the data subject access inventory - exactly what we hold and where it came from.
Hard delete across the shared layer
Profile, employment edges, the person's intel_signals rows and the intel_sources payloads they reference are deleted in one transaction. Tenant CRM copies are each tenant's own controller responsibility.
Suppression hash appended
SHA-256 of the normalized LinkedIn URL and any known emails lands in intel_person_suppressions. No plaintext is retained.
Re-ingestion blocked at write time
Every write-back path checks the suppression table first. An erased person stays erased even when a future run rediscovers them.
A legitimate-interest assessment and privacy policy update cover the GDPR basis for sharing professional data cross-tenant, and Hunter's ToS must be cleared for caching found emails across tenants. Neither blocks the schema work, but both gate cross-org emails shipping.
intel_person_* schema
model Three person-specific tables, slimmed to match the company layer: a flat intel_people profile carrying per-tier freshness stamps (no facts table until multi-source conflicts appear), the append-only intel_person_employments edges, and the GDPR intel_person_suppressions tombstones. Provenance reuses the shared intel_sources table and people events land in the unified intel_signals store, both designed on the signals page. The structural divergence from the company layer is intel_person_employments: employer, title and work email are edges in time, not profile columns - and company_domain on the edge is where the two intel layers join. Click a table.
intel_peopleCanonical flat profile - who the person is, independent of any employer. Per-tier freshness stamps live on the row, so there is no facts table to assemble on readlinkedin_urltext · uniquenormalized, primary identity keyfull_name · avatar_url · summarytextlocation · country · country_codetextskills · languagestext[]education_history · certificationsjsonbslow-moving, cold tiertwitter_url · personal_websitetextcurrent_title · current_company_domaintextderived from the latest open employment edge, never written directlyextrajsonblong-tail provider fields until usage earns a columnsource_idsuuid[]fk into the shared intel_sources lineage table - the same provenance table the company and signals layers writefetched_hot_at · fetched_warm_at · fetched_cold_attimestamptzper-tier freshness stamps drive the TTL check - no facts table neededfirst_seen_at · last_enriched_attimestamptzJane Doe leaves Acme for Beacon. The schema made tangible. Her profile row is untouched - identity survives the move. The old employment edge is closed, a new one appended, and the Acme work email dies with the edge it sat on. Only Hunter re-runs, at the new domain, so there is one new intel_sources row. The move itself becomes one append-only intel_signals row that rolls up to Beacon's company feed. Follow the keys down the cards: per_5c the person, src_b2 the new fetch, cmp_beacon the rollup.
intel_peoplethe person, unchanged by the move - only the derived current_* fields flipintel_person_employmentsthe old job - closed, not deleted. The Acme work email dies with itintel_person_employmentsthe new job - appended, never an UPDATE. ended_at null = currentintel_sourcesshared lineage - only Hunter re-ran, at the new domain. Profile stayed a hitintel_signalsunified store - ONE append-only row. related_company_id rolls it onto Beacon's company feedPutting the work email on the employment edge encodes the invalidation rule in the schema itself: close the edge and the email is superseded with it, no cleanup job needed. And a new edge appearing is precisely the job-change signal phase 2 will score - the cache and the signal pipeline share one data model.
Decision register
decidedThe six decisions the earlier workspace left open, now closed. Each tab shows the call and why it went that way.
What identifies a person?
decidedNormalized LinkedIn URL is the canonical key - never the employer
Exact normalized LinkedIn URL (lowercase, canonical slug) auto
Exact email match against any employment edge auto
Fuzzy name + employer suggest only
Emails churn when people move and employer is a time-bound fact, so neither can anchor identity. The LinkedIn URL is the one key that follows the person. Email still matches automatically because an edge email is unambiguous - but fuzzy name + employer never merges on its own; in a shared layer a wrong merge corrupts every tenant at once.
company_domain. The shared intel_sources lineage and the unified intel_signals store are designed on the Signals intelligence database.