fix(harmonic): correct the destructive-clear copy and stop double-billing enrichment (#6915)

* fix(harmonic): correct the destructive-clear copy and stop double-billing enrichment

Follow-up to #6902, from a final validation pass against Harmonic's OpenAPI and
API reference. No endpoint, method, or response mapping changed.

- The `personUrns` field told users and the LLM that Clear Net-New Results
  "clears everything when omitted". That is the raw provider behavior the
  clearScope guard was added to block; omitting it now throws. The field is
  shared across three operations, so the wrong sentence was being served as
  guidance on all of them.
- Bulk email enrichment deduplicated LinkedIn URLs before canonicalising them,
  so `.../in/foo?utm_source=x` and `.../in/foo` were submitted as two people.
  Harmonic bills per submitted entry, so this spent quota twice and
  double-counted against the 5,000 cap. Deduplicate after canonicalising.
- The two documented bulk-enrichment failures carry a code in `error` and no
  message anywhere, so quota exhaustion surfaced as "Request failed with status
  429". Render the code with its counters instead. Gated on those counters being
  present: `extractErrorMessage` without an explicit id walks every extractor in
  order, and claiming a bare `error` key swallowed OAuth's `error_description`.
- An enrichment 404 whose detail carries only the URN no longer discards it.
- Report the identifier conflict before complaining about an individual URL.
- Validate `companyContextUrns` as company URNs, like every other URN param.

Forward-compat: `user_saved_search_type` is passed through rather than checked
against a fixed set. It is display metadata nothing branches on, and Harmonic
owns the enum — an allow-list turned any value they add into a hard failure of
the whole list while the selector reading the same rows kept working.

Also drops `USER_CONNECTION`, which Harmonic documents as unsupported via the
API, removes three superseded types and one dead helper, and extends the
"credential never reaches a URL or body" assertion from 4 tools to all 13.

* fix(harmonic): fold equivalent profile URLs and stop blank entries failing a batch

Review round on #6915.

- Blank and non-string `personLinkedinUrls` entries are dropped before the
  mutual-exclusivity check. Moving the filter after per-URL validation meant a
  list like `['']` alongside person URNs reported "not both" — naming a conflict
  the caller never created — or failed the URL parse instead of reading as absent.
- Deduplicate on a profile key rather than the canonical string, so
  `linkedin.com/in/x`, `www.linkedin.com/in/x` and a trailing slash count once.
  Harmonic canonicalizes and silently deduplicates server-side and reserves quota
  afterwards, so this does not change what is billed; it keeps Sim's own 1-5000
  accounting in step with the set Harmonic accepts, so a batch of equivalent URLs
  is not rejected locally for a cap it never reaches.

Regional subdomains stay distinct: folding `uk.linkedin.com` into `www.` would
assert an equivalence Harmonic does not document, and the URL kept for display
must remain the one the caller supplied.

* fix(harmonic): fold only recognized profile URLs, never pass-through ones

Review round on #6915. The profile key added last round was applied to every
entry, but it is built from host and path alone. A URL forwarded verbatim for
Harmonic to adjudicate keeps its query, port and fragment significant, so two
distinct identifiers collapsed to one key and the later one was dropped before
Harmonic ever saw it.

The key now applies only to a URL `normalizeLinkedinProfileUrl` already
canonicalized — where the query and fragment are gone by construction, so folding
host and trailing slash is safe. Anything passed through deduplicates on its exact
text.
This commit is contained in:
Waleed
2026-08-20 17:59:28 -07:00
committed by GitHub
parent 5b28da1989
commit d8d983859a
5 changed files with 263 additions and 56 deletions
+2 -2
View File
@@ -358,7 +358,7 @@ export const HarmonicBlock: BlockConfig = {
language: 'json',
placeholder: '["urn:harmonic:person:22", "urn:harmonic:person:1690"]',
description:
'Batch Get requires at least one Person URN or Person ID. Clear Net-New Results clears everything when omitted',
'Batch Get requires at least one Person URN or Person ID. Clear Net-New Results requires at least one URN unless Clear Scope is set to every net-new result',
condition: { field: 'operation', value: [...PERSON_URN_OPERATIONS] },
paramVisibility: 'user-or-llm',
wandConfig: {
@@ -838,7 +838,7 @@ export const HarmonicBlockMeta = {
description:
'Turn LinkedIn URLs or email addresses a workflow already holds into Harmonic contacts.',
content:
'# Enrich Known Identifiers\n\nUse Enrich Person when the workflow already has an identifier rather than a description of who to find.\n\n## Steps\n1. Prefer the LinkedIn profile URL; supply the email only as a fallback identifier.\n2. Run Enrich Person once per identifier and keep personUrn from every match.\n3. When Harmonic reports the person is not on file, capture the enrichment it scheduled and poll Get Enrichment Status until it is COMPLETE or FAILED.\n4. Read the resulting person with Get Person or Batch Get People once enrichment completes.\n\n## Output\nReturn the hydrated contacts, the identifiers still pending enrichment, and the identifiers Harmonic could not resolve. Do not invent contact fields for unresolved rows.',
'# Enrich Known Identifiers\n\nUse Enrich Person when the workflow already has an identifier rather than a description of who to find.\n\n## Steps\n1. Prefer the LinkedIn profile URL; supply the email only as a fallback identifier.\n2. Run Enrich Person once per identifier and keep personUrn from every match.\n3. A person Harmonic does not have yet fails the block rather than returning a row: the error names the enrichment that was scheduled and carries its URN. Handle that error instead of treating it as a match, and poll Get Enrichment Status with the URN until it is COMPLETE or FAILED.\n4. Read the resulting person with Get Person or Batch Get People once enrichment completes.\n\n## Output\nReturn the hydrated contacts, the identifiers still pending enrichment, and the identifiers Harmonic could not resolve. Do not invent contact fields for unresolved rows.',
},
{
name: 'source-company-employees',
+20 -2
View File
@@ -213,7 +213,7 @@ const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [
{
id: 'harmonic-errors',
description:
'Harmonic API message errors, string and object FastAPI detail aborts including the enrichment URN, and validation detail arrays without echoed request input',
'Harmonic API message errors, string and object FastAPI detail aborts including the enrichment URN, bulk email-enrichment error codes with their quota counters, and validation detail arrays without echoed request input',
examples: ['Harmonic'],
extract: (errorInfo) => {
const data = errorInfo?.data
@@ -241,12 +241,30 @@ const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [
if (data.detail && typeof data.detail === 'object' && !Array.isArray(data.detail)) {
const detail = data.detail as { message?: unknown; enrichment_urn?: unknown }
const detailMessage = typeof detail.message === 'string' ? detail.message.trim() : ''
if (!detailMessage) return undefined
const enrichmentUrn =
typeof detail.enrichment_urn === 'string' ? detail.enrichment_urn.trim() : ''
if (!detailMessage) return enrichmentUrn || undefined
return enrichmentUrn ? `${detailMessage} (${enrichmentUrn})` : detailMessage
}
/**
* The bulk email-enrichment endpoint answers 422/429 with a code in `error`
* and no message anywhere `{error: 'MONTHLY_QUOTA_INSUFFICIENT', needed,
* available, submitted}`. These are the most actionable failures on that path.
*
* Gated on one of the documented numeric counters being present. `error` alone
* is far too common a key to claim: `extractErrorMessage` without an explicit
* id walks every extractor in order, so a bare `error` check here would swallow
* OAuth's `{error, error_description}` and return the code instead of the text.
*/
const emailJobCounters = (['needed', 'available', 'submitted'] as const).filter(
(key) => typeof data[key] === 'number'
)
if (typeof data.error === 'string' && data.error.trim() && emailJobCounters.length > 0) {
const code = data.error.trim()
return `${code} (${emailJobCounters.map((key) => `${key} ${data[key]}`).join(', ')})`
}
if (!Array.isArray(data.detail)) return undefined
const details = data.detail
.map((entry: unknown) => {
+163 -1
View File
@@ -230,6 +230,7 @@ describe('Harmonic authentication and registry-facing contracts', () => {
expect(headers.Authorization).toBeUndefined()
}
/** One sample per registered tool: every URL builder interpolates user input. */
const requestSamples: Array<[ToolConfig, Record<string, unknown>]> = [
[harmonicSearchPeopleScoutTool, { accessToken: 'team-secret', query: 'find FDEs' }],
[harmonicListPeopleSavedSearchesTool, { accessToken: 'team-secret' }],
@@ -238,7 +239,35 @@ describe('Harmonic authentication and registry-facing contracts', () => {
{ accessToken: 'team-secret', savedSearchId: 'urn:harmonic:saved_search:1' },
],
[harmonicBatchGetPeopleTool, { accessToken: 'team-secret', personIds: [1] }],
[
harmonicEnrichPersonTool,
{ accessToken: 'team-secret', linkedinUrl: 'https://www.linkedin.com/in/ada' },
],
[harmonicGetPersonTool, { accessToken: 'team-secret', personId: '123' }],
[harmonicGetCompanyEmployeesTool, { accessToken: 'team-secret', companyId: '1' }],
[
harmonicGetPeopleSavedSearchNetNewResultsTool,
{ accessToken: 'team-secret', savedSearchId: '5' },
],
[
harmonicClearPeopleSavedSearchNetNewResultsTool,
{ accessToken: 'team-secret', savedSearchId: '5', clearScope: 'all' },
],
[
harmonicSubmitEmailEnrichmentJobTool,
{ accessToken: 'team-secret', personUrns: ['urn:harmonic:person:1'] },
],
[harmonicGetEmailEnrichmentJobTool, { accessToken: 'team-secret', jobId: 'job-1' }],
[harmonicGetEmailEnrichmentUsageTool, { accessToken: 'team-secret' }],
[
harmonicGetEnrichmentStatusTool,
{ accessToken: 'team-secret', enrichmentUrns: ['urn:harmonic:enrichment:1'] },
],
]
expect(requestSamples).toHaveLength(allTools.length)
expect(new Set(requestSamples.map(([tool]) => tool.id))).toEqual(
new Set(allTools.map((tool) => tool.id))
)
for (const [tool, params] of requestSamples) {
expect(buildUrl(tool, params)).not.toContain('team-secret')
if (tool.request.body)
@@ -336,6 +365,13 @@ describe('Harmonic authentication and registry-facing contracts', () => {
{ status: 404, data: { detail: { enrichment_urn: 'urn:harmonic:enrichment:abc' } } },
harmonicEnrichPersonTool.errorExtractor
)
).toBe('urn:harmonic:enrichment:abc')
expect(
extractErrorMessage(
{ status: 404, data: { detail: {} } },
harmonicEnrichPersonTool.errorExtractor
)
).toBe('Request failed with status 404')
})
@@ -564,7 +600,6 @@ describe('Harmonic people retrieval', () => {
['entity_urn', 'urn:harmonic:company:1'],
['name', ' '],
['creator', 'urn:harmonic:company:1'],
['user_saved_search_type', 'UNKNOWN'],
['created_at', 'yesterday'],
['created_at', '2026-02-31T12:34:56Z'],
['created_at', '2026-01-01T00:00:60Z'],
@@ -579,6 +614,14 @@ describe('Harmonic people retrieval', () => {
).rejects.toThrow(/saved search/)
})
it('passes an unrecognized user_saved_search_type through instead of failing the list', async () => {
const result = await harmonicListPeopleSavedSearchesTool.transformResponse!(
jsonResponse([{ ...validPeopleSavedSearch, user_saved_search_type: 'SOMETHING_NEW' }])
)
expect(result.output.savedSearches).toHaveLength(1)
expect(result.output.savedSearches[0].userSavedSearchType).toBe('SOMETHING_NEW')
})
it.each([
'id',
'entity_urn',
@@ -929,6 +972,15 @@ describe('Harmonic person enrichment', () => {
}
})
it('rejects company context URNs from another entity family', () => {
expect(() =>
buildUrl(harmonicGetPersonTool, {
personId: '123',
companyContextUrns: ['urn:harmonic:person:1'],
})
).toThrow('"companyContextUrns" must contain only company URNs')
})
it('repeats company context URNs as query parameters', () => {
expect(
buildUrl(harmonicGetPersonTool, {
@@ -1098,6 +1150,116 @@ describe('Harmonic email enrichment', () => {
).toThrow('must contain absolute http(s) URLs')
})
it('folds every spelling of one profile into a single submitted entry', () => {
expect(
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
personLinkedinUrls: [
'https://www.linkedin.com/in/ada?utm_source=x',
'https://www.linkedin.com/in/ada',
'https://www.linkedin.com/in/ada#about',
'https://www.linkedin.com/in/ada/',
'https://linkedin.com/in/ada',
'https://WWW.LinkedIn.com/in/ada',
],
})
).toEqual({ person_linkedin_urls: ['https://www.linkedin.com/in/ada'] })
})
it('keeps pass-through URLs distinct on every component Harmonic still sees', () => {
expect(
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
personLinkedinUrls: [
'https://profiles.example/p?id=1',
'https://profiles.example/p?id=2',
'https://profiles.example:8443/p',
'https://profiles.example/p#a',
'https://profiles.example/p#b',
],
})
).toEqual({
person_linkedin_urls: [
'https://profiles.example/p?id=1',
'https://profiles.example/p?id=2',
'https://profiles.example:8443/p',
'https://profiles.example/p#a',
'https://profiles.example/p#b',
],
})
expect(
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
personLinkedinUrls: ['https://profiles.example/p?id=1', 'https://profiles.example/p?id=1'],
})
).toEqual({ person_linkedin_urls: ['https://profiles.example/p?id=1'] })
})
it('keeps regional subdomains distinct rather than assuming an undocumented equivalence', () => {
expect(
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
personLinkedinUrls: ['https://uk.linkedin.com/in/ada', 'https://www.linkedin.com/in/ada'],
})
).toEqual({
person_linkedin_urls: ['https://uk.linkedin.com/in/ada', 'https://www.linkedin.com/in/ada'],
})
})
it('treats blank LinkedIn entries as absent instead of a conflicting identifier list', () => {
expect(
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
personUrns: ['urn:harmonic:person:1'],
personLinkedinUrls: ['', ' ', null],
})
).toEqual({ person_urns: ['urn:harmonic:person:1'] })
expect(() =>
buildBody(harmonicSubmitEmailEnrichmentJobTool, { personLinkedinUrls: ['', ' '] })
).toThrow('requires at least one person URN or LinkedIn profile URL')
})
it('reports the identifier conflict before complaining about any single URL', () => {
expect(() =>
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
personUrns: ['urn:harmonic:person:1'],
personLinkedinUrls: ['not-a-url'],
})
).toThrow('accepts person URNs or LinkedIn URLs, not both')
})
it('surfaces the bulk email error codes with their quota counters', () => {
expect(
extractErrorMessage(
{
status: 429,
data: {
error: 'MONTHLY_QUOTA_INSUFFICIENT',
needed: 500,
available: 20,
submitted: 500,
},
},
harmonicSubmitEmailEnrichmentJobTool.errorExtractor
)
).toBe('MONTHLY_QUOTA_INSUFFICIENT (needed 500, available 20, submitted 500)')
expect(
extractErrorMessage(
{ status: 422, data: { error: 'NO_ELIGIBLE_PEOPLE', submitted: 3, dropped: [] } },
harmonicSubmitEmailEnrichmentJobTool.errorExtractor
)
).toBe('NO_ELIGIBLE_PEOPLE (submitted 3)')
/**
* `extractErrorMessage` with no id walks every extractor in order, so a bare
* `error` key here would hijack other providers' envelopes.
*/
expect(
extractErrorMessage({
status: 400,
data: { error: 'invalid_grant', error_description: 'The grant is invalid' },
})
).toBe('The grant is invalid')
})
it('forwards unrecognised profile URLs so Harmonic can drop them per item', () => {
expect(
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
-18
View File
@@ -120,24 +120,6 @@ export interface HarmonicEnrichmentOutput {
enriched_entity_urn?: unknown
}
export interface HarmonicDroppedPerson {
submitted_identifier?: unknown
reason?: unknown
}
export interface HarmonicPersonJobResultOutput {
person_urn?: unknown
status?: unknown
}
export interface HarmonicPersonJobCountsOutput {
total_processed?: unknown
total_succeeded?: unknown
total_failed?: unknown
total_skipped?: unknown
total_not_found?: unknown
}
export interface HarmonicEnrichmentStatus {
enrichmentUrn: string | null
status: string | null
+78 -33
View File
@@ -34,11 +34,8 @@ export const HARMONIC_EMPLOYEE_GROUP_TYPES = [
'NON_PARTNERS',
] as const
export const HARMONIC_EMPLOYEE_STATUSES = ['ACTIVE', 'NOT_ACTIVE', 'ACTIVE_AND_NOT_ACTIVE'] as const
export const HARMONIC_USER_CONNECTION_STATUSES = [
'USER_CONNECTION',
'TEAM_CONNECTION',
'NO_CONNECTION',
] as const
/** Harmonic documents per-user connection filtering as unsupported via the API. */
export const HARMONIC_USER_CONNECTION_STATUSES = ['TEAM_CONNECTION', 'NO_CONNECTION'] as const
/** Terminal states for a bulk email-enrichment job; `results` stays null until one is reached. */
export const HARMONIC_EMAIL_JOB_TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED'])
export const HARMONIC_PERSON_INCLUDE_FIELDS = [
@@ -61,6 +58,7 @@ const PERSON_URN_PATTERN = /^urn:harmonic:person:[^\s]+$/
const SAVED_SEARCH_URN_PATTERN = /^urn:harmonic:saved_search:[^\s]+$/
const USER_URN_PATTERN = /^urn:harmonic:user:[^\s]+$/
const ENRICHMENT_URN_PATTERN = /^urn:harmonic:enrichment:[^\s]+$/
const COMPANY_URN_PATTERN = /^urn:harmonic:company:[^\s]+$/
const COMPANY_OR_PERSON_URN_PATTERN = /^urn:harmonic:(company|person):[^\s]+$/
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@@ -230,14 +228,14 @@ function requireUserUrn(value: unknown): string {
return normalized
}
/**
* Passed through rather than checked against a fixed set. This value is display
* metadata that nothing downstream branches on, and Harmonic owns the enum an
* allow-list would turn any value they add into a hard failure of the entire list,
* while the selector reading the same rows kept working.
*/
function requireUserSavedSearchType(value: unknown): string {
const normalized = requireSavedSearchString(value, 'user_saved_search_type')
if (!HARMONIC_USER_SAVED_SEARCH_TYPES.has(normalized)) {
throw new Error(
'Harmonic returned a people saved search with an invalid user_saved_search_type'
)
}
return normalized
return requireSavedSearchString(value, 'user_saved_search_type')
}
function requireSavedSearchTimestamp(value: unknown, field: string): string {
@@ -357,10 +355,6 @@ export function parsePersonUrns(value: unknown, paramName = 'personUrns'): strin
return normalizePersonUrns(parseArrayParam(value, paramName), paramName)
}
export function parsePersonIds(value: unknown): number[] {
return normalizePersonIds(parseArrayParam(value, 'personIds'))
}
export function clampPageSize(value: unknown): number {
if (value === undefined || value === null || value === '') return HARMONIC_PAGE_SIZE_DEFAULT
const parsed = parseSafeDecimalInteger(value, 'size')
@@ -700,6 +694,9 @@ export function buildGetPersonUrl(personId: unknown, companyContextUrns: unknown
`${HARMONIC_API_BASE}/persons/${encodeURIComponent(requireIdentifier(personId, 'personId'))}`
)
for (const urn of uniqueStrings(parseArrayParam(companyContextUrns, 'companyContextUrns'))) {
if (!COMPANY_URN_PATTERN.test(urn)) {
throw new Error('Harmonic "companyContextUrns" must contain only company URNs')
}
url.searchParams.append('company_context_urns', urn)
}
return url.toString()
@@ -831,6 +828,39 @@ export function buildEnrichmentStatusUrl(enrichmentUrns: unknown): string {
return url.toString()
}
/**
* `linkedin.com/in/x`, `www.linkedin.com/in/x` and a trailing slash all name one
* profile, so a recognized profile folds to a host-and-path key. Regional
* subdomains (`uk.linkedin.com`) are deliberately left distinct: folding them
* would claim an equivalence Harmonic does not document.
*
* This key is only ever applied to a URL `normalizeLinkedinProfileUrl` already
* canonicalized one with no query or fragment left. A URL forwarded verbatim for
* Harmonic to adjudicate keeps every component significant, so it deduplicates on
* its exact text; dropping the query or port there would silently discard a
* distinct identifier the caller asked to submit.
*/
function linkedinProfileKey(canonicalUrl: string): string {
try {
const parsed = new URL(canonicalUrl)
const host = parsed.hostname.toLowerCase().replace(/^www\./, '')
return `${host}${parsed.pathname.replace(/\/+$/, '')}`
} catch {
return canonicalUrl
}
}
function dedupeByKey(entries: Array<{ url: string; key: string }>): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const entry of entries) {
if (seen.has(entry.key)) continue
seen.add(entry.key)
result.push(entry.url)
}
return result
}
export function buildEmailEnrichmentJobBody(
personUrns: unknown,
personLinkedinUrls: unknown
@@ -843,34 +873,49 @@ export function buildEmailEnrichmentJobBody(
* for Harmonic to adjudicate. Only values that are not absolute http(s) URLs at
* all are rejected here, because those are a local mistake, not a provider call.
*/
const linkedinUrls = uniqueStrings(parseArrayParam(personLinkedinUrls, 'personLinkedinUrls')).map(
(value) => {
/**
* Blank and non-string entries are dropped first so `['']` reads as "no LinkedIn
* URLs supplied" rather than tripping the mutual-exclusivity check below or
* failing the per-URL parse.
*/
const rawLinkedinUrls = uniqueStrings(parseArrayParam(personLinkedinUrls, 'personLinkedinUrls'))
/**
* Harmonic documents these as mutually exclusive "Provide exactly one of the
* two arrays" so sending both is rejected locally rather than letting the
* provider silently pick one and bill for it. This runs before any per-URL work
* so the clearer of the two errors wins when both problems are present.
*/
if (urns.length > 0 && rawLinkedinUrls.length > 0) {
throw new Error(
'Harmonic Submit Email Enrichment Job accepts person URNs or LinkedIn URLs, not both'
)
}
/**
* Canonicalise first, then deduplicate. Harmonic canonicalizes and silently
* deduplicates server-side and reserves quota afterwards, so this is not what
* protects the bill it keeps Sim's own 1-5000 accounting in step with the set
* Harmonic will actually accept, so a batch of equivalent URLs is not rejected
* locally for exceeding a cap it never reaches.
*/
const linkedinUrls = dedupeByKey(
rawLinkedinUrls.map((value) => {
const normalized = normalizeLinkedinProfileUrl(value)
if (normalized) return normalized
if (normalized) return { url: normalized, key: linkedinProfileKey(normalized) }
try {
const parsed = new URL(value)
const parsed = new URL(String(value))
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('unsupported scheme')
}
return value
return { url: String(value), key: String(value) }
} catch {
throw new Error(
'Harmonic "personLinkedinUrls" must contain absolute http(s) URLs; Harmonic reports unmatched profiles in dropped'
)
}
}
})
)
/**
* Harmonic documents these as mutually exclusive "Provide exactly one of the
* two arrays" so sending both is rejected locally rather than letting the
* provider silently pick one and bill for it.
*/
if (urns.length > 0 && linkedinUrls.length > 0) {
throw new Error(
'Harmonic Submit Email Enrichment Job accepts person URNs or LinkedIn URLs, not both'
)
}
const identifiers = urns.length > 0 ? urns : linkedinUrls
if (identifiers.length === 0) {
throw new Error(