People search

Implementation notes for people.search. Bounded company scope and persona gates return selectable person identities without enrichment.

1 min read Updated Sep 3, 2026

People search

The Phase 7 capability contract owns the public schema and limits. This page preserves scope, identity and gate details for the implementation.

Product behavior

People Search returns relevant people for a known company scope or a bounded company brief. Search returns stable LinkedIn identities and observed fields. People Enrich is a separate operation. Search performs no profile enrichment, email discovery or CRM write.

The earlier enrich: True and email_lane: True defaults are superseded. V1 has no enrich, enrich_fields, email_lane or depth search parameter. Headhunter remains a legacy surface during migration; it is not the new capability's product name.

Execution stages

TEXT
people.search
  -> validate at most one company-scope form
  -> use known companies, invoke one company.search child, or use no scope
  -> read supplied people and/or query Explorium
  -> normalize LinkedIn identities and merge observations
  -> apply company and persona gates
  -> persist tenant-owned Run output with per-company counts

ENG-2280 owns the core and supplied source. ENG-2291 owns the licensed Explorium adapter. ENG-2293 owns company-brief resolution. Known company refs skip that child step. Each child uses the normal Run boundary, frozen definition and shared root budget. The source branch selects one of two native company nodes. This preserves the omitted companies field for Explorium-only briefs.

Company scope

The request supplies known company refs or one Company Search input, never both. A supplied-only request may omit the scope. That lane attempts no company and returns an empty per_company list. A company-search child returns identities without enrichment. Its output order determines any scope truncation. The public contract owns the 20-company ceiling and explicit truncation diagnostics. No resolved company means no people source call. A partial company result stays partial, even with no rows. See the company-brief contract for child failures, replay and result accounting.

Do not reuse a private target-company resolver when the shared company capability can resolve the same scope. The selected company refs also support the Companies surface's Find People handoff.

Sources

SourceV1 statusReusable detail
Supplied listSupported as typed PersonRefsUnmetered people.search_supplied tool; preserve input indexes and report unresolved identities
ExploriumSelected licensed providerMap supported persona fields within known company IDs
Exa / ParallelDeferred for direct People SearchSignals can retain its bounded web research and feed resolved observations to supplied search
Signals storeDeferred for direct People SearchA future source can read stored people events without launching signal collection

Provider IDs remain evidence. They never replace normalized LinkedIn identity. An employer's country is not the person's location. The adapter must preserve that distinction. Unofficial LinkedIn scraping is not a V1 source.

Identity and gates

A person needs a normalized individual LinkedIn profile URL. A name and employer alone cannot prove identity. Return an unresolved diagnostic instead of guessing a profile. Merge duplicate observations before applying gates. Conflicting canonical identities never merge automatically.

Apply company and persona gates to fields available from discovery or authorized cache reads. Unknown values remain unknown. Explicit contradictions exclude a row with a stable reason. Return per-company counts, including companies with no people and companies not attempted because a bound was reached. No single company can consume the whole result allowance.

The earlier post-enrichment re-gate is not part of standalone search. A composed Signals workflow can evaluate refreshed employer evidence after explicit People Enrich. That evaluation must distinguish a missing employer from a confirmed employer outside the selected scope. It must not discard a company prospect merely because it finds no matching person.

Selection and enrichment

The product surface passes selected search-row refs to People Enrich. The tenant input adapter resolves those refs; the client does not resubmit provider data as canonical facts. Search success is independent of email availability. The next operation reports its own cost and field states. See the search and identity scenarios.

Legacy diagram

The diagram below records the superseded automatic-enrichment and post-enrichment gate design. Use its source and identity separation as background, not its V1 execution order.

