Company, People and Signals contracts

The five Phase 7 product capabilities, their bounded inputs, stable references, permissions and results.

1 min read Updated Sep 4, 2026

Company, People and Signals contracts

This page defines the Phase 7 contract. All five capabilities install and reconcile on the runtime baseline. The runtime baseline is agentic-platform. The documentation repository uses main. ENG-2285 proves this contract through the API and the CLI, with ac-python-api scripts/agentic_phase7_exit_test/run.sh. The design scenarios list the cases those tests cover.

Product boundary

ConceptMeaning
CapabilityA stable business action, such as company.search
WorkflowThe published steps that execute the action
AgentReasoning inside a bounded task
ToolOne technical operation with a schema, grant and policy check

Companies and People each have Search and Enrich segments. Signals finds opportunities and explains their timing. Search returns selectable references. Enrich completes known identities and writes canonical Intelligence. Search never starts enrichment or email lookup. CRM and list writes require a separate explicit action.

All five V1 executors are workflows. They may contain bounded Agent steps where reasoning is necessary. The Front Door starts at most one capability per turn. A published workflow may compose child workflows. Compound chat plans, live steering, CSV parsing and Deep enrichment remain outside Phase 7.

Registry record

The tenant registry projects published definition bindings. It does not maintain a second copy of executable configuration.

FieldContract
idOne of the five IDs below; case-sensitive
name, descriptionProduct text from the published binding
contract_versionPositive integer; V1 is 1
input_schema, output_schemaClosed JSON Schema contracts for that version
required_scopesrun.start plus the published workflow's derived required tool scopes
executor_typeworkflow in V1
executor_idThe active definition UUID in the caller's organization
availabilityavailable or unavailable, with a bounded reason

The five stable IDs are company.search, company.enrich, people.search, people.enrich and signals.search. The vocabulary is code and the bindings are data, so an organization that installed nothing still reads five records. Display names, provider names and definition UUIDs never replace these IDs. Public reads expose schemas and availability. They omit executor configuration, Tool Registry details and foreign bindings. The executor UUID is internal registry data; clients start by product ID. A public read exposes required_scopes on an available record, and it omits the field on an unavailable one. The registry answers a contract only to a caller that holds every scope in that set, so the field discloses nothing new. It says what a start consumes.

An unavailable record carries the ID, the availability and the reason alone. The registry cannot check the caller's rights against a capability it cannot resolve, because the required scopes live in the binding. Product text and schemas there would be a contract nobody authorized, so those fields are absent rather than null.

Missing installation, disabled executor and invalid binding make an authorized capability unavailable in the registry. An incompatible contract version and a missing provider connection are not registry reasons. A start answers a stale version with contract_version_conflict, and a request-level check answers a missing connection. Both read data the registry does not. Duplicate active bindings fail closed as invalid_binding. The registry never selects the newest duplicate. Unsupported optional fields have request-level availability checks. Missing email support does not disable profile enrichment.

reasonMeaning
not_installedNo bound row of this organization names the ID
inactive_executorA bound row names it, and none is active
invalid_bindingMetadata, scope contract or executor row does not validate; also two active bindings
unauthorizedThe caller does not hold run.start, or does not hold every required scope

not_installed and inactive_executor are two answers on purpose. Disabling a definition clears its activation in the same database write, so a disabled executor leaves a bound inactive row. One answer for both would send an operator to look for an installer fault that is not there.

Authorization and effects

Registry visibility is not a grant. Start rechecks tenant ownership, current rights, binding health, schema and policy. RunManager mints the Principal and applies its existing admission, approval and budget rules. Required tool scopes come from the published graph. Clients cannot submit or widen them.

The registry checks rights in two stages, because the required scopes live in the binding.run.start is checked first, before any binding lookup. An actor that may start no run reads five unauthorized records and learns nothing about what this tenant installed. The published required scopes are checked second, for each capability whose binding resolved. Both stages read the live role rights, so one role change reaches the registry and the admission checkpoint together.

CapabilityRequired effect boundary
company.searchGranted discovery and identity reads; tenant-owned Run output only
people.searchGranted discovery and identity reads; company child scopes when used
company.enrichcompany.enrich write permission covers the shared provider workflow and its canonical Intelligence write
people.enrichPublished profile, write and email-child scopes; the whole graph is checked at admission
signals.searchPublished research, Intelligence and prospect scopes; no CRM or send scopes

Implementations register new tool names in ROLE_RIGHTS before publishing their executors. A capability ID is not a tool scope. A provider connection never grants a tool by itself. An unavailable optional lane must fail before a paid call; it must not silently return a narrower result. Required scopes are static for a published workflow, including both sides of a branch. Request fields never narrow that set. A profile request also needs the email tool scopes when its bound executor contains the email child. A profile-only executor can serve the initial installation; its email field requests are unavailable until the email executor is installed. Adding required scopes requires an explicit managed upgrade, not a silent binding refresh. Provider connection checks can remain field-dependent.

Shared schema rules

The tables below are normative schema definitions for version 1. Implementation tickets encode them as JSON Schema and typed models. All objects reject unknown fields. Optional fields are omitted, unless a row explicitly permits null. Strings are nonempty and at most 1,000 characters. URLs are at most 2,048 characters; domains are at most 253. IDs use UUID format where specified. Counts are integers. Booleans do not count as integers. Filter lists contain 1 to 20 unique values. Unsupported filters or enum values fail validation before provider work.

Every start uses {contract_version: 1, input: <capability input>} and an Idempotency-Key header. The header contains 1 to 255 characters. The CLI requires the same key. The server records a digest of the normalized capability ID, version and input with the admitted Run. The same tenant, caller, source and key return the same Run. A changed request under that key returns idempotency_conflict. The selected binding and contract stay fixed for that admitted Run, even if an upgrade occurs before a retry. Read authorization still applies to a replay. Retry does not start or charge for a second Run.

Cross-field rule projection

ENG-2328 owns this rule. The start endpoint validates a body against the published JSON Schema and nothing else. A rule that lives only in a typed model therefore admits input the Run then fails on, after it spent an admission and a Run row. Every rule a schema can express must be published. A rule a schema cannot express stays at execution time.

The published schema owns these. Each one is structural, so JSON Schema states it exactly.

CapabilityRule
company.searchThe supplied source requires companies. companies requires the supplied source.
company.searchThe explorium source requires one filter at least.
people.searchThe supplied source requires people. people requires the supplied source.
people.searchThe explorium source requires company_scope.
company.enrich, people.enrichThe basic preset limits fields to the basic set.
signals.searchEach source value carries its own fields, and no other. A company reference names exactly one identity.

A projection is one allOf entry with if, then and an optional else. The negative direction writes {"<field>": {"not": {}}} and never a root not. Both refuse the same input. Only the first reports the field path design case ST08 asks for.

Execution time owns these. JSON Schema cannot state them, and a projection would refuse legal input.

RuleWhy a schema cannot hold it
The explorium industry belongs to the provider vocabulary523 values, and the check casefolds. An enum compares exact case, so it would refuse a legal spelling.
The explorium department and seniority belong to their vocabulariesThe same casefold rule, over 29 and 17 values.
A persona or filter list holds no case-duplicate valueuniqueItems compares exact case. It accepts CEO beside ceo, and the model does not.

Do not project a rule from the second table. A start that carries one of those values is admitted, and the Run reports the refusal. Read the second table before you file a schema gap. The gap may be deliberate.

