koala73 / koala73/worldmonitor

feat(company-monitoring): resolve independent company identity via ZoomInfo

Open
#7,043 0 comments 0 reactions 0 assignees View on GitHub
feature
Dominant language
TypeScript
Stars
86.6k
Forks
13.1k
Avg merge
8h 4m
Merged PRs (30d)
825

Description

## Parent

#6002

## What to build

Bind each monitored company to a verified official domain using an independent
third-party provider, so that provider attribution has an authority to rest on. This is
the first production writer of `provenance: "independent_provider"` claims.

Provider: ZoomInfo `POST /gtm/data/v1/companies/search`. The endpoint consumes no
credits and returned companies do not count toward record limits. Only request limits
increment. Measured ceilings are 5 requests per second, 10,800 per hour, and 129,600 per
day.

Because the endpoint is credit-free and writes only internal claims, this work is
outside the `cm_eval_v1` paid-provider fence. It does not require a Stage 0 promotion and
does not set any `COMPANY_MONITORING_ROLLOUT_FLAGS` entry.

## Scope fence

Provider attributes decide a binding and are then discarded. Only the resolved domain
claim, the canonical alias, and the provider company ID persist. Employee count, revenue,
and address are not stored and never enter a customer-facing response, alert, or export.
This is identity resolution, not registry enrichment. See the #6002 amendment.

## Measured provider behaviour

Name search is fuzzy and ranked by revenue, not relevance. It cross-binds badly:

- `companyName: "Anduril Industries"` returns the real Anduril under the name "Anduril",
plus two lookalikes whose names match the query string exactly. The exact-string match
is the wrong company.
- `companyName: "Stripe"` on the scoops surface returned 27 results spanning 14 distinct
companies. Only 10 of 25 were Stripe.

Website search is deterministic:

- `companyWebsite: "anduril.com"` returns exactly one result.
- `companyWebsite: "stripe.com"` returns exactly one result, id `352810353`.

No match returns a clean `data: []` with `totalResults: 0`. Errors are JSON:API with
`errors[].code`, `source.pointer`, and an `x-request-id` header.

## Matcher

Never accept `data[0]` by rank.

Path 1, customer supplied a domain claim, preferred and deterministic:

```
for each customer domain claim d (NFC, lowercased, www-stripped, trailing-dot-stripped):
r = POST companies/search { companyWebsite: d }
if r.meta.totalResults !== 1: skip
c = r.data[0]
accept iff normalizeDomain(c.attributes.website) === d
AND countryMatches(c.attributes.country, company.domicileCountry)
AND nameConsistent(c.attributes.name, {company.name} union alias claim values)
```

`countryMatches` maps ZoomInfo labels ("United States", "United Kingdom") to the `US` and
`GB` literals in the schema.

Path 2, name only, fail-closed:

```
r = POST companies/search { companyName: company.name, country: label(domicileCountry) }
survivors = r.data.filter(c => strictNameEqual(c.name, {company.name} union aliases))
accept iff survivors.length === 1
AND safeDomain(survivors[0].website) !== null
AND that domain is unclaimed by another company in this account
```

Zero results, ambiguity, or a failed cross-check writes no claim. Partial or failed
resolution stays partial and never refreshes coverage as adequate quiet.

Corporate-family fence: reject when two monitored companies in one account resolve to the
same ZoomInfo `companyId`. That is the actual cross-bind. Record `parentId` and
`ultimateParentId` as audit metadata. Do not reject on a shared parent — a customer may
legitimately monitor both a parent and a subsidiary.

## Delivery

**PR 1 — adapter and matcher, no Convex changes.**

- `scripts/lib/company-monitoring-zoominfo.mjs`, mirroring the export shape of
`scripts/lib/company-monitoring-exa.mjs`.
- Frozen `COMPANY_MONITORING_ZOOMINFO_CONTRACT` with
`contractVersion: 'cm_zoominfo_identity_v1'`. No `paidRuntimeApproved` field; the
endpoint is credit-free.
- OAuth2 client credentials against `POST /gtm/oauth/v1/token`. Token lives 86400s. Cache
in memory, refresh before expiry, refresh once on 401 and then fail. Copy
`access_token` verbatim into the `Authorization` header. Never decode, parse, or trim
it.
- `fetch` only. Set an explicit `User-Agent`; default stdlib agents are gateway-blocked
and return a misleading 403.
- Throttle to 4 requests per second. Honour `Retry-After`. Reuse the Exa adapter's
`RETRYABLE_STATUS` and bound attempts at 2.
- Reuse `safeDomain` and the `DOMAIN` regex from the Exa adapter rather than re-deriving
them.
- Record `x-request-id` and `errors[].code` on every non-2xx response.
- `tests/fixtures/company-monitoring-zoominfo/` with recorded payloads: the Anduril
three-result trap, the Monzo cross-domicile results, the `stripe.com` single result,
and the empty `totalResults: 0` shape.
- `tests/company-monitoring-zoominfo.test.mts` covering the matcher against those
fixtures.