People search — resolve the scope, then find the people
People search — resolve the scope, then find the people
The company half of a role brief is a company search, so it runs first. Enrichment is invoked, and the re-gate runs after it, because enrichment can correct an employer.
The company half of a role brief is a company search, so it runs first. Enrichment is invoked, and the re-gate runs after it, because enrichment can correct an employer.
workflow/people_search.requested
{ brief, company_scope, sources[], target_per_company, depth, enrich }
workflow/people_search.requested{ brief, company_scope, sources[], target_per_company, depth, enrich }
workflow.people_search  ·  Inngest orchestrator (thin step spine)
workflow.people_search · Inngest orchestrator (thin step spine)
1 · prepare
parse event · preflight · start run
1 · prepareparse event · preflight · start run
2 · resolve the scope
company names → domains, via company_search
2 · resolve the scopecompany names → domains, via company_search
3 · discover
ctx.group.parallel over the enabled sources
3 · discoverctx.group.parallel over the enabled sources
4 · resolve identity
candidate → one linkedin_url, then dedup
4 · resolve identitycandidate → one linkedin_url, then dedup
5 · invoke enrichment
ON by default — the roster comes back filled
5 · invoke enrichmentON by default — the roster comes back filled
6 · re-gate
location · employer · seniority
6 · re-gatelocation · employer · seniority
7 · link + persist
attach company_id, then store the roster
7 · link + persistattach company_id, then store the roster
8 · finalize
counts · funnel · SSE done
8 · finalizecounts · funnel · SSE done
workflow.company_search  ·  SCOPE RESOLVE, enrich=False
workflow.company_search · SCOPE RESOLVE, enrich=False
"heads of growth at Series B fintechs in Berlin"
the company half of the brief is a company search. It needs domains, not firmographics — so one resolver serves both workflows.
"heads of growth at Series B fintechs in Berlin"the company half of the brief is a company search. It needs domains, not firmographics — so one resolver serves bothworkflows.
Pick the sources for this brief
an open role brief, a precise title inside a known company set, and a people-event brief are three different searches
Pick the sources for this briefan open role brief, a precise title inside a known company set, and a people-event brief are three different searches
Sources  ·  component.people.search.source.*  ·  results come back structured — no extractor agent in this lane
Sources · component.people.search.source.* · results come back structured — no extractor agent in this lane
.exa · .parallel  planner agent + web search
for an open role brief across many companies. The planner translates the brief into vendor query params, and nothing else.
.exa · .parallel planner agent + web searchfor an open role brief across many companies. The planner translates the briefinto vendor query params, and nothing else.
.provider  licensed people-data provider
there is no LinkedIn source, and there will not be one. No legitimate API exists; the constraint is data licensing, not agent capability.
.provider licensed people-data providerthere is no LinkedIn source, and there will not be one. No legitimate API exists;the constraint is data licensing, not agent capability.
.signals_store  SQL read on intel_signals
for a people-event brief — job_change, promotion, tenure_milestone — already collected and stored
.signals_store SQL read on intel_signalsfor a people-event brief — job_change, promotion, tenure_milestone —already collected and stored
.supplied  the caller's own list
a named list needs a gate, not a search. Import and the CRM enter here.
.supplied the caller's own lista named list needs a gate, not a search. Import and the CRM enter here.
Identity resolve + dedup  ·  deterministic
the linkedin_url IS the identity · a person with no profile URL cannot be deduped, enriched, or tracked over time
so that row is dropped, and the funnel counts it
Identity resolve + dedup · deterministicthe linkedin_url IS the identity · a person with no profile URL cannot be deduped, enriched, or tracked over timeso that row is dropped, and the funnel counts it
workflow.enrichment  ·  INVOKED, NOT EMBEDDED  ·  enrich=True IS THE DEFAULT
workflow.enrichment · INVOKED, NOT EMBEDDED · enrich=True IS THE DEFAULT
fast lane  profile clean + avatar  ·  seconds
the LLM clean that lived in normalize belongs here
work emails are the ONE field the caller waits for: 50–150s, so they ride the trailing lane and pending_fields says so
fast lane profile clean + avatar · secondsthe LLM clean that lived in normalize belongs herework emails are the ONE field the caller waits for: 50–150s, so they ride the trailing lane and pending_fields says so
Re-gate  location · employer · seniority
it runs AFTER enrichment, because enrichment can correct an employer. It keys on a snapshot of the pre-enrichment employer text, which keeps two cases apart:
CLEARED an empty employer → keep · CORRECTED to a company outside the scope → drop
Re-gate location · employer · seniorityit runs AFTER enrichment, because enrichment can correct an employer. It keys on a snapshot of the pre-enrichment employertext, which keeps two cases apart:CLEARED an empty employer → keep · CORRECTED to a company outside the scope → drop
Link + persist the ENRICHED roster
company_id set before storage, so companies render on the first read
Link + persist the ENRICHED rostercompany_id set before storage, so companies render on the first read
enrichment · trailing lane
dispatched after finalize
FullEnrich work emails
webhook-parked, 50–150s
enrichment · trailing lanedispatched after finalizeFullEnrich work emailswebhook-parked, 50–150s
What this workflow stopped doing