A published schema change always raises the contract version. The installer compares the whole binding. A schema edit under an unchanged contract_version makes a provisioned tenant report invalid, and that tenant cannot recover. The upgrade refuses for the same reason, a rollback to the only released revision writes nothing, and a database trigger refuses both a delete and a binding update on a bound row. The tenant then serves the old schema for as long as it exists.

So a schema edit takes three steps together:

  1. Raise the capability's number in CONTRACT_VERSIONS (ac-python-api src/agentic/shared/capabilities.py). It is the one place any reader compares against.
  2. Add a node revision in capabilities/provisioning.py to carry the new binding, and bump every node that references it.
  3. Re-record RELEASED_NODE_DIGESTS and RELEASED_BINDING_DIGESTS in tests/agentic/capabilities/test_provisioning.py. The binding table names the capability whose contract moved.

An operator then runs scripts/reconcile_capabilities.py --upgrade <capability> --apply, and the tenant reports upgraded. A client that still names the old version reads contract_version_conflict, which is design case ST27.

ENG-2328 raised company.search, people.search and signals.search to version 2.

Capability start identity

ENG-2276 owns this rule for the shared start service and RunManager. The registry and Run capability fields already exist on runtime trunk. Reuse both.

The key namespace includes the actor kind, actor ID, source and a capability-start prefix. The tenant remains the database uniqueness scope. Do not include the capability ID or request digest in the key. Thus, the same key with a different capability conflicts. Generic definition starts use a separate namespace.

Normalize the request as parsed JSON with sorted object keys, compact separators and UTF-8 text. Preserve arrays, strings, parsed numeric types and omitted fields. Reject non-finite numbers and invalid Unicode. Parsed 1 and 1.0 differ. JSON parsing already equates 1e0 with 1.0; source text spelling is not part of the digest. Do not insert schema defaults, trim identities or apply product transformations at this boundary. An omitted default and an explicit value are different requests. Capability executors own their semantic normalization. The digest includes the capability ID, contract version and input. Record digest format version 1.

Store the digest metadata in the frozen Run snapshot in the same insert as the Run and its start key. Do not add a claim table, separate digest write, lease or process-local lock. Only RunManager inserts the Run. The unique tenant/key constraint selects the concurrent winner. The losing request compares its digest with the stored digest before it returns duplicate or idempotency_conflict. A missing or malformed stored digest fails closed; it never starts replacement work.

Read an existing key before resolving the current binding or checking current admission. An authenticated caller can replay its own tenant-visible Run after an upgrade, disable, budget change or scope removal. This is a read of the stored Run. It does not prove that admission completed. A fresh key requires scopes, schema, binding and policy checks. The replay does not repeat admission or dispatch. The existing reaper repairs a lost dispatch after admission completes.

Insert a capability start in the existing temporary approval hold: waiting, waiting_on: approval, and a null approval reference. Set the hold expiry to insertion time. No human approval exists until policy requests one and its row is written. On allow, RunManager conditionally releases this hold to queued, clears the wait fields, and dispatches. The release requires the same null-reference admission hold. It cannot release a human approval or a terminal Run. On deny, RunManager fails the held Run. On require-approval, it uses the existing approval creation and dispatch path. A crash before the decision or release leaves an undispatched hold. The existing wait recovery ends it with wait_abandoned. This hold prevents the queued-run recovery path from executing work whose admission never completed. A crash before insertion claims nothing. A crash after insertion leaves the key and digest together.

A fresh start checks the selected executor again in RunManager before insertion. Its active state, capability ID, version and required scopes must still match. Rights use the existing shared RoleRights cache, with a maximum age of 30 seconds. A repeated check does not promise immediate revocation. A scope absent from the observed rights refuses before insertion. A binding change before that check refuses the start. A later change does not rewrite the selected immutable executor. The start and a concurrent upgrade can overlap; this endpoint does not add a transaction across definition reads and Run insertion.

Validate input against the selected published Draft 2020-12 schema, including local references and declared formats. Do not fetch remote schema references. Return a bounded list of field paths without echoing the input. The envelope rejects extra fields and requires a positive integer version and an input object. Measure the input with the existing 32 KiB boundary before schema work. Oversized input returns 413.

Reference ownership and provider availability remain with each capability's input resolver and executor. This shared endpoint validates the published schema; it does not add five product-specific database lookup paths. Those resolvers must refuse foreign references before provider work, as specified in their own tickets.

Stable references

CompanyRef and PersonRef are tagged unions. Each value has exactly the fields for its tag.

ReferenceShape
Canonical company{kind: "intel_company", id: UUID}
Company domain{kind: "domain", value: string}
Domainless company{kind: "company_linkedin", value: URL}
Canonical person{kind: "intel_person", id: UUID}
Person profile{kind: "person_linkedin", value: URL}
Search row{kind: "search_result", run_id: UUID, result_id: UUID}
CRM person input{kind: "crm_person", id: UUID}; People Enrich adapter only

The search-row tag resolves only within the caller's organization and to the expected entity type. The stored row owns result_id; retries and pagination reuse it. A new search has new result IDs. The row retains a normalized domain or LinkedIn identity and an optional canonical Intelligence UUID. Search need not create a canonical record to return a stable identity. If the source Run or row is unavailable, return stale_reference. Never reconstruct it from client display text.

Normalize identities through shared identity functions. Do not strip meaningful LinkedIn path segments or merge companies by display name. Company domain is primary; company LinkedIn is the fallback when no domain is known. A person requires a normalized individual LinkedIn URL in V1. Name plus company is unresolved, not a guessed identity. If supplied keys resolve to different canonical records, return identity_conflict. Do not merge the records.

CRM, prospect, list, saved-search and Run references are tenant-owned. Resolve their ownership before reading their contents. The canonical Intelligence cache is global. Only allowed public facts enter that cache. CRM notes, private list membership, scores, prompts and tenant selections never enter global Intelligence.

Superadmin company identity writes

The superadmin company service stores intel_companies.linkedin_url as linkedin.com/<kind>/<slug>. On create and update, apply linkedin_organization_key after clean_writes and before any database call. Preserve /company/, /school/ and /showcase/ as distinct identities. Use the shared parser's case, locale, query, fragment and page-subpath normalization. A value already in match-key form stays unchanged.

Omitted, null and blank values stay absent after cleaning. They never clear an existing LinkedIn key. A create still requires a name, domain or valid LinkedIn key. An empty update keeps its existing validation error. Refuse a nonblank value when the parser returns no organization key or raises ValueError. Return the existing HTTP 400 validation error before any database write or cache invalidation. Keep successful-write cache invalidation and the existing HTTP 409 mapping for unique-key conflicts. The unique index resolves concurrent collisions on stored match keys; do not add a lookup before the write.

ENG-2311 owns this write contract and scenarios IW01 to IW14. ENG-2310 owns legacy-row repair, including collisions with values still stored in display form. This write contract changes no CRM display form, shared parser, endpoint schema or database schema.

Intelligence identity repair

ENG-2310 repairs intel_people.linkedin_url and intel_companies.linkedin_url. The stored match key is linkedin.com/<kind>/<slug>. CRM fields keep their display URLs. canonicalize_identity keeps its CRM display contract. Intelligence writers use the existing key helpers. Reads by LinkedIn require the match key. Reads by UUID and company domain remain available.

