Entity reconciliation
2026-09-12 — claim-first layer. Numeric fields (capacity, investment) now flow through the claim store described in CLAIMS.md: scope + semantics + evidence sentence + field-level authority, sanity engine, then the merge policy below. External-id folding is allowlist-only (
IDENTIFYING_EXTERNAL_ID_KEYS), candidates are relevance-ordered (same operator → same name → nearest), andrule:campus-vs-buildingnow links the building to its campus (parent_facility_id,record_scope) instead of opening a duplicate review.provenance.is_winnermarks the observation behind each displayed value;run_idties every write to a connector run (rollback).
How a normalized record coming out of a connector becomes (or updates) a canonical facility, operator, project,
cloud region or IXP — and how conflicting values are merged. Implementation: apps/worker/src/ingest/
(match.ts holds the pure scoring/merge logic, facilities.ts wires it to Postgres).
Guarantees
- Nothing is invented and nothing is lost. Every incoming value is written to
provenanceeven when it does not win the merge; ambiguous matches are kept as new records and queued for review, never discarded. - Idempotent. Re-ingesting the same document yields 0 created rows, 0 events and the same provenance rows
(refreshed
last_observed). Connector keys (peeringdb:fac:12) are mapped inentity_keys. - Atomic per batch, tolerant per entity. A batch runs in one transaction; each entity runs in a savepoint,
so a bad record is rolled back and counted as
rejectedwithout failing the batch. Dry runs roll back everything.
Concurrency
Connectors run in parallel, so two batches may describe the same operator at the same moment. Operator
resolution takes a transaction-scoped advisory lock on the normalized (canonical) name before looking up or
inserting, which serializes creation across batches; mergeOperators() folds duplicates that slipped through
before the lock existed. Facilities are not locked (a dataset batch can carry thousands of them and Postgres'
lock table is finite): two connectors creating the same unseen facility within the same seconds can still
produce a pair of records that only a later near-duplicate sweep or an admin merge (mergeFacilities) will fold.
Facilities
Resolution order
- Connector key —
entity_keys.key = record.key→ same facility (followingmerged_into). Always wins. - Shared external ids — any
externalIdsentry (peeringdb_fac,osm,wikidata, …) already stored on a live facility → same facility. - Candidate scoring — candidates are live facilities that (a) lie in a 40 km bounding box around the incoming
coordinates, or (b) share the normalized name (or an alias) in the same country, or (c) belong to the same
operator in the same country (so facility codes like
DC12can be compared). Up to 400 candidates are scored.
Score
Weighted sum, weights redistributed over the signals available on both sides:
| Signal | Weight | Value |
|---|---|---|
| Name | 0.40 | 1 for equal normalized names (noise words removed), equal code-preserving names or an alias hit; else max(token Jaccard, 0.85 × containment) |
Facility codes (DC12, FR5, LD8) |
0.15 | 1 when both names carry codes and one matches, 0 when they conflict; skipped when either side has no code |
| Operator | 0.20 | 1 same operator, 0.5 unknown on either side, 0 different |
| Distance | 0.15 | 1 − 0.4·d/r inside the match radius r = matchRadiusKm(precisionA, precisionB) (exact 250 m … metro 40 km), decaying to 0 at 4r; 0.6 for "same city" without coordinates |
| Address | 0.10 | normalized address equality → 1; same house number → ≥ 0.75; different house numbers → ≤ 0.4; else token Jaccard |
Rules applied on top of the weighted sum:
- Operator + code rule: same operator, matching facility code and geographically compatible (inside the radius or
no coordinates) → the name signal is raised to 0.95. This is what merges
DC12intoEquinix DC12. - Code conflict: same operator but conflicting codes (
DC12vsDC13) caps the name signal at 0.45. - Different countries cap the score at 0.30.
- Too far: precise coordinates more than 4× the match radius apart cap the score at 0.50.
- Different known operators never merge — the score is capped below the pending threshold — unless the normalized name and the normalized address are both exact (an operator change on the same building).
Decision thresholds
| Score | Decision | What happens |
|---|---|---|
| ≥ 0.92 | auto-merge | The incoming record updates the candidate; entity_matches gets an auto_merged row (score + reasons) for audit. |
| 0.60 – 0.92 | pending | A new facility is created with confidence = unverified, and an entity_matches row with status = pending, the candidate JSON (incl. createdFacilityId), the matched facility id, the score and the reasons. Nothing is lost; the admin decides. |
| < 0.60 | create | New facility (auto_created row recorded when a candidate scored ≥ 0.30, for tuning). |
Why "create + pending" rather than "hold": the record is visible immediately (flagged unverified), its provenance is
preserved, and approving the match later is a pure fold (mergeFacilities). Holding would hide real facilities for
days when the review queue is slow.
Admin review flow
GET /api/admin/matches?status=pending lists the queue. For each row:
- Approve →
mergeFacilities(createdFacilityId, matchedFacilityId): keys, aliases, provenance (deduplicated on entity/field/source/url), tenants, IXPs, projects and events are moved to the surviving facility; the created one getsmerged_into; its external ids are folded; derived fields are recomputed; the match row becomesapproved. - Reject → the match row becomes
rejected; the created facility simply stays a separate record and its confidence is recomputed on the next observation (it is no longer forced tounverified).
A facility keeps confidence = unverified for as long as a pending match references it as createdFacilityId.
Field merge policy
Each incoming field is compared with the observation that currently backs the stored value (from provenance,
is_current = true, joined with sources.kind):
- Empty stored value → take the incoming one.
- Same source re-observing → take it (a source may correct itself).
- Measured beats estimate (
isEstimate), whatever the source. - Otherwise higher authority wins: operator / government / filing 4 · utility / cloud provider 3.5 · registry 3 · dataset 2.5 · community 2 · secondary 1.5 · news 1 (estimates −1.5). Ties → most recent observation.
Special cases:
- MW (
itCapacityMw,totalPowerMw,plannedPowerMw): a non-primary source (dataset, community, secondary, news) never overwrites a figure from an operator / government / filing / utility / cloud-provider / registry source.mw_is_estimateis true only when every current MW observation is an estimate. - Coordinates: never replaced by a less precise point (
PRECISION_RANK: exact > parcel > street > approximate > city > metro > unknown). Same precision → newest wins (same source always refreshes its own point). - Flags
is_ai,is_hyperscale: true sticks. A facility operated by a hyperscaler is hyperscale. - Certifications, aliases, external ids: accumulated (set union).
- Name: same policy as other fields; the previous name becomes an alias.
- Status / type:
unknownnever overwrites a known value.
Derived fields (recomputed after every write)
completeness0–100: geo 15 (exact/parcel/street; 8 for city/metro/approximate), operator 10, address 10, status 10, MW 20 (14 when estimate; planned-only 12/8), type 5, opened 10, website 5, description 5, tenants/IXPs 10.confidence=computeConfidence()with the best source kind as base and the number of distinct primary source kinds as corroborations;verifiedneeds ≥ 2 primary kinds,estimatedwhen every observation is an estimate.source_count= distinct sources with current provenance;last_verified= latest observation by a primary source.metro_id= nearest seeded metro whose radius covers the point (same country), else the metro whose alias matches the city;country_iso2falls back to the metro's country;geohash(precision 7).
Operators
Order: connector key → external ids → curated canonical table (canonical-operators.ts: ~175 brands with aliases,
e.g. Interxion/Telx/DuPont Fabros → Digital Realty, RagingWire/e-shelter/Gyron/NetMagic → NTT Global Data Centers)
→ exact normalized name → alias match (case-insensitive) → trigram similarity ≥ 0.92 and the same website
domain → create. Kind inference: curated kind, else caller hint (carrier / cloud), else name heuristics.
Hyperscalers (AWS, Microsoft, Google, Meta, Oracle, Alibaba Cloud, Tencent Cloud, Apple, IBM, Huawei Cloud) get
kind = hyperscaler; is_cloud_provider / is_carrier are set from the table or the role hint and only ever turn
on. Incoming names that differ from the canonical name are appended to aliases. News text can only link to
curated or already-known operators; it never creates one.
Projects
Key → external ids → same sourceUrl → (same country, same or unknown operator, normalized-name trigram
similarity ≥ 0.85). Timeline rows are deduplicated on (project, date, type, sha256(description)). Status changes
emit project_status_changed; planned MW, expected opening, investment and operator changes follow the same event
rules as facilities. last_update moves whenever a column or a timeline row changes.
Cloud regions, IXPs, campuses, tenants
- Cloud regions are unique on
(provider, code); the provider is resolved as an operator with the cloud hint. - IXPs: key → external ids → normalized name (or long name) in the same country.
facility_ixpslinks come from the IXP'sfacilityKeys(resolved throughentity_keys) or from a facility page'sixpslist. - Campuses: key → external ids → normalized name with compatible operator/country.
- Tenants:
carriers/cloudProviderson a facility resolve to operators (carrier / cloud hint,AS12345parsed intoasn) and fillfacility_tenants;carriers_count/networks_countare recomputed.
Events
Tracked facility fields: status, IT/total/planned MW, opened/announced/construction-start dates, operator, owner,
facility type, name. Significance: status 90 · operator 85 · owner 70 · MW change ≥ 20 % 80 else 50 · dates 60 ·
others 20 (first-time values are capped at 50; enrichments of name/type/operator are not events).
Fingerprint = sha256(entityType|entityId|eventType|JSON(newValue)|day) — the same change observed by several pages
on the same day is one event. facility_discovered (65 when MW ≥ 50 or in pipeline, else 40) and
project_announced (75 when ≥ 100 MW planned) are emitted for new records. News items keyed by URL emit one event
per article (fingerprint on the URL) when they carry an event type with significance ≥ 40.