Headhunter held the search, both enrichment lanes and the CRM promotion in one spine.

So a CRM contact with no avatar needed a whole Headhunter run to get one, and signals search had no cheap way to resolve a named person.

The lanes were right. Their placement was not.
What this workflow stopped doingHeadhunter held the search, bothenrichment lanes and the CRM promotionin one spine.So a CRM contact with no avatar needed awhole Headhunter run to get one, andsignals search had no cheap way toresolve a named person.The lanes were right. Their placement wasnot.
One resolver, not two

The target-company resolve inside components/people/targets.py goes away.

A second resolver drifts from the first, and then dedup splits: the same company arrives under two identities and neither roster can be joined to the other.
One resolver, not twoThe target-company resolve insidecomponents/people/targets.py goes away.A second resolver drifts from the first, andthen dedup splits: the same companyarrives under two identities and neitherroster can be joined to the other.
Why normalize splits in two

normalize does two jobs today: it cleans the profile text with an LLM, and it re-runs three hard gates.

The clean is enrichment. The re-gate is search. They share a step only because they shared a workflow.
Why normalize splits in twonormalize does two jobs today: it cleansthe profile text with an LLM, and it re-runsthree hard gates.The clean is enrichment. The re-gate issearch. They share a step only becausethey shared a workflow.
Legend
Legend
trigger event
trigger event
costs an LLM call
costs an LLM call
the branch point
the branch point
deterministic step
deterministic step
durable write
durable write
dashed = another workflow, or a trailing lane
dashed = another workflow, or a trailing lane
company_scope
company_scope
domains
domains
sources[]
sources[]
candidates
candidates
linkedin_urls
linkedin_urls
corrected employers
corrected employers
matched
matched
Text is not SVG - cannot display
People search — resolve the scope, then find the people
People search — resolve the scope, then find the people
The company half of a role brief is a company search, so it runs first. Enrichment is invoked, and the re-gate runs after it, because enrichment can correct an employer.
The company half of a role brief is a company search, so it runs first. Enrichment is invoked, and the re-gate runs after it, because enrichment can correct an employer.
workflow/people_search.requested
{ brief, company_scope, sources[], target_per_company, depth, enrich }
workflow/people_search.requested{ brief, company_scope, sources[], target_per_company, depth, enrich }
workflow.people_search  ·  Inngest orchestrator (thin step spine)
workflow.people_search · Inngest orchestrator (thin step spine)
1 · prepare
parse event · preflight · start run
1 · prepareparse event · preflight · start run
2 · resolve the scope
company names → domains, via company_search
2 · resolve the scopecompany names → domains, via company_search
3 · discover
ctx.group.parallel over the enabled sources
3 · discoverctx.group.parallel over the enabled sources
4 · resolve identity
candidate → one linkedin_url, then dedup
4 · resolve identitycandidate → one linkedin_url, then dedup
5 · invoke enrichment
ON by default — the roster comes back filled
5 · invoke enrichmentON by default — the roster comes back filled
6 · re-gate
location · employer · seniority
6 · re-gatelocation · employer · seniority
7 · link + persist
attach company_id, then store the roster
7 · link + persistattach company_id, then store the roster
8 · finalize
counts · funnel · SSE done
8 · finalizecounts · funnel · SSE done
workflow.company_search  ·  SCOPE RESOLVE, enrich=False
workflow.company_search · SCOPE RESOLVE, enrich=False
"heads of growth at Series B fintechs in Berlin"
the company half of the brief is a company search. It needs domains, not firmographics — so one resolver serves both workflows.
"heads of growth at Series B fintechs in Berlin"the company half of the brief is a company search. It needs domains, not firmographics — so one resolver serves bothworkflows.
Pick the sources for this brief
an open role brief, a precise title inside a known company set, and a people-event brief are three different searches
Pick the sources for this briefan open role brief, a precise title inside a known company set, and a people-event brief are three different searches
Sources  ·  component.people.search.source.*  ·  results come back structured — no extractor agent in this lane
Sources · component.people.search.source.* · results come back structured — no extractor agent in this lane
.exa · .parallel  planner agent + web search
for an open role brief across many companies. The planner translates the brief into vendor query params, and nothing else.
.exa · .parallel planner agent + web searchfor an open role brief across many companies. The planner translates the briefinto vendor query params, and nothing else.
.provider  licensed people-data provider
there is no LinkedIn source, and there will not be one. No legitimate API exists; the constraint is data licensing, not agent capability.
.provider licensed people-data providerthere is no LinkedIn source, and there will not be one. No legitimate API exists;the constraint is data licensing, not agent capability.
.signals_store  SQL read on intel_signals
for a people-event brief — job_change, promotion, tenure_milestone — already collected and stored
.signals_store SQL read on intel_signalsfor a people-event brief — job_change, promotion, tenure_milestone —already collected and stored
.supplied  the caller's own list
a named list needs a gate, not a search. Import and the CRM enter here.
.supplied the caller's own lista named list needs a gate, not a search. Import and the CRM enter here.
Identity resolve + dedup  ·  deterministic
the linkedin_url IS the identity · a person with no profile URL cannot be deduped, enriched, or tracked over time
so that row is dropped, and the funnel counts it
Identity resolve + dedup · deterministicthe linkedin_url IS the identity · a person with no profile URL cannot be deduped, enriched, or tracked over timeso that row is dropped, and the funnel counts it
workflow.enrichment  ·  INVOKED, NOT EMBEDDED  ·  enrich=True IS THE DEFAULT
workflow.enrichment · INVOKED, NOT EMBEDDED · enrich=True IS THE DEFAULT
fast lane  profile clean + avatar  ·  seconds
the LLM clean that lived in normalize belongs here
work emails are the ONE field the caller waits for: 50–150s, so they ride the trailing lane and pending_fields says so
fast lane profile clean + avatar · secondsthe LLM clean that lived in normalize belongs herework emails are the ONE field the caller waits for: 50–150s, so they ride the trailing lane and pending_fields says so
Re-gate  location · employer · seniority
it runs AFTER enrichment, because enrichment can correct an employer. It keys on a snapshot of the pre-enrichment employer text, which keeps two cases apart:
CLEARED an empty employer → keep · CORRECTED to a company outside the scope → drop
Re-gate location · employer · seniorityit runs AFTER enrichment, because enrichment can correct an employer. It keys on a snapshot of the pre-enrichment employertext, which keeps two cases apart:CLEARED an empty employer → keep · CORRECTED to a company outside the scope → drop
Link + persist the ENRICHED roster
company_id set before storage, so companies render on the first read
Link + persist the ENRICHED rostercompany_id set before storage, so companies render on the first read
enrichment · trailing lane
dispatched after finalize
FullEnrich work emails
webhook-parked, 50–150s
enrichment · trailing lanedispatched after finalizeFullEnrich work emailswebhook-parked, 50–150s
What this workflow stopped doing