This migration converts stored identity forms. It does not add a URL parser to the database. Accept an exact match key or the exact display prefix https://www.linkedin.com/. People use in. Companies use company, school or showcase; each kind remains distinct. The slug uses lowercase ASCII URI path characters: [a-z0-9_%.'~!$&()*+,=:@-]+. Keep percent escapes as stored. Do not decode them or resolve aliases. Preserve null company URLs. Stop the transaction for any other value, including blank text or a wrong kind. The operator must inspect unexpected values before another attempt. Do not clear or guess their identities.

Before a write, count both tables in local, staging and production. Record totals, display forms, match keys, nulls, unsupported values and collision groups. Refresh the target counts under a write lock when the migration runs. Use one transaction and lock both intelligence tables in the same order. Use a short lock timeout and a bounded statement timeout. A timeout rolls back the complete repair. Ordinary reads can continue. Concurrent writes wait until the transaction ends. Deploy the person and company writer fixes to the target before applying the repair there.

Build one set that includes every supported row, including rows already stored as match keys. Group by table and match key. Company groups include rows with and without domains. A group with one row can change its display URL to the match key. A group with two or more rows keeps every row unchanged. Do not choose a winner or merge facts. Record all member UUIDs, in sorted order, in firmographics_linkedin_collisions with a null organization. Use the match key as the report's canonical value. This separates the report from earlier display-form reports. Retain the report with service-role access only. Revoke all table grants from PUBLIC, anon and authenticated roles. Do not insert the same group snapshot twice on a repeat run.

Preserve row UUIDs, facts, provenance and freshness dates. The existing update trigger can advance updated_at on changed rows. Do not update a row that already holds the key. Do not change CRM rows, indexes, API contracts or cached Run results. Collision rows can remain unavailable through LinkedIn reads; the retained report makes that limitation explicit. A failed transaction needs no reverse migration. After commit, use the normal recovery process; do not reverse newer writes. Prove the repair with rollback-based database tests and a PeopleIntelStore write followed by a people.search read.

Shared results

Company and People capabilities return the following closed envelope. Signals keeps its existing product output.

FieldShape
capability_id, contract_versionThe admitted stable ID and version
itemsUp to 100 typed search or enrichment rows
outcomecomplete, partial or empty; this is not a Run status
diagnosticsUp to 100 {item_index: integer or null, reason: string} records
usage{cost_cents: nonnegative number, cost_kind: "estimated" or "settled", provider_calls: nonnegative integer}

diagnostics.reason is one of unresolved, identity_conflict, stale_reference, filter_mismatch, provider_failed, deadline_reached, budget_reached, scope_truncated, output_truncated, not_found, no_matches or unknown_filter_value. The response reports omitted diagnostic counts separately as diagnostics_omitted, a required nonnegative integer. Foreign tenant references fail the whole request with a non-disclosing error, before any work starts. Other item failures preserve valid items and caller order. Duplicate inputs share one work item and retain their input indexes. Usage reads the canonical Run meter. Parent summaries include child usage once and never create a second charge.

Search rows contain result_id, ref, nullable canonical_id, display, evidence and input_indexes. ref is a normalized identity tag. input_indexes is an integer array, empty for discovered rows. display permits only the fields in the relevant enrichment field table, excluding email and verification data. People Search adds the discovery-only person fields, which no enrichment preset can supply. Its own section lists them. Search displays only values already returned by discovery or an allowed cache read. It makes no extra enrichment calls. evidence holds at most three {source, provider_ref, url} records; provider_ref and url can be null. source is supplied, explorium or cache. Provider refs are observations, not entity keys.

Search also returns funnel: {candidates, duplicates, unresolved, excluded, returned} with nonnegative integer counts. per_source maps each requested source to {status: "complete" or "partial" or "failed", candidates: integer}. People Search also returns per_company, with one {ref, candidates, returned, reason} row for each attempted company. Its nullable reason uses the diagnostic enum. An unattempted company has an explicit deadline_reached or budget_reached row.

All outputs obey the runtime payload limit. Reduce optional evidence first and report output_truncated. Never cut an identity, JSON value or field-state record. If the required result cannot fit, fail with the existing payload error. A valid empty search succeeds with outcome: empty and no_matches. Total provider failure fails the Run; it is not an empty success. Usable results with an item or source failure return partial. Invalid input creates no Run.

InputContract
sourcesRequired unique list from supplied, explorium; no automatic source expansion
companies1 to 100 CompanyRefs when supplied is selected; absent otherwise
filtersOptional closed object defined below; at least one filter for provider discovery
target_countDefault 50; range 1 to 100

V1 filters are country_codes, industries, employee_bands and revenue_bands. country_codes uses ISO alpha-2. industries uses LinkedIn industry names from the adapter's versioned vocabulary. Employee and revenue bands use published adapter enums, never guessed numeric conversions. The adapter maps these fields to Explorium country_code, linkedin_category, company_size and company_revenue. See Explorium business filters.

Both sources feed the same identity resolution, deduplication and gates. Merge duplicate observations before gating. Retain bounded evidence from both sources. Keep an unknown field and mark it unknown. Drop an explicit contradiction with filter_mismatch. No score, enrichment, CRM write or list write occurs.

Technology, free-text thesis, funding-event, city and signals-store filters are deferred for direct Company Search. Do not silently map these requests to a different query. Signals research retains its own broader brief contract. Explorium access is a deployment prerequisite; public API documentation does not prove an account's entitlement.

Supplied-list core

The retained company.search_supplied tool accepts exactly sources: ["supplied"] and remains unmetered. The provider-capable workflow uses a separate company.search tool and grant. Republish through the definition lifecycle. A published supplied-only grant must never authorize provider spend. The product registry and start routes remain with ENG-2275 and ENG-2276. The bundle uses the existing definition lifecycle. It does not install itself into a tenant.

The core accepts canonical company, domain, company LinkedIn and company search-row references. A missing or inaccessible source Run fails the request with the same non-disclosing stale_reference error. Check every source Run before any company cache read. A missing row in an accessible Run produces an item diagnostic. Read only frozen company.search output. Do not accept person rows or client display fields.

A valid domain or organization LinkedIn URL remains selectable when Intelligence has no match. A canonical UUID with no record produces not_found. A record with no usable domain or LinkedIn URL produces unresolved. Use shared normalization. Preserve /company/, /school/ and /showcase/ as distinct organization identities. If a cache observation has keys that identify different canonical records, exclude it with identity_conflict. Never merge two canonical UUIDs through a shared domain or LinkedIn key.

Merge observations before filters. Domain is primary; LinkedIn connects a domainless observation only when the identity is unambiguous. Retain first-input order and all duplicate input indexes. Use the first nonempty display value in that order. Evaluate all observed filter values: any explicit contradiction excludes the group. Missing values alone do not exclude it. Use OR within each filter list and AND between filter fields. Compare industry names without case differences. The supplied-only path accepts bounded industry names. Provider requests validate names against the versioned Explorium vocabulary. Compare names without case differences. Do not guess aliases or request live autocomplete during validation. Use ISO alpha-2 country codes and the published Explorium employee and revenue band tokens. Never infer a band from an exact count, revenue amount or currency. An absent or unrecognized observed band is unknown. Emit one filter_mismatch or unknown_filter_value diagnostic per group, using its first input index.

