Enrichment

Reusable company and people enrichment workflows with canonical Intelligence write-back, existing tier freshness and bounded asynchronous email.

1 min read Updated Sep 2, 2026

Enrichment

The Phase 7 capability contract owns inputs, fields, presets and results. Company Enrich and People Enrich are separate product capabilities over shared enrichment components. They do not duplicate the provider waterfalls or introduce another runtime.

Product behavior

The caller gives known identities and a Basic or Standard field set. The workflow fills or refreshes those fields and writes normalized facts to canonical Intelligence. The result contains canonical refs, field states, provider lineage, currentness decisions and cost. It performs no discovery, fit scoring, CRM mutation or email sending.

Search never invokes enrichment by default. A user selects Enrich, or a published workflow includes an explicit enrichment child. CSV parsing, Deep research, forced full refresh and per-field provenance storage are deferred. The former FilledField contract and quick/standard/deep proposal are superseded.

Reusable execution stages

TEXT
company.enrich or people.enrich
  -> resolve authorized inputs to canonical identities
  -> load cache and capture the evaluation time
  -> calculate the requested missing or stale targets
  -> invoke the existing subject component with bounded concurrency
  -> normalize and persist successful facts and source lineage
  -> return per-item states

people.enrich, when email is requested
  -> persist profile results early
  -> submit one bounded email child
  -> wait and collect by echoed contact reference
  -> persist email facts and finish the live root

The company component is component.company.enrichment. Its registered durable function boundary permits reuse without importing legacy helpers into src.agentic. The workflow needs an authorized durable tool bridge outside the ordinary tool step. The Company Enrich design cases define the required recovery and ownership checks. The wrapper passes root usage identity and requested targets through that boundary. Selected company batches invoke existing children in order, one at a time. This lets a meter fault stop the next paid child. Checkpoint source eligibility and settle saved usage before continuing. Keep the existing child concurrency limit across batches. Check output size after identity deduplication and between children. Preserve selected values; trim lineage and diagnostics with counts. Return payload_too_large if required output cannot fit. A 100-item request must also fit the 31 KiB output limit. Do not replace the waterfall with the narrow exact-domain Hunter tool.

Source order

The existing company source order remains cost-aware and deterministic:

TEXT
Explorium name lookup
  -> Exa company/link resolution
  -> Parallel company/link resolution
  -> Explorium firmographics
  -> Exa firmographics fallback
  -> Hunter

The Exa firmographics fallback is separate from Exa link resolution. It can fill industry, country, description and location. Each source checks whether it can fill a remaining target. Accepted earlier values take precedence over later source values. The workflow stops paid work when the requested targets are satisfied or a time/cost bound is reached. A source that fails does not erase another source's result.

People profile enrichment reuses the current cache and fast profile components, including Hunter media enrichment. The people component is component.people.enrichment, with one registered component.people.enrichment_one child per person. Hunter is the only V1 profile source. It supplies name, title, employer, place, avatar, summary and the two social URLs. FullEnrich handles requested work-email completion through the platform provider-job contract. Provider coverage can be incomplete. A preset names target fields, not guaranteed values. A preset still lists only fields a V1 source can supply, so a request cannot ask for work that no source performs.

A source gates on the pending selected fields it can supply, not on one stored value another caller wanted. The Hunter avatar self-skip belongs to the discovery caller and must not suppress a selected profile refresh.

Media rehosting remains part of the existing component path. A cached logo or avatar can still need rehosting before it reaches a product surface. Identity and tenancy checks apply before any provider or canonical write.

Freshness and merge

Use the canonical freshness rules. Company and person Intelligence already have separate tier windows and provider-fetch lineage. The former never/180/30-day field TTL table is superseded. Do not add a second TTL model.

Currentness and completeness are different checks. A current row can still lack a requested field. Capture the evaluation time in the durable lookup step so a retry makes the same currentness decision. refresh: missing preserves existing values; refresh: stale refreshes requested stale targets as well as gaps. An existing stale value kept by refresh: missing has field_states[field]: retained. No refresh: all mode is part of V1.

Preserve a tier timestamp when a partial refresh leaves retained stale sourced fields unverified. A stored field that no V1 source can supply is not sourced, so it never holds a tier stamp back. A successful title update must not make a failed industry refresh appear current. The implementation adds this decision to the shared component/store write boundary; it must not repair timestamps after the write. A profile-only write never refreshes the email hot timestamp.

Canonical writes union source lineage and handle concurrent updates through the shared store. Do not overwrite canonical identities, copy private CRM fields into Intelligence, or report success before persistence. An explicit later CRM action can copy approved public facts into a tenant's relationship records.

Email state and recovery