Headhunter held the search, both enrichment lanes and the CRM promotion in one spine.

So a CRM contact with no avatar needed a whole Headhunter run to get one, and signals search had no cheap way to resolve a named person.

The lanes were right. Their placement was not.
What this workflow stopped doingHeadhunter held the search, bothenrichment lanes and the CRM promotionin one spine.So a CRM contact with no avatar needed awhole Headhunter run to get one, andsignals search had no cheap way toresolve a named person.The lanes were right. Their placement wasnot.
One resolver, not two

The target-company resolve inside components/people/targets.py goes away.

A second resolver drifts from the first, and then dedup splits: the same company arrives under two identities and neither roster can be joined to the other.
One resolver, not twoThe target-company resolve insidecomponents/people/targets.py goes away.A second resolver drifts from the first, andthen dedup splits: the same companyarrives under two identities and neitherroster can be joined to the other.
Why normalize splits in two

normalize does two jobs today: it cleans the profile text with an LLM, and it re-runs three hard gates.

The clean is enrichment. The re-gate is search. They share a step only because they shared a workflow.
Why normalize splits in twonormalize does two jobs today: it cleansthe profile text with an LLM, and it re-runsthree hard gates.The clean is enrichment. The re-gate issearch. They share a step only becausethey shared a workflow.
Legend
Legend
trigger event
trigger event
costs an LLM call
costs an LLM call
the branch point
the branch point
deterministic step
deterministic step
durable write
durable write
dashed = another workflow, or a trailing lane
dashed = another workflow, or a trailing lane
company_scope
company_scope
domains
domains
sources[]
sources[]
candidates
candidates
linkedin_urls
linkedin_urls
corrected employers
corrected employers
matched
matched
Text is not SVG - cannot display
Legacy reference only. Phase 7 separates People Search from profile enrichment and the work-email lane.