Return the first target_count retained groups. Do not refill, score or enrich them. The funnel counts input observations, merged duplicates, unresolved observations, excluded groups and returned groups. The target can omit valid groups, so the funnel fields are not a partition of the input count. An unknown filter value does not make a result partial. Item identity failures do. Any item identity failure makes the outcome partial, even when no item remains after filters. Otherwise, if no group survives the filters, return empty and no_matches.

Derive each result UUID from the current Run UUID and normalized identity. Replay preserves it; a new Run changes it. The workflow stores its capability envelope under RunResult.output.company_search. The supplied-only lane has zero provider calls and zero settled cost. It creates no usage charge. Keep at most 100 diagnostics and report the exact omitted count.

Reserve space for the Run result envelope below the existing 32,768-byte limit. Remove optional evidence from the last row first, then optional display fields in stable order. Report output_truncated and partial when this removes data. Keep every selected identity and input index intact. If the required fields still exceed the limit, fail with payload_too_large. Never return a malformed or silently cut result.

Explorium execution

The adapter calls POST /v1/businesses in full mode. It makes no match or enrichment call. Use numbered pages with size = target_count and fixed page_size = min(20, target_count). Start at page one. Do not mix page and cursor pagination or change the page size during collection. Collect at most target_count raw provider rows before merging and gates. This makes the cost predictable. This collection can return fewer companies after duplicates or contradictions. Do not fetch replacement rows. Supplied observations come first in input order. Provider observations follow in page and row order, regardless of source selection order. Provider-only rows have empty input_indexes; provider diagnostics use a null item_index. Validate all saved Run references before the first provider request. Read only exact public company identities from the cache.

Map domain and linkedin_profile to shared identities. A provider ID is evidence, never a canonical ID. Map number_of_employees_range and yearly_revenue_range directly to band tokens. Map an exact ISO country name to its alpha-2 code. Unrecognized country names remain unknown. Read industry only when the response supplies a LinkedIn industry field. Never copy the requested industry into observed facts. A name-only row is unresolved. Invalid optional facts remain absent. Valid rows survive malformed sibling rows.

Use one request at a time. The lane permits five pages and ten attempts, with at most 100 rows per page and 500 candidates. The smaller raw collection limit above applies first. Each attempt has a ten-second deadline. The lane has a 60-second deadline, reduced by the root deadline. Stop provider calls 20 seconds before that deadline. Reserve five seconds each for the last meter write, final meter read, cache reads and output. Each provider attempt has an absolute ten-second deadline. Do not start another attempt after the provider deadline. Check the canonical root meter before each attempt. Require enough remaining budget for the maximum page charge. The Company Search runtime uses four US cents per credit and one credit per fetched business. This estimate is below the current public Lite price. Verify account pricing before enabling this executor. The workflow default is 400 cents. A smaller root budget can stop collection before the first page. Account-specific pricing and licensed access remain deployment prerequisites.

Request credit-usage: true. Record one canonical usage row for each attempt that returns control, including uncertain timeout charges. Use reported nonnegative credit usage when valid. Otherwise record the maximum page estimate; do not report zero for an uncertain call. The output reports estimated cents from the canonical root meter. It does not create another usage charge. A meter read or write failure fails the Run. Do not continue provider work when spend cannot be counted. Do not retry provider requests in V1. This avoids duplicate spend after a lost response and stays within the retry ceilings. Meter persistence retries remain owned by the canonical meter. A cancellation still propagates to the runtime. The no-retry rule applies within one tool execution. Completed durable steps replay their stored result. An interrupted step can repeat a paid call. A process stop before meter persistence can leave an unrecorded charge. This adapter has no provider idempotency key or durable attempt recovery. It does not promise exactly-once provider spend. The runtime bounds interrupted step retries. Operators reconcile uncertain charges through the provider account records.

An empty successful page stops collection and retains observations from earlier pages. Return empty only when no merged group survives and no identity failure, source failure or bound occurred. A failed provider request with no usable identity fails the Run, even if the supplied source was requested. A failed request with usable supplied or provider identities returns partial; gates can reduce those identities to zero output rows. A page, deadline or budget stop returns partial with scope_truncated, deadline_reached or budget_reached. Those stops can return zero rows and must not claim no_matches. per_source contains only requested sources. Identity failures make the affected source partial. A provider transport or malformed-page failure is failed before any good page, and partial after a good page. Preserve the shared diagnostic and byte limits. A provider error never exposes a response body, API key or cursor.

Provider details: fetch schema, industry vocabulary, credit reporting, and credit prices.

InputContract
sourcesRequired unique list from supplied, explorium
company_scopeExactly {companies: [CompanyRef]} or {company_search: <Company Search input>}; optional when sources is exactly ["supplied"]
people1 to 100 PersonRefs when supplied is selected; absent otherwise
personaRequired object with at least one supported criterion
target_per_companyDefault 5; range 1 to 10; total output at most 100; the supplied-only lane ignores it

Known scope contains 1 to 20 companies. More than 20 explicit companies fails validation. A company-search brief can return more. Keep its first 20 deduplicated refs in result order and report scope_truncated. The scope child uses company.search without enrichment. Zero resolved companies starts no people source. A complete zero scope returns empty. A partial zero scope returns partial, without a no_matches claim. The child mapping is published with the parent definition. The Front Door still makes one delegation.

Persona fields are titles, departments, seniority and country_codes, each a bounded list. Explorium maps these to job_title, job_department, job_level and the person's country_code. Resolve provider company IDs before querying people; ambiguous company matches remain unresolved. Employer country does not substitute for person country. Related-title expansion is off in V1. See Explorium prospect filters.

People need a LinkedIn identity even when a provider supplies its own ID. Apply company and persona gates to observed fields. Keep unknown values with explicit diagnostics; never claim verified fit. Report companies that return no people. Do not let the first company consume the total result allowance. Fill the output in company-scope order, one person for each company in each pass, and stop at 100 rows. Web sources, signals-store sources, email lookup and profile enrichment are outside direct People Search V1. No unofficial LinkedIn scraping adapter is part of this design.

Company-brief resolution

ENG-2293 owns this composition. The request uses the full CompanyDiscoveryInput contract for company_search. It accepts supplied companies, Explorium filters, or both. A free-text string is not a company-search input. Reject both scope forms, an empty scope object, explicit null, and unknown fields before child work. Check supplied person source-Run access before company work, including requests whose company scope later resolves to zero. Use the existing non-disclosing stale_reference refusal. This check reads no person cache facts. Keep the supplied-only tool and its grant unchanged. A composed definition requires all tools in its published graph, including company discovery. Known refs and an omitted supplied-only scope skip the company child, but do not reduce that definition's required grants.

Resolve one active tenant company.search V1 binding when publishing the composition. Reject missing, disabled, foreign, ambiguous or incompatible bindings before creating a draft. Publish the child definition ID in native subworkflow nodes directly under the People Search workflow. The input DSL maps named fields and cannot forward a whole object or omit a missing reference. Use resolve-company-brief for supplied or mixed company sources, and discover-company-brief for Explorium only. The source branch selects exactly one node. The Explorium-only node omits companies; both forward normalized filters and target count. Do not add a wrapper workflow, custom child dispatcher or new retry loop. Branch nodes produce no shared output, and references cannot select an absent branch output. Each branch therefore validates the request and checks person source-Run access. The brief branch then runs the company child. The known branch starts no child. A common people.search_scoped tool reads that completed child from durable storage only for a brief. Match the trusted tenant, parent Run, source-selected fixed child node and frozen company capability attribution. Use the runtime's hashed workflow-step delivery key. A missing or incomplete child fails closed; the reader never starts work. This explicit metered tool shares the people provider implementation. Its grant does not widen either existing search grant. It skips discovery for zero scope and emits the common People Search envelope for every branch. The Run snapshot freezes the child tree. An upgrade changes only new Runs. The runtime owns child admission, parent/root lineage, cancellation, shared budget and the stable parent-Run/node idempotency key. A failed or cancelled child stops the parent. A waiting child must finish before any people source starts. A succeeded child with outcome: partial supplies its usable scope; it is not a failed Run.