Profile results become readable while email waits. The root Run remains live until the email child ends. Clients distinguish pending, not-found, failed, cancelled and usable email results. A provider's completed batch is not proof that every contact has an email. Verification and confidence remain provider facts; the product never constructs an address or upgrades an unverified result.

A durable provider-job row records completion. Callback delivery only wakes the workflow. Check stored state around wait registration so an early callback cannot strand the Run. Match results by unique echoed contact refs. Callback order does not define person identity. Retry persistence without resubmitting a paid batch. Settle provider cost once against the root.

The live callback-before-reservation case remains gated by ENG-2300 and ENG-2210. Do not claim that a future-events-only wait or this design review proves that provider behavior. Cancellation stops further work and writes from the cancelled lane. Late callbacks cannot reopen the Run.

Required scopes come from the entire published workflow graph, including its email child. A Basic request does not narrow that graph's admission scopes. Provider availability may still depend on requested fields. See the enrichment scenarios.

Legacy diagram

The diagram below records the earlier one-spine proposal. Its detached trailing lane and per-field provenance assumptions are superseded by the live-root and tier rules above.

Enrichment — fill fields on an identity we already hold
Enrichment — fill fields on an identity we already hold
One spine, two subjects, two lanes. It finds nobody and it judges nothing. Freshness is the rule it is missing today, and the one that unblocks the signals follow-up.
One spine, two subjects, two lanes. It finds nobody and it judges nothing. Freshness is the rule it is missing today, and the one that unblocks the signals follow-up.
Callers  ·  five doors into one waterfall — today there is only one, and it needs a search attached
Callers · five doors into one waterfall — today there is only one, and it needs a search attached
company_search
the roster it just found
company_searchthe roster it just found
people_search
profile + avatar
people_searchprofile + avatar
signals_search
a signal needs an address
signals_searcha signal needs an address
the CRM
refresh a held row
the CRMrefresh a held row
import
a CSV, rate-controlled
importa CSV, rate-controlled
workflow/enrichment.requested
{ subject_type, subjects[], fields[], lane, refresh, max_cost_usd }
workflow/enrichment.requested{ subject_type, subjects[], fields[], lane, refresh, max_cost_usd }
workflow.enrichment  ·  Inngest orchestrator (thin step spine)
workflow.enrichment · Inngest orchestrator (thin step spine)
1 · prepare
parse event · preflight · start run
1 · prepareparse event · preflight · start run
2 · load the current state
what do we hold, and how old is it?
2 · load the current statewhat do we hold, and how old is it?
3 · plan the gaps
field set − fresh fields = the gap set
3 · plan the gapsfield set − fresh fields = the gap set
4 · fan out
one child per subject, ctx.group.parallel
4 · fan outone child per subject, ctx.group.parallel
5 · merge
fill ONLY missing fields, never overwrite
5 · mergefill ONLY missing fields, never overwrite
6 · rehost media
avatars and logos → R2
6 · rehost mediaavatars and logos → R2
7 · finalize
per-field provenance · cost · SSE done
7 · finalizeper-field provenance · cost · SSE done
Which fields are missing, and which have gone stale?
the only branch point. A closed gap costs nothing; the whole waterfall exists to stop paying once it closes.
Which fields are missing, and which have gone stale?the only branch point. A closed gap costs nothing; the whole waterfall exists to stop paying once it closes.
The waterfall  ·  cost-ordered, self-gating, no LLM  ·  one spine, two subjects
The waterfall · cost-ordered, self-gating, no LLM · one spine, two subjects
company  identity = domain
explorium_name → exa → parallel → explorium → hunter

name · website · industry · country · size · revenue · founding year · logo · linkedin
company identity = domainexplorium_name → exa → parallel → explorium → huntername · website · industry · country · size · revenue · founding year · logo ·linkedin
person  identity = linkedin_url
provider profile → hunter avatar → [trailing] fullenrich email

full name · title · employer · location · summary · avatar · work email
person identity = linkedin_urlprovider profile → hunter avatar → [trailing] fullenrich emailfull name · title · employer · location · summary · avatar · work email
Two rules make the waterfall safe
A source self-gates. explorium runs only when a website is present AND a domain-fillable gap remains. A source that cannot help does not bill.
The merge fills only what is missing. It never overwrites a value that a cheaper source, or a human, already set.
Two rules make the waterfall safeA source self-gates. explorium runs only when a website is present AND a domain-fillable gap remains. A source that cannot help does not bill.The merge fills only what is missing. It never overwrites a value that a cheaper source, or a human, already set.
fast lane  inside the run
seconds
returned to the caller
fast lane inside the runsecondsreturned to the caller
trailing lane  after the run completes
50–150s · parks on a webhook, never polls
patched onto stored rows as field updates
trailing lane after the run completes50–150s · parks on a webhook, never pollspatched onto stored rows as field updates
Freshness — the missing rule  every field carries filled_at + source
stable founding year · country  → never expires
slow industry · size · revenue  → 180 days
volatile title · employer · avatar · email  → 30 days