**PR 2 — Convex writer and wiring.**

- `convex/schema.ts`: add `zoomInfoCompanyId: v.optional(v.string())` to
`companyMonitoringCompanies`. It is a resolution result, not a customer claim, so it
belongs on the company document rather than in `companyMonitoringClaims`.
- Widen `companyMonitoringScanSourceValidator` with an `identity` source, so resolution
gets leasing, retry, checkpointing, and purge integration from the existing obligation
machinery. Audit every sibling arm and both sources of truth, including the
`proto/worldmonitor/company_monitoring/v1` mirror.
- `convex/companyMonitoring/identityProvider.ts`, a new internal mutation writing
`provenance: "independent_provider"`, `trustState: "verified"`,
`allowedUses: ["attribution", "discovery"]`, and `expiresAt: now + 30 days`.
- Check `COMPANY_MONITORING_LIMITS.maxClaimsPerCompany` (81) before inserting and throw
`COMPANY_MONITORING_CLAIM_LIMIT_EXCEEDED`, matching
`convex/companyMonitoring/claimPolicyMigration.ts:87`.
- Re-resolution must run before `expiresAt`. The authority predicate requires
`expiresAt > now`, so an expired claim silently kills the binding.
- Contradiction on re-resolution sets `trustState: "rejected"`. The existing demotion path
then fires without new code.

**PR 3 — worker and operations.**

- Wire the provider into `scripts/company-monitoring-worker.mjs` next to the Exa and X
blocks at lines 681-693, reading `ZOOMINFO_CLIENT_ID` and `ZOOMINFO_CLIENT_SECRET`.
- Document both variables in `.env.example` near the Exa block at line 774.
- Set both variables on the existing company-monitoring worker service in Railway. No new
service, so the service-provisioning checklist does not apply.
- Add a `## ZoomInfo` section to `docs/internal/company-monitoring/provider-policy.md`
matching the Exa and X sections: official API only, measured rate limits, credit-free
status with the documentation citation, retention bound, and refresh date.

## Acceptance criteria

- [ ] A resolved company yields a non-empty domain list in the X worker projection at
`convex/companyMonitoring/orchestration.ts:957`. The test fails against current
`main`.
- [ ] The Anduril and Monzo fixtures write no claim.
- [ ] Two monitored companies in one account cannot resolve to the same ZoomInfo
`companyId`.
- [ ] Claim expiry demotes the binding and reinstates the unresolved coverage state.
- [ ] The 81-claim cap is enforced.
- [ ] No provider attribute other than the domain, canonical name, and company ID is
persisted anywhere.
- [ ] Mutation tests: removing the `totalResults === 1` check, the country cross-check,
the name-consistency check, or the `expiresAt` write each turns a test red. Revert
mutants by file copy, never by `git checkout`.

## Scale and cost

500 companies at one to two calls each is at most 1,000 calls per full refresh. With a
30-day claim TTL, steady state is roughly 33 companies per day against a 129,600 per day
budget. Cost is zero.

## Blocked by

None.

---

All file:line references verified against `origin/main` at `9143d4025792ff1da912f93e76d04a8c1c9c79c2` on 2026-08-21.

Contributor guide

Open the contributing guide

Research direction

Start with scripts/lib/company-monitoring-zoominfo.mjs and tests/company-monitoring-zoominfo.test.mts, using the recorded fixtures to understand the matcher and adapter contract. Then trace the Convex schema, identityProvider.ts, orchestration.ts:957, and the worker wiring at lines 681-693. Done means all three delivery stages are integrated, acceptance and mutation tests pass, and the documented provider and retention constraints are preserved.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, backend, database, devops
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
28/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.