Validate the complete company_search envelope before mapping refs. Missing, malformed or oversized output fails closed. Keep canonical company refs when canonical_id exists; otherwise keep the normalized identity ref from the child row. Deduplicate canonical IDs and normalized identities in child result order before applying the 20-company ceiling. Reject duplicate result IDs and conflicting canonical IDs for one normalized identity. Do not guess an identity from display fields. Keep the first 20 distinct refs. Add one scope-level scope_truncated diagnostic when more refs exist. Do not refill the scope, repeat the company search or enrich companies. Every selected people source receives the same bounded refs. The supplied lane still reports unknown employer identity when only the cached employer name exists.

A resolved empty scope skips supplied reads and provider discovery. This differs from an omitted supplied-only scope. Preserve a partial child outcome, including when it has no rows. A complete zero scope yields an empty people envelope. Keep people funnel, per_source and per_company counts about people only. Skipped sources have zero candidates. Copy child diagnostic reasons with item_index: null; a company index must never identify a person. Preserve omitted diagnostic counts and reserve space for the scope truncation reason within the 100-diagnostic limit. Read cumulative cost once from the canonical root meter after composition, including empty and supplied-only branches. Company and provider people outputs already report root cost; never add their cost totals. The root total includes earlier parent or sibling work. A nonzero total is conservatively estimated. Add the child and people local provider_calls once. This output calculation never writes the meter again. A failed final meter read fails the result. The normal root meter remains the budget authority. Apply the existing 31 KiB result bound after combining scope diagnostics and people output. Trim optional evidence and display fields only. If required fields do not fit, fail without dropping identities.

Supplied-list core

The people.search_supplied tool accepts exactly sources: ["supplied"] and remains unmetered. The provider-capable workflow uses a separate people.search tool and grant. Republish through the definition lifecycle. ENG-2291 adds people.search. ENG-2293 adds people.search_scoped, which reuses its source execution after company-scope resolution. Contract version 1 does not change, and no client migrates. A published supplied-only grant must never authorize provider spend. The product registry and start routes remain with ENG-2275 and ENG-2276. The bundle uses the existing definition lifecycle. It does not install itself into a tenant.

The core accepts canonical person, person LinkedIn and people search-row references. It refuses the crm_person tag, because that tag belongs to the People Enrich adapter. The supplied-only tool always refuses the company_search form. The composed workflow resolves that form before source execution. A missing or inaccessible source Run fails the request with the same non-disclosing stale_reference error. Check every source Run before any person cache read. A missing row in an accessible Run produces an item diagnostic. Read only frozen people.search output. Do not accept company rows or client display fields.

People Search display holds the People Enrich profile fields without the email family. It holds full_name, current_title, current_company_text, location, country_code, avatar_url, summary, twitter_url and personal_website. It also holds three discovery-only fields: industry, skills and languages. People Enrich accepts identities, not queries, so only search supplies them. skills and languages hold at most 20 strings each. experience_history, education_history and certifications stay outside the V1 display. The contract closes every object, and those records have no canonical closed schema. The cache keeps them, and the existing person reads still return them.

A valid individual LinkedIn profile URL remains selectable when Intelligence has no match. A canonical UUID with no record produces not_found. A record with no usable LinkedIn URL produces unresolved. Use shared normalization. The /in/ profile path is the only V1 person identity. An organization LinkedIn URL and a name with an employer are unresolved, not a person identity. If a cache observation has keys that identify different canonical records, exclude it with identity_conflict. Never merge two canonical UUIDs through a shared LinkedIn key.

Merge observations before gates. Retain first-input order and all duplicate input indexes. Use the first nonempty display value in that order. Keep at most three evidence records for each group. Evaluate all observed company and persona values: any explicit contradiction excludes the group. Missing values alone do not exclude it.

The company gate compares the observed employer identity with the refs in company_scope. A resolved employer outside that scope excludes the group with filter_mismatch. An absent or unresolved employer stays unknown and does not exclude the group. company_scope holds references, not an enum vocabulary, so unknown_filter_value never applies to it. The people cache stores an employer display name, not an employer identity, and a name is never an identity. The supplied lane therefore keeps every employer unknown. ENG-2291 adds the provider company ID this gate resolves.

The persona gate uses OR within each persona list and AND between persona fields. Compare titles, departments and seniority without case differences. Use ISO alpha-2 codes for country_codes. The person's own country decides country_codes. An employer country is never a substitute. An observed persona value outside the supported vocabulary is unknown_filter_value. It stays unknown. The people cache stores a title and a country code. It stores no department and no seniority. The supplied lane therefore keeps those two persona fields unknown. ENG-2291 supplies both from the provider. Emit one filter_mismatch or unknown_filter_value diagnostic for each group, using its first input index.

The supplied-only lane attempts no company. It returns an empty per_company list and ignores target_per_company. Return the first 100 retained groups. Do not refill, score or enrich them. The funnel counts input observations, merged duplicates, unresolved observations, excluded groups and returned groups. per_source reports the supplied source alone. An unknown persona value does not make a result partial. Item identity failures do. Any item identity failure makes the outcome partial, even when no item remains after gates. Otherwise, if no group survives the gates, return empty and no_matches.

Derive each result UUID from the current Run UUID and normalized identity. Replay preserves it; a new Run changes it. The workflow stores its capability envelope under RunResult.output.people_search. The supplied-only lane has zero provider calls and zero settled cost. It creates no usage charge. A failed or timed-out cache read fails the tool. It never reports an empty successful search. Keep at most 100 diagnostics and report the exact omitted count.

Reserve space for the Run result envelope below the existing 32,768-byte limit. Remove optional evidence from the last row first, then optional display fields in stable order. Keep funnel, per_source and per_company complete; they are required result fields. Report output_truncated and partial when this removes data. Keep every selected identity and input index intact. If the required fields still exceed the limit, fail with payload_too_large. Never return a malformed or silently cut result.

Licensed provider execution

ENG-2291 adds people.search for known company refs. ENG-2293 owns the company-brief child step. Keep people.search_supplied and its input unchanged. Publish new workflows with the metered people.search grant. The new tool accepts supplied identities, Explorium, or both. Require known scope only when Explorium is selected. When Explorium is selected, validate department and seniority filters against its published vocabulary before reads or calls. Supplied-only requests keep the existing free-text persona contract. Titles remain exact strings. Disable related-title expansion. Do not add implicit contact-data filters.

Resolve known company refs with the Company Search identity core and its tenant checks. Keep the first ref for each resolved identity. Keep unresolved refs as separate company diagnostics. Check all selected company and person Runs before provider work. Never use a company name as a match key. Send one bounded match batch with at most 20 domain or company LinkedIn identities. Validate each echoed match input. Missing, ambiguous or conflicting matches remain unresolved. If distinct company identities receive one provider business ID, reject those matches with identity_conflict. A provider business ID is only a query key. It never replaces a canonical company or person identity.