refresh = none | stale | all. Today only "none" exists: a field that is present is never refreshed, however old it is.
Freshness — the missing rule every field carries filled_at + sourcestable founding year · country → never expiresslow industry · size · revenue → 180 daysvolatile title · employer · avatar · email → 30 daysrefresh = none | stale | all. Today only "none" exists: a field that is present is never refreshed, however old it is.
Write the filled fields + their provenance
per_source — calls, hits, cost: the record that says whether a source earns its place
unresolved[] — the record that says an identity is wrong
Write the filled fields + their provenanceper_source — calls, hits, cost: the record that says whether a source earns its placeunresolved[] — the record that says an identity is wrong
Why it becomes a workflow

The two components are the best-tested code in the pipeline. The problem is the door: only a Sonar or a Headhunter run can open it.

A workflow gives it a run id, a budget, a progress stream and a cost record.

It stays a component too, for a caller already inside a run.
Why it becomes a workflowThe two components are the best-testedcode in the pipeline. The problem is thedoor: only a Sonar or a Headhunter runcan open it.A workflow gives it a run id, a budget, aprogress stream and a cost record.It stays a component too, for a calleralready inside a run.
It holds no opinion about a match

Enrichment fills fields and hands them back. It never decides whether a row belongs in a roster.

That gate belongs to the workflow that holds the brief, and five callers hold five different briefs.

Teach enrichment one brief and it serves one caller.
It holds no opinion about a matchEnrichment fills fields and hands themback. It never decides whether a rowbelongs in a roster.That gate belongs to the workflow thatholds the brief, and five callers hold fivedifferent briefs.Teach enrichment one brief and it servesone caller.
Freshness unblocks signals

A job_change signal states that the employer we hold is wrong.

Fill-only-when-missing cannot act on that: the field is present, so no source runs and the stale employer survives.

The signals follow-up is blocked until refresh=stale exists.
Freshness unblocks signalsA job_change signal states that theemployer we hold is wrong.Fill-only-when-missing cannot act on that:the field is present, so no source runs andthe stale employer survives.The signals follow-up is blocked untilrefresh=stale exists.
Legend
Legend
trigger event
trigger event
the branch point
the branch point
deterministic step
deterministic step
durable write
durable write
no purple — this workflow makes no LLM call.
dashed = a trailing lane, or not built yet
no purple — this workflow makes no LLM call.dashed = a trailing lane, or not built yet
identity + field set
identity + field set
subjects[]
subjects[]
the gap set
the gap set
cheap sources
cheap sources
slow sources
slow sources
filled + stamped
filled + stamped
Text is not SVG - cannot display
Enrichment — fill fields on an identity we already hold
Enrichment — fill fields on an identity we already hold
One spine, two subjects, two lanes. It finds nobody and it judges nothing. Freshness is the rule it is missing today, and the one that unblocks the signals follow-up.
One spine, two subjects, two lanes. It finds nobody and it judges nothing. Freshness is the rule it is missing today, and the one that unblocks the signals follow-up.
Callers  ·  five doors into one waterfall — today there is only one, and it needs a search attached
Callers · five doors into one waterfall — today there is only one, and it needs a search attached
company_search
the roster it just found
company_searchthe roster it just found
people_search
profile + avatar
people_searchprofile + avatar
signals_search
a signal needs an address
signals_searcha signal needs an address
the CRM
refresh a held row
the CRMrefresh a held row
import
a CSV, rate-controlled
importa CSV, rate-controlled
workflow/enrichment.requested
{ subject_type, subjects[], fields[], lane, refresh, max_cost_usd }
workflow/enrichment.requested{ subject_type, subjects[], fields[], lane, refresh, max_cost_usd }
workflow.enrichment  ·  Inngest orchestrator (thin step spine)
workflow.enrichment · Inngest orchestrator (thin step spine)
1 · prepare
parse event · preflight · start run
1 · prepareparse event · preflight · start run
2 · load the current state
what do we hold, and how old is it?
2 · load the current statewhat do we hold, and how old is it?
3 · plan the gaps
field set − fresh fields = the gap set
3 · plan the gapsfield set − fresh fields = the gap set
4 · fan out
one child per subject, ctx.group.parallel
4 · fan outone child per subject, ctx.group.parallel
5 · merge
fill ONLY missing fields, never overwrite
5 · mergefill ONLY missing fields, never overwrite
6 · rehost media
avatars and logos → R2
6 · rehost mediaavatars and logos → R2
7 · finalize
per-field provenance · cost · SSE done
7 · finalizeper-field provenance · cost · SSE done
Which fields are missing, and which have gone stale?
the only branch point. A closed gap costs nothing; the whole waterfall exists to stop paying once it closes.
Which fields are missing, and which have gone stale?the only branch point. A closed gap costs nothing; the whole waterfall exists to stop paying once it closes.
The waterfall  ·  cost-ordered, self-gating, no LLM  ·  one spine, two subjects
The waterfall · cost-ordered, self-gating, no LLM · one spine, two subjects
company  identity = domain
explorium_name → exa → parallel → explorium → hunter

