Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .agents/skills/add-enrichment/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Because enrichments run on Sim's hosted keys by default, **every provider tool y

## Architecture (what you're plugging into)

- **`enrichments/types.ts`** — `EnrichmentConfig { id, name, description, icon, inputs, outputs, providers }` and `EnrichmentProvider { id, label, toolId, buildParams, projectFailure, mapOutput }` — `toolProvider` fills `projectFailure` with the standard HTTP projection; override it only for a provider whose failure shape is nonstandard (see `enrichments/provider-failures/`). Providers are **plain data** (no `@/tools` import) so the catalog stays client-safe.
- **`enrichments/types.ts`** — `EnrichmentConfig { id, name, description, icon, inputs, outputs, providers }` and `EnrichmentProvider { id, label, toolId, buildParams, projectFailure, mapOutput, isVerified? }` — `toolProvider` fills `projectFailure` with the standard HTTP projection; override it only for a provider whose failure shape is nonstandard (see `enrichments/provider-failures/`). Providers are **plain data** (no `@/tools` import) so the catalog stays client-safe.
- **`enrichments/providers.ts`** — `toolProvider(...)` (typed passthrough) plus shared input helpers: `str(v)`, `normalizeDomain(v)`, `firstNonEmpty(arr)`, `splitName(fullName)`, and `projectEnrichmentProviderFailure`.
- **`enrichments/run.ts`** — the server-only cascade runner. Calls `executeTool(provider.toolId, { ...params, _context: { workspaceId, userId } })`, accumulates hosted-key cost, returns the first non-empty mapped result. **You do not edit this** — it works for any registry entry.
- **`enrichments/registry.ts`** — `ENRICHMENT_REGISTRY` / `ALL_ENRICHMENTS` / `getEnrichment`. Register new entries here.
Expand All @@ -37,7 +37,9 @@ For each output the enrichment produces, decide which existing tool provides it.
- Its `params` accept what you can derive from table columns (read the tool's `params`).
- Its `outputs` / `transformResponse` actually expose the field you need (read the real output shape — don't assume).

Order providers **cheapest / most-likely-to-hit first**; the cascade stops at the first non-empty result. Apollo and LinkedIn APIs are not hosted-safe (ToS) — never call them as providers.
Order providers by **accuracy first, then price per hit**. Order decides which answer wins when two providers would disagree, so a cheaper but less accurate provider ahead of a better one trades quality for cost; reorder for price only with benchmark evidence. Among similarly accurate providers, put the ones that bill on a miss or charge a high price per hit after the cheaper, free-on-miss ones (check each vendor's billing docs and the tool's `hosting.pricing`); never move a more accurate provider behind a less accurate one to save cost. The cascade stops at the first non-empty, verified result. Apollo and LinkedIn APIs are not hosted-safe (ToS) — never call them as providers.

Prefer a vendor's own verified-only option (e.g. Prospeo `only_verified_email`) when it turns an unverified result into a free miss. Otherwise, when a provider can return an unconfirmed hit (a catch-all guess, a `risky` status), declare `isVerified(output)`: an unconfirmed hit is held while later providers look for a confirmed one, and fills the cell only if none does. `isVerified: false` marks a provider that never confirms its hits (a database record); it runs only while nothing is held.

## Step 2: Verify hosted-key support — chain to `/add-hosted-key` if missing

Expand Down Expand Up @@ -136,7 +138,8 @@ export const ENRICHMENT_REGISTRY: EnrichmentRegistry = {

- [ ] Each output mapped to a real tool field (verified against the tool's `params`/`outputs`)
- [ ] **Every provider tool has a `hosting` block — ran `/add-hosted-key` for any that didn't**
- [ ] Providers ordered cheapest / most-likely-first; Apollo/LinkedIn not used
- [ ] Providers ordered accuracy-first, then price per hit (billing on a miss checked against vendor docs); Apollo/LinkedIn not used
- [ ] Providers that can return an unconfirmed hit declare `isVerified`
- [ ] Enrichment file is client-safe (no `@/tools` import); uses `toolProvider` + shared helpers
- [ ] `buildParams` returns `null` on insufficient inputs; `mapOutput` returns `null` on a miss
- [ ] Registered in `enrichments/registry.ts`
Expand Down
27 changes: 26 additions & 1 deletion apps/sim/background/enrichment-capability-subject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import {
billingUsageGateCacheMock,
billingUsageGateCacheMockFns,
} from '@sim/testing/mocks/billing-usage-gate-cache.mock'
import {
billingUsageLogMock,
billingUsageLogMockFns,
} from '@sim/testing/mocks/billing-usage-log.mock'
import { executeWorkflowMock } from '@sim/testing/mocks/execute-workflow.mock'
import { networkConfigMock, networkConfigMockFns } from '@sim/testing/mocks/network-config.mock'
import { tableEventsMock } from '@sim/testing/mocks/table-events.mock'
Expand Down Expand Up @@ -60,6 +64,7 @@ vi.mock('@/enrichments/run', () => ({
}))
vi.mock('@/lib/billing/core/billing-attribution', () => billingAttributionMock)
vi.mock('@/lib/billing/core/usage-gate-cache', () => billingUsageGateCacheMock)
vi.mock('@/lib/billing/core/usage-log', () => billingUsageLogMock)
vi.mock('@/lib/table/rows/secret-provenance', () => tableRowsSecretProvenanceMock)
vi.mock('@/executor/utils/resolved-secret-trace-registry', () => ({
ResolvedSecretTraceRegistry: class {
Expand Down Expand Up @@ -112,7 +117,7 @@ const GROUP = {
type: 'enrichment' as const,
enrichmentId: 'company-lookup',
workflowId: '',
outputs: [{ columnName: 'col-out', blockId: '', path: '' }],
outputs: [{ columnName: 'col-out', blockId: '', path: '', outputId: 'email' }],
inputMappings: [{ columnName: 'col-in', inputName: 'domain' }],
}

Expand Down Expand Up @@ -288,4 +293,24 @@ describe('enrichment cell capability subject', () => {
await runRowCascadeLoop(payload(null, 'legacy-trigger-user') as never)
expect(gatedUserId()).toBeNull()
})

/** A completed cell is final on the auto path, so an outage must not complete blank. */
it('marks the cell errored when every provider errored', async () => {
mocks.runEnrichment.mockResolvedValue({
result: {},
cost: 0.02,
error: 'Rate limit exceeded',
detail: { providers: [] },
})

await runRowCascadeLoop(payload('acting-user', 'acting-user') as never)

const terminal = mocks.writeWorkflowGroupState.mock.calls.at(-1) as unknown[]
const write = terminal[1] as { executionState: Record<string, unknown>; dataPatch?: unknown }
const state = write.executionState
expect(state.status).toBe('error')
expect(state.error).toBe('Rate limit exceeded')
expect(write.dataPatch).toEqual({ 'col-out': '' })
expect(billingUsageLogMockFns.mockRecordUsage).toHaveBeenCalledTimes(1)
})
})
57 changes: 31 additions & 26 deletions apps/sim/background/workflow-column-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,27 +681,29 @@ async function runWorkflowAndWriteTerminal(
await enrichmentRegistry.importCrossingProvenance(inputProvenance, enrichInputs, {
trusted: true,
})
const { result, cost, detail } = await withResourceOutboundScope({ workspaceId }, () =>
runEnrichment(enrichment, enrichInputs, {
tableId,
rowId,
workspaceId,
/**
* The person who asked, not who pays. `triggeredByUserId` is an
* attribution: for a workspace-API-key run it names the workspace's
* billing owner, and running that bystander's tool denylist against
* an actorless request is wrong in both directions — it fails cells
* nobody meant to govern, and it skips the denylist for the person
* who actually triggered one. The governed subject is carried
* separately from the dispatch. `null` means no per-tool gate
* applies, which is the documented behavior for an actorless run —
* stated, because the field is required precisely so it cannot be
* skipped by omission.
*/
userId: payload.capabilityGovernedUserId ?? null,
signal: attemptSignal,
resolvedSecretTraceRegistry: enrichmentRegistry,
})
const { result, cost, error, detail } = await withResourceOutboundScope(
{ workspaceId },
() =>
runEnrichment(enrichment, enrichInputs, {
tableId,
rowId,
workspaceId,
/**
* The person who asked, not who pays. `triggeredByUserId` is an
* attribution: for a workspace-API-key run it names the workspace's
* billing owner, and running that bystander's tool denylist against
* an actorless request is wrong in both directions — it fails cells
* nobody meant to govern, and it skips the denylist for the person
* who actually triggered one. The governed subject is carried
* separately from the dispatch. `null` means no per-tool gate
* applies, which is the documented behavior for an actorless run —
* stated, because the field is required precisely so it cannot be
* skipped by omission.
*/
userId: payload.capabilityGovernedUserId ?? null,
signal: attemptSignal,
resolvedSecretTraceRegistry: enrichmentRegistry,
})
)

// An abort during the cascade must not be recorded as a completed cell.
Expand All @@ -720,7 +722,7 @@ async function runWorkflowAndWriteTerminal(

/**
* Record the triggerer or system fallback as actor while charging the
* exact workspace payer. Billing failures do not fail a successful cell.
* exact workspace payer. Billing failures do not fail the cell.
*/
if (cost > 0) {
try {
Expand Down Expand Up @@ -753,28 +755,31 @@ async function runWorkflowAndWriteTerminal(

// Write every output column: the result value when present, else clear
// it. A partial/empty result must blank the columns it didn't fill so a
// re-run that finds less than before doesn't leave stale values.
// re-run that finds less than before doesn't leave stale values, and a
// failed run must not leave old values for dependent groups to consume.
const dataPatch: RowData = {}
for (const out of group.outputs) {
if (!out.outputId) continue
const value = result[out.outputId]
dataPatch[out.columnName] =
value === undefined || value === null ? '' : (value as RowData[string])
}
// Every provider that ran errored (rate limit, missing hosted key, auth):
// `error` rather than `completed`, so a manual re-run picks the cell up.
await writeState(
{
status: 'completed',
status: error ? 'error' : 'completed',
executionId,
jobId: null,
workflowId: statusId,
error: null,
error,
enrichmentDetails: detail,
},
dataPatch,
undefined,
createTableRowSecretProvenanceFromRegistry(dataPatch, enrichmentRegistry)
)
return 'completed'
return error ? 'error' : 'completed'
} catch (err) {
await writeState({
status: 'error',
Expand Down
41 changes: 20 additions & 21 deletions apps/sim/enrichments/email-verification/email-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,26 @@ import { ShieldCheck } from '@sim/emcn/icons'
import { str, toolProvider } from '@/enrichments/providers'
import type { EnrichmentConfig } from '@/enrichments/types'

/**
* Verdicts that say nothing about the mailbox; the cascade falls through to the
* next verifier on these. MillionVerifier reports `unverified` (and may report
* `error`) when it could not complete a check.
*/
const INCONCLUSIVE_STATUSES = new Set(['unknown', 'unverified', 'error'])

function mapVerdict(output: Record<string, unknown>) {
const status = str(output.status)
if (!status || INCONCLUSIVE_STATUSES.has(status)) return null
return { status, deliverable: output.deliverable === true }
}

/**
* Email Verification enrichment. Checks an email address's deliverability via a
* verifier waterfall — ZeroBounce first (highest coverage), then NeverBounce,
* then MillionVerifier, then Enrow. A provider that returns a
* definitive verdict (valid / invalid / catch_all / disposable / etc.) fills the
* cell; a provider that can only return `unknown` falls through to the next so
* the row gets the most confident answer available. All providers support hosted
* keys.
* then MillionVerifier, then Enrow. A provider that returns a definitive verdict
* (valid / invalid / catch_all / disposable / etc.) fills the cell; an
* inconclusive one falls through to the next so the row gets the most confident
* answer available. All providers support hosted keys.
*/
export const emailVerificationEnrichment: EnrichmentConfig = {
id: 'email-verification',
Expand All @@ -31,12 +43,7 @@ export const emailVerificationEnrichment: EnrichmentConfig = {
if (!email) return null
return { email }
},
mapOutput: (output) => {
const status = str(output.status)
// Fall through to the next verifier when the verdict is missing or inconclusive.
if (!status || status === 'unknown') return null
return { status, deliverable: output.deliverable === true }
},
mapOutput: mapVerdict,
}),
toolProvider({
id: 'neverbounce',
Expand All @@ -47,11 +54,7 @@ export const emailVerificationEnrichment: EnrichmentConfig = {
if (!email) return null
return { email }
},
mapOutput: (output) => {
const status = str(output.status)
if (!status || status === 'unknown') return null
return { status, deliverable: output.deliverable === true }
},
mapOutput: mapVerdict,
}),
toolProvider({
id: 'millionverifier',
Expand All @@ -62,11 +65,7 @@ export const emailVerificationEnrichment: EnrichmentConfig = {
if (!email) return null
return { email }
},
mapOutput: (output) => {
const status = str(output.status)
if (!status || status === 'unknown') return null
return { status, deliverable: output.deliverable === true }
},
mapOutput: mapVerdict,
}),
toolProvider({
id: 'enrow',
Expand Down
22 changes: 14 additions & 8 deletions apps/sim/enrichments/phone-number/phone-number.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import type { EnrichmentConfig } from '@/enrichments/types'
/**
* Phone Number enrichment. Finds a contact's phone number from a full name plus
* any available identifiers (company domain, LinkedIn URL) via a waterfall:
* People Data Labs (name match) → Wiza reveal → Findymail (LinkedIn) → Prospeo
* mobile → LeadMagic (LinkedIn) → Datagma (LinkedIn). Each provider
* opportunistically uses whatever identifiers the row provides and self-skips
* when it has none usable, so adding more inputs widens coverage without
* reordering. First phone wins; all providers support hosted keys.
* People Data Labs (name + company or LinkedIn) → Wiza reveal → Findymail
* (LinkedIn) → Prospeo mobile → LeadMagic (LinkedIn) → Datagma (LinkedIn). Each
* provider opportunistically uses whatever identifiers the row provides and
* self-skips when it has none usable, so adding more inputs widens coverage
* without reordering. First phone wins; all providers support hosted keys.
*/
export const phoneNumberEnrichment: EnrichmentConfig = {
id: 'phone-number',
Expand All @@ -31,19 +31,23 @@ export const phoneNumberEnrichment: EnrichmentConfig = {
toolId: 'pdl_person_enrich',
buildParams: (inputs) => {
const name = str(inputs.fullName)
if (!name) return null
const company = normalizeDomain(inputs.companyDomain)
const linkedin = str(inputs.linkedinUrl)
// A bare name is not a valid PDL match and scores below min_likelihood 6.
if (!name || !(company || linkedin)) return null
// `required` makes PDL 404 (free) when the profile has no phone,
// instead of charging a credit for a match we'd discard as a no-match.
return filterUndefined({
name,
company: normalizeDomain(inputs.companyDomain) || undefined,
company: company || undefined,
profile: linkedin || undefined,
min_likelihood: 6,
required: 'phone_numbers OR mobile_phone',
})
},
mapOutput: (output) => {
const person = output.person as Record<string, unknown> | undefined
const phone = firstNonEmpty(person?.phone_numbers) ?? str(person?.mobile_phone)
const phone = str(person?.mobile_phone) || firstNonEmpty(person?.phone_numbers)
return phone ? { phone } : null
},
}),
Expand Down Expand Up @@ -102,6 +106,8 @@ export const phoneNumberEnrichment: EnrichmentConfig = {
full_name: fullName || undefined,
company_website: companyWebsite || undefined,
enrich_mobile: true,
// An unverified mobile becomes a free NO_MATCH instead of a billed hit.
only_verified_mobile: true,
})
},
projectFailure: projectProspeoEnrichmentFailure,
Expand Down
Loading
Loading