Fetch one prospect page per matched company, in scope order. Set page=1 and both size and page_size to target_per_company. This limits V1 to 21 HTTP requests and 200 raw candidates, below the shared Search ceilings. Do not refill after identity checks or gates remove candidates. A requested raw target is a normal stop. Use sequential requests and no automatic retries. This keeps spend and response order predictable. A transport failure affects its company; continue with later companies while time and budget remain. Stop all provider work on an authentication failure or a meter failure.

Use the primary linkedin field for person identity. Do not merge distinct profile URLs through prospect_id or alias arrays. Map only observed public profile fields. Never return email hashes, phone data or raw provider payloads. Use job_department_main, then job_department; use job_level_main, then a singleton job_seniority_level. A multi-value fallback or unsupported token remains unknown. Person country_name can map to an exact ISO country name. An observed employer ID that differs from the queried company excludes that observation's merged group. A missing employer ID remains unknown. Query membership alone does not prove employment.

Merge supplied observations first, then provider observations in company order. Apply all gates after merging. Provider-only rows have no supplied input index. Preserve every supplied index when observations merge. Keep at most three evidence records, with one record for each contributing source before extra cache evidence. Use first-observed display facts. Explicit contradictory facts exclude the merged group. Assign a merged provider group to its first company. Return one row per company per pass, up to its target and 100 total. Append supplied-only groups in input order when room remains. They do not claim a verified company. Report scope_truncated if the total output cap omits retained groups. Each deduplicated company reports raw candidates, returned groups and one reason, including no matches or an unattempted bound. An unresolved company makes the output partial. A successful empty scope starts no prospect fetch. A failed provider call fails the tool only when no usable supplied or provider identity exists. Otherwise return partial data, even if gates exclude every usable identity. Never turn provider failure into no_matches.

Use the canonical root meter before each request and after collection. Record every attempted match and fetch request. Estimate one credit per submitted company match and one credit per requested prospect. Use six US cents per credit. These are admission estimates, not verified account tariffs. Verify match pricing and licensed access before deployment; request credit-usage: true for both endpoints. Use valid reported credits; otherwise record the requested-row estimate. Mark all dollar costs estimated. A charge above the admitted estimate stops provider work. A failed request retains a conservative uncertain charge. A missing key or license returns an explicit provider failure. Account access and pricing require deployment verification. Completed workflow steps replay their stored results. Cancellation propagates after the attempt's usage write. An interrupted step can repeat a paid call or leave an uncertain charge. This adapter does not promise exactly-once spend. The root meter checks accrued cost; it does not reserve funds across concurrent lanes. Keep a 60-second lane deadline, ten-second request timeouts and time for metering, cache reads and the result envelope. Keep the existing 31 KiB product output bound and exact diagnostic omission count.

Provider references: prospect fields and filters, business matching, credit units and credit prices.

Search bounds

Company Search scans at most 500 candidates over five provider pages. People Search scans at most 500 candidates over 20 provider pages and 20 companies. Each provider page holds at most 100 rows. Stop at the first candidate, page, deadline or budget bound. Company Search allows at most ten provider requests; People Search allows at most 40, including matching and retries. Use at most four concurrent provider requests. Each request times out after ten seconds; each search lane has a 60-second deadline. Retries consume the same request, time and cost budgets. A root deadline or cost ceiling can reduce these limits. Return explicit partial diagnostics at a bound. Do not refill after deterministic gates drop rows.

Company Enrich and People Enrich

InputContract
subjectsRequired list of 1 to 100 CompanyRefs or PersonRefs, matching the capability
presetbasic or standard; default basic
fieldsOptional nonempty unique subset of the chosen preset; omitted means the whole preset
refreshmissing or stale; default stale

People Enrich also accepts crm_person refs through its tenant input adapter. Contract version 1 declares the whole PersonRef union, including crm_person and search_result. The profile core resolves intel_person and person_linkedin. The tenant adapter resolves crm_person and search_result under the same version and public schema. Raw CSV, arbitrary CRM/list objects and unresolved name-plus-company inputs are not accepted. An input adapter preserves selection order, verifies ownership and emits only canonical identity inputs. Provider payloads and unverified client display fields cannot overwrite the canonical cache.

People Enrich input adapter

ENG-2301 owns this adapter. It uses the existing durable preparation step and profile component. The request accepts 1 to 100 subjects across all four PersonRef tags. It accepts no list or prospect reference. Read CRM people through scoped_db(organization_id). Read source Runs through agent_db() with an explicit organization filter. A missing or foreign CRM person or source Run refuses the whole request with stale_reference. These cases use the same error text. Never query outside the caller's organization to distinguish them. Complete tenant checks before canonical cache reads and person child dispatch.

Read only id and linkedin_url from CRM people. A CRM person has no canonical Intelligence ID column. Normalize its LinkedIn value with the existing person contract. An absent or invalid identity returns unresolved for that item. Do not use CRM names, email, employment, notes or manual edits as enrichment facts or fallback identities.

A selected row must occur exactly once in a succeeded Run's result.output.people_search.items. The envelope must declare capability_id: people.search, integer contract_version: 1, and at most 100 items. A partial product outcome is valid when the Run succeeded. An unfinished or failed Run supplies no selected identity. Read only the selected row's result_id, identity ref, and nullable canonical_id. Ignore its display, evidence, old input indexes and unrelated rows' display validation. Do not reconstruct an identity from display fields, provider IDs or another capability's output. A missing, duplicate, malformed or wrong-kind selected row returns an item stale_reference diagnostic. A selected row cannot contain another search_result or crm_person reference. Do not follow reference chains.

Resolve canonical UUIDs with bounded cache reads. A missing canonical record or unusable stored profile returns stale_reference. If a selected identity and its canonical UUID disagree, return identity_conflict for the item. Do not fall back to its frozen profile when the canonical record is missing. Carry the canonical UUID as the existing component's expected-row guard. The component rechecks identity before provider work and persistence.

Group resolved subjects by normalized LinkedIn identity. Preserve the first selection position and all input indexes. A group that claims two canonical UUIDs returns identity_conflict for every input in that group. Do not select one UUID. Unresolved inputs keep separate rows and their original positions. They start no provider work. Pass only normalized person identity and the expected canonical UUID to profile execution. The original request contains reference values only and remains available for result index checks.

Deduplicate lookup IDs and query at most 50 UUIDs per request. The whole preparation has a ten-second timeout. There are at most 100 CRM IDs, 100 source Run IDs, and 200 canonical ID claims before conflict checks. These are ceilings, not separate subject allowances. The total subject count remains at most 100. A read failure or timeout refuses the batch with cache_unavailable. Cancellation propagates. Use the existing result byte preflight after grouping. No new endpoint, provider call, CRM write or schema is required. A completed preparation replay reuses the stored identity snapshot. A fresh Run resolves the current tenant rows. A process stop before the preparation checkpoint can repeat reads and observe a later identity. A CRM edit after preparation does not redirect the prepared work. Tenant rows are not read again during a completed-step replay.

CapabilityBasic field setStandard additions
Company Enrichname, website, linkedin_url, industry, country_code, employee_count_exact, employee_count_band, logo_urldescription, sub_industry, business_model, location, founding_year, annual_revenue, revenue_band, revenue_currency, revenue_year, funding_round, funding_amount, funding_currency
People Enrichfull_name, current_title, current_company_text, location, country_code, avatar_urlsummary, twitter_url, personal_website, email, email_source, email_score

These are bounded target sets, not promises of provider coverage. Missing fields remain missing. Company scalar fields are strings, except nonnegative numeric counts, years and monetary amounts. email_score uses the current Intelligence numeric representation. Email source or score requests also require email in fields. Every field in a preset must have a V1 enrichment source. A field only discovery can supply does not belong in a preset. industry, skills, languages, experience_history, education_history and certifications reach a person through People Search. People Enrich accepts identities, not queries, so it cannot fill them. They stay stored, readable and outside both presets. Phone, new technology schemas, arbitrary keywords, custom research questions and a deep preset are deferred.

Cache, freshness and merge

Reuse the current company and people enrichment components. There is one waterfall per subject, shared by all callers. The platform wrapper invokes the existing component by its registered durable function boundary, with typed input and root usage attribution. The company.enrich write permission authorizes the complete operation, including canonical Intelligence persistence. Its policy, approval and journal checks run before dispatch. It does not call a second intelligence.upsert_company tool. CRM changes still require a separate explicit CRM action. The current component imports legacy helpers. Do not import that module into src.agentic or copy its waterfall. ENG-2279 must preserve the import-linter boundary and prove that retries keep one root cost record. Selected company batches invoke one existing company child at a time. Settle its saved usage before starting the next child. An ordinary item failure keeps valid siblings. A meter fault stops further paid children and fails the Run. Checkpoint source eligibility before its first call, so a settings change cannot skip saved provider usage on replay. It does not wrap only the narrow research.enrich_company exact-domain tool.

Current Intelligence stores carry source_ids linked to provider-fetch records and fetched_<tier>_at timestamps. They do not provide a reliable source and filled_at timestamp for every field. Do not invent per-field provenance, freshness or billing data from a row timestamp.

SubjectCurrent tier windowsOperational check
CompanyCold 180 days; warm 30 days; hot 7 days reservedThe company component uses warm currentness
PersonHot 30 days; warm 90 days; cold 365 days reservedProfile uses warm; email uses hot

A missing or future timestamp is stale. Capture evaluated_at once inside a durable read step and reuse it on replay. An exact TTL boundary is stale. Cache freshness does not prove that a missing requested field exists. missing keeps existing values and fills gaps. stale also bypasses stale cache values for the requested target fields. The ordered source waterfall fills remaining gaps. A later source cannot overwrite a value from an earlier accepted source. Each selected field needs its own usable value. A count band does not satisfy an explicitly selected exact count. A selected field can enable a capable source even when the legacy full-profile preset does not target that field. Existing current fields outside the refresh target remain unchanged. No null or failed lookup erases an existing fact. Keep related facts together: count and band; revenue amount, currency, year and band; funding amount, currency and round; address fields; industry and sub-industry. The people groups are location with country and country_code, and email with email_source and email_score. The shared location normalizer always writes country beside country_code, so one observation must supply the whole place. A changed group must come from one compatible observation. Do not attach an old currency or year to a new amount. If the source cannot verify the retained parts, keep the stored group and leave the selected field unavailable. A later source cannot change a group already verified in this attempt. A concurrent group change cannot count as this attempt's refresh.

The wrapper must pass the selected target set to the existing component. It must not fetch unrelated fields just to satisfy a legacy full-profile gate. A source gates on the pending selected fields it can supply. It must not gate on one stored value that a different caller wanted. The people Hunter source currently returns nothing when the person already has an avatar. That guard belongs to the discovery caller. Under a selected request it must run whenever a pending selected field is in its output set, and stay unchanged for the legacy caller. Stale refresh does not guarantee a changed value. Report a field as unavailable when no source provides it. The cache retains old facts when refresh fails, with their original freshness. A failed attempt never makes them current. A partial or selected-field refresh preserves its old tier timestamp when any retained stale field in that tier remains unverified. Advance a tier only when every retained field in that tier is current or successfully refreshed in this attempt. Only a sourced field can hold a stamp back. A sourced field is a stored profile field that a V1 enrichment source can supply. A stored field with no V1 source can never be verified. It must not pin a tier stamp for the life of the record. The company profile uses its existing mergeable profile field set. Email and its metadata use the hot tier. The people warm set is the profile fields of both presets, without the email family. It holds full_name, current_title, current_company_text, location, country_code, avatar_url, summary, twitter_url and personal_website. The people hot set holds email, email_source and email_score. work_phone, mobile_phone, country and the six discovery-only fields are in neither set, so they never hold a stamp back. A sourced field must also be verifiable. country is stored and a source supplies it, but the contract exposes country_code alone, so no result can verify country by itself. In the warm set it could never be verified, and the stamp could never advance again. The shared location normalizer re-derives it on every write instead. The two tiers are decided on their own windows. A record is often warm current and hot stale, so a warm hit never reports a stale email as current. A successful lookup that returns no replacement cannot validate a retained stale value. A profile-only write never refreshes the email hot stamp. A failed email refresh preserves that stamp too. The shared company and people write boundaries accept a preserve-stamp decision. The wrapper must pass this decision through the component before it writes; a later compensating timestamp write is unsafe. No new per-field provenance table is required. Conservative tier stamps can repeat some reads, but cannot hide a failed refresh. Tier freshness remains coarse; the response states this limit rather than claiming field-level verification.

Serialize updates for the same canonical identity, or use the shared store's conflict check and retry with a fresh read. Union provider lineage without losing concurrent source IDs. Recheck identity before each canonical write. A conflict retry rereads identity keys, profile values and timestamps, then rebuilds the selected-field merge. Do not replay a stale full-row payload after only refreshing the source IDs. Persist only normalized, allowed provider facts. Read tenant CRM data for identity resolution, not as global enrichment facts. CRM data changes only through a later explicit CRM action.

Enrichment result rows

Each row contains ref, nullable canonical_id, input_indexes, values, field_states, cache and source_ids. values uses the closed field table above. It contains only requested fields that have usable values. field_states maps every requested field to current, retained, refreshed, not_found, pending, failed or cancelled. current means the cache value is within its tier window. retained means refresh: missing keeps an existing stale value without provider verification. A failed stale refresh can return the old value with failed or not_found; it never reports current or refreshed. cache contains {tier, evaluated_at, fetched_at, decision}. Times are UTC timestamps; fetched_at can be null. decision is hit, miss, stale or not_read; tier is warm for profile results. An unresolved or unstarted row uses not_read, the durable preparation time, and null fetched_at and canonical_id. Email has its own email_cache with tier hot when email is requested. source_ids is the canonical provider-lineage UUID list, bounded by the runtime output limit. If lineage exceeds that limit, return a bounded list and a required source_ids_omitted count. Keep full lineage in Intelligence. Keep selected values and field states intact. Trim only lineage and diagnostics, with exact omitted counts. Deduplicate identities, then check the minimum result size before paid work and between company children. If completed values and required row data cannot fit, stop further children and return payload_too_large. Facts and usage already persisted remain available. Do not truncate identity keys or change a value to fit. The 100-subject limit is also subject to the 31 KiB output limit; a full Basic or Standard batch can exceed it before providers run. An unresolved subject has no canonical UUID and a diagnostic. A failed write must never report refreshed. Rehost selected cached LinkedIn logos when necessary, without a provider charge or newer tier timestamp. If rehosting fails, keep the stored fact. Omit an unusable LinkedIn URL from output and mark the selected logo failed. Keep an older usable logo when a new replacement fails. The people avatar uses the same rules on the same shared rehost path. A failed rehost keeps the stored avatar and marks the selected field failed.