name · website · industry · country · size · revenue · founding year · logo · linkedin
company identity = domainexplorium_name → exa → parallel → explorium → huntername · website · industry · country · size · revenue · founding year · logo ·linkedin
person  identity = linkedin_url
provider profile → hunter avatar → [trailing] fullenrich email

full name · title · employer · location · summary · avatar · work email
person identity = linkedin_urlprovider profile → hunter avatar → [trailing] fullenrich emailfull name · title · employer · location · summary · avatar · work email
Two rules make the waterfall safe
A source self-gates. explorium runs only when a website is present AND a domain-fillable gap remains. A source that cannot help does not bill.
The merge fills only what is missing. It never overwrites a value that a cheaper source, or a human, already set.
Two rules make the waterfall safeA source self-gates. explorium runs only when a website is present AND a domain-fillable gap remains. A source that cannot help does not bill.The merge fills only what is missing. It never overwrites a value that a cheaper source, or a human, already set.
fast lane  inside the run
seconds
returned to the caller
fast lane inside the runsecondsreturned to the caller
trailing lane  after the run completes
50–150s · parks on a webhook, never polls
patched onto stored rows as field updates
trailing lane after the run completes50–150s · parks on a webhook, never pollspatched onto stored rows as field updates
Freshness — the missing rule  every field carries filled_at + source
stable founding year · country  → never expires
slow industry · size · revenue  → 180 days
volatile title · employer · avatar · email  → 30 days

refresh = none | stale | all. Today only "none" exists: a field that is present is never refreshed, however old it is.
Freshness — the missing rule every field carries filled_at + sourcestable founding year · country → never expiresslow industry · size · revenue → 180 daysvolatile title · employer · avatar · email → 30 daysrefresh = none | stale | all. Today only "none" exists: a field that is present is never refreshed, however old it is.
Write the filled fields + their provenance
per_source — calls, hits, cost: the record that says whether a source earns its place
unresolved[] — the record that says an identity is wrong
Write the filled fields + their provenanceper_source — calls, hits, cost: the record that says whether a source earns its placeunresolved[] — the record that says an identity is wrong
Why it becomes a workflow

The two components are the best-tested code in the pipeline. The problem is the door: only a Sonar or a Headhunter run can open it.

A workflow gives it a run id, a budget, a progress stream and a cost record.

It stays a component too, for a caller already inside a run.
Why it becomes a workflowThe two components are the best-testedcode in the pipeline. The problem is thedoor: only a Sonar or a Headhunter runcan open it.A workflow gives it a run id, a budget, aprogress stream and a cost record.It stays a component too, for a calleralready inside a run.
It holds no opinion about a match

Enrichment fills fields and hands them back. It never decides whether a row belongs in a roster.

That gate belongs to the workflow that holds the brief, and five callers hold five different briefs.

Teach enrichment one brief and it serves one caller.
It holds no opinion about a matchEnrichment fills fields and hands themback. It never decides whether a rowbelongs in a roster.That gate belongs to the workflow thatholds the brief, and five callers hold fivedifferent briefs.Teach enrichment one brief and it servesone caller.
Freshness unblocks signals

A job_change signal states that the employer we hold is wrong.

Fill-only-when-missing cannot act on that: the field is present, so no source runs and the stale employer survives.

The signals follow-up is blocked until refresh=stale exists.
Freshness unblocks signalsA job_change signal states that theemployer we hold is wrong.Fill-only-when-missing cannot act on that:the field is present, so no source runs andthe stale employer survives.The signals follow-up is blocked untilrefresh=stale exists.
Legend
Legend
trigger event
trigger event
the branch point
the branch point
deterministic step
deterministic step
durable write
durable write
no purple — this workflow makes no LLM call.
dashed = a trailing lane, or not built yet
no purple — this workflow makes no LLM call.dashed = a trailing lane, or not built yet
identity + field set
identity + field set
subjects[]
subjects[]
the gap set
the gap set
cheap sources
cheap sources
slow sources
slow sources
filled + stamped
filled + stamped
Text is not SVG - cannot display
Legacy reference only. Phase 7 keeps the root live for requested email completion and uses existing Intelligence tier freshness.