People search The Phase 7 capability contract owns the public schema and limits.
This page preserves scope, identity and gate details for the implementation.
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.
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.
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.
Source V1 status Reusable detail Supplied list Supported as typed PersonRefs Unmetered people.search_supplied tool; preserve input indexes and report unresolved identities Explorium Selected licensed provider Map supported persona fields within known company IDs Exa / Parallel Deferred for direct People Search Signals can retain its bounded web research and feed resolved observations to supplied search Signals store Deferred for direct People Search A 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.
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.
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 .
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 · prepareparse event · preflight · start run
1 · prepare parse event · preflight · start run 2 · resolve the scopecompany names → domains, via company_search
2 · resolve the scope company names → domains, via company_search 3 · discoverctx.group.parallel over the enabled sources
3 · discover ctx.group.parallel over the enabled sources 4 · resolve identitycandidate → one linkedin_url, then dedup
4 · resolve identity candidate → one linkedin_url, then dedup 5 · invoke enrichmentON by default — the roster comes back filled
5 · invoke enrichment ON by default — the roster comes back filled 6 · re-gatelocation · employer · seniority
6 · re-gate location · employer · seniority 7 · link + persistattach company_id, then store the roster
7 · link + persist attach company_id, then store the roster 8 · finalizecounts · funnel · SSE done
8 · finalize counts · 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 both workflows. 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 brief an 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 searchfor an open role brief across many companies. The planner translates the brief into vendor query params, and nothing else.
.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. .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.
.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. .signals_store SQL read on intel_signalsfor a people-event brief — job_change, promotion, tenure_milestone — already collected and stored
.signals_store SQL read on intel_signals for a people-event brief — job_change, promotion, tenure_milestone — already collected and stored .supplied the caller's own lista named list needs a gate, not a search. Import and the CRM enter here.
.supplied the caller's own list a named list needs a gate, not a search. Import and the CRM enter here. Identity resolve + dedup · deterministicthe 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 · 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 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 · secondsthe 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 · 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 Re-gate location · employer · seniorityit 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 · 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 Link + persist the ENRICHED roster company_id set before storage, so companies render on the first read
Link + persist the ENRICHED roster company_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 lane dispatched after finalize FullEnrich work emails webhook-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 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. 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 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. 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 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. Legend trigger event costs an LLM call the branch point deterministic step durable write dashed = another workflow, or a trailing lane
dashed = another workflow, or a trailing lane company_scope domains sources[] candidates linkedin_urls corrected employers 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 · prepareparse event · preflight · start run
1 · prepare parse event · preflight · start run 2 · resolve the scopecompany names → domains, via company_search
2 · resolve the scope company names → domains, via company_search 3 · discoverctx.group.parallel over the enabled sources
3 · discover ctx.group.parallel over the enabled sources 4 · resolve identitycandidate → one linkedin_url, then dedup
4 · resolve identity candidate → one linkedin_url, then dedup 5 · invoke enrichmentON by default — the roster comes back filled
5 · invoke enrichment ON by default — the roster comes back filled 6 · re-gatelocation · employer · seniority
6 · re-gate location · employer · seniority 7 · link + persistattach company_id, then store the roster
7 · link + persist attach company_id, then store the roster 8 · finalizecounts · funnel · SSE done
8 · finalize counts · 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 both workflows. 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 brief an 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 searchfor an open role brief across many companies. The planner translates the brief into vendor query params, and nothing else.
.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. .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.
.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. .signals_store SQL read on intel_signalsfor a people-event brief — job_change, promotion, tenure_milestone — already collected and stored
.signals_store SQL read on intel_signals for a people-event brief — job_change, promotion, tenure_milestone — already collected and stored .supplied the caller's own lista named list needs a gate, not a search. Import and the CRM enter here.
.supplied the caller's own list a named list needs a gate, not a search. Import and the CRM enter here. Identity resolve + dedup · deterministicthe 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 · 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 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 · secondsthe 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 · 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 Re-gate location · employer · seniorityit 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 · 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 Link + persist the ENRICHED roster company_id set before storage, so companies render on the first read
Link + persist the ENRICHED roster company_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 lane dispatched after finalize FullEnrich work emails webhook-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 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. 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 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. 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 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. Legend trigger event costs an LLM call the branch point deterministic step durable write dashed = another workflow, or a trailing lane
dashed = another workflow, or a trailing lane company_scope domains sources[] candidates linkedin_urls corrected employers matched Text is not SVG - cannot display
Legacy reference only. Phase 7 separates People Search from profile enrichment and the work-email lane.