Email verification metadata

When email is requested, each enrichment row also returns email_verification, independently of the selected values fields. It is null while no lookup or cached email result exists. Otherwise it is a closed object:

FieldShape
statusverified, unverified, unknown or not_found
emailThe exact email value this status describes, or null for no email
sourceBounded provider name, or null when unknown
provider_statusBounded normalized provider status, or null
checked_atProvider verification timestamp in UTC, or null when not supplied

An email_score alone never proves verification. An old cache row without verification evidence returns unknown. Only an explicitly verified provider result with matching identity and no role mailbox can return verified. Map catch-all, unverifiable and unverified results to unverified. A completed lookup with no email returns not_found. Pending, failed and cancelled work still use field_states; retained cached metadata does not hide that operation state.

ENG-2299 stores the metadata under existing canonical extra.email_verification and writes it atomically with the matching email value. An email replacement invalidates metadata for the previous value. Readers return unknown if the metadata does not match the current email. This is provider verification evidence, not a new per-field freshness model or a guessed verification date. The final Run freezes this metadata with its email value. Reconnect renders that pair without reading raw provider jobs. Current canonical reads use their own matching pair and remain separate from the frozen result.

Asynchronous email

People Enrich requests FullEnrich only for requested email fields that are missing or stale. The profile step writes canonical results first. Clients can read those results while the email child waits. The root remains live until that child reaches a terminal state. Profile readiness is not root completion. The final result is immutable; later UI reads of canonical Intelligence do not rewrite it.

The native email child stores the complete profile output in its immutable input.profile. A product client reads that child through the Run list and detail routes while the root is live. The client accepts the preview only when the root has exactly one direct workflow child. The child must name the same root and parent, use workflow_step as its source and carry only profile in its input. The client validates that profile against the root's frozen request before display. It does not read span payloads, poll provider jobs or call the superadmin Intelligence API. The root's valid final people_enrich output replaces the preview.

The root invokes one email child after the profile tool completes. Signals Search can therefore use the three-level workflow limit. The child processes up to four sequential batches of at most 25 deduplicated subjects. Empty batches make no provider call. Each batch submits through fullenrich.submit, waits on the platform event, and reads through fullenrich.collect. Do not add another workflow level, concurrent provider waits, vendor polling, or a second provider-job store. A job wait lasts at most 600 seconds. The root budget must cover profile work, four waits, and persistence overhead.

Use canonical person facts to build the provider identity. Require a canonical ID, matching LinkedIn URL, and a usable first and last name. Split the canonical full name at its first space. Do not guess a missing name or copy tenant CRM facts into the global store. An unsupported identity fails only that subject with unresolved. Email-only requests do not start an unrelated profile lookup. Each contact ref is the canonical person UUID. Match only that exact echoed ref, never array order, name, or a vendor ID. The job must belong to the current organization, email child, root Run and submitted contact set before a canonical write. Duplicate, missing, unknown and malformed refs produce bounded diagnostics. Valid sibling contacts remain usable.

email_job is null when no job exists. Otherwise it contains job_id, child_run_id and root_run_id. The profile result marks selected email targets pending. The child's submit output and Run lineage identify the job while it waits. The final row includes that lineage and a terminal field state. refreshed means a found email was persisted. A completed lookup with no email uses not_found. Invalid or missing contact results use failed. Found results from a failed or cancelled provider batch can survive; contacts without usable results use that batch's terminal state. A missing provider score stays missing. It cannot inherit the score of a replaced email.

The durable provider-job row is the completion truth. The runtime reads it before waiting and again on timeout. An event in the registration gap can delay completion until the second read. The wait never becomes unbounded. ENG-2300 establishes the signed callback contract. ENG-2210 reserves the job before submission and bounds stored results. Those dependencies are complete. ENG-2299 uses their behavior and does not repeat their infrastructure work. Freeze the first durable collect result after the wait. A running result records timeout and stays unchanged on write retries. A later callback can settle cost but cannot reopen that batch for canonical writes.

Persist the email family and matching verification metadata in one conflict-checked write. Preserve unrelated profile facts and the warm stamp. Recheck canonical identity and live Run authority before each write attempt. Preserve a concurrent email-group change and report identity_conflict. Use one stable source ID per job and contact. Replay preserves source lineage, facts and freshness without a second provider submission. A storage fault retries the canonical write from stored provider evidence. It never restarts the profile workflow or resubmits a batch. A failed lookup retains the old email, matching metadata and hot stamp. Its operation state still reports the failure or absence. No-email evidence describes null only when no cached email remains. A score alone does not establish verification.

Cancellation stops further calls and canonical writes. The runtime can terminate before a final result exists. When the email child exists, its immutable input keeps the profile output readable. Cancellation before child creation exposes no profile snapshot, so clients do not guess one from canonical data. Consumers derive cancelled email state from the terminal root Run, as ENG-2289 specifies. A late callback cannot resume a cancelled Run. Provider settlement can still record a real charge once. Read the root usage meter for the final result. Provider accounting remains owned by the existing settlement function. Check byte bounds before paid work where overflow is certain. Never trim selected facts, verification data or job lineage to fit a result.

Signals Search composition

signals.search keeps the three root sources: discovery, company_set and saved_search. Its schema uses SearchSourceInput, the scoring brief, shared PersonaCriteria and bounded company reference definitions. ENG-2324 owns the shared persona and legacy correction contract. The Signals Search page owns those fields and its existing output projection. Do not apply the Company/People result envelope to existing Signals clients. Capability identity and version still appear on the Run and telemetry.

Published child nodes store resolved executor UUIDs. No capability DSL node or live registry lookup is added to workflow execution. The company and people lanes compose the four capabilities with parent/child lineage and shared root budgets. Signals web research can discover candidates, then pass them to the supplied search source for normalization and gates. This preserves thesis and signal discovery without adding web sources to direct Company or People Search. Company/person enrichment is explicit in the published Signals graph and requests only its needed fields. Signals scoring, persona fit, saved-search diffs, smart-feed publication, prospect promotion and stream semantics remain in place. Signals does not start CRM promotion or outreach without the existing explicit product action.

The company lane contract defines the exact ENG-2282 handoff. Research keeps canonical identity and signal writes. Company Enrich replaces its private profile provider calls. Empty-input wrappers skip capability starts without weakening direct Search or Enrich input validation.

Implementation owners

Contract areaTickets
Binding, registry, initial install, upgradesENG-2287, ENG-2275, ENG-2277, ENG-2303
Start/read API and CLI, telemetryENG-2276, ENG-2286, ENG-2298
Company core, provider, enrichmentENG-2278, ENG-2295, ENG-2279
People core, provider, scope, enrichment adaptersENG-2280, ENG-2291, ENG-2293, ENG-2281, ENG-2301
Email live gate, tools, child workflowENG-2300, ENG-2210, ENG-2299
Signals persona alignmentENG-2324
Signals company and people compositionENG-2282, ENG-2302
Front Door and direct surfacesENG-2283, ENG-2284, ENG-2288, ENG-2289, ENG-2290, ENG-2292, ENG-2294
API/CLI, Front Door and browser exitsENG-2285, ENG-2297, ENG-2296