@agent-native/core 0.123.0 → 0.123.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (263) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +16 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/templates-meta.ts +12 -0
  5. package/corpus/templates/crm/.agents/skills/agent-native-docs/SKILL.md +115 -0
  6. package/corpus/templates/crm/.agents/skills/agent-native-toolkit/SKILL.md +177 -0
  7. package/corpus/templates/crm/.agents/skills/crm/SKILL.md +205 -0
  8. package/corpus/templates/crm/.agents/skills/customizing-agent-native/SKILL.md +220 -0
  9. package/corpus/templates/crm/.agents/skills/feature-flags/SKILL.md +169 -0
  10. package/corpus/templates/crm/.agents/skills/sharing/SKILL.md +228 -0
  11. package/corpus/templates/crm/.agents/skills/storing-data/SKILL.md +178 -0
  12. package/corpus/templates/crm/.agents/skills/upgrade-agent-native/SKILL.md +122 -0
  13. package/corpus/templates/crm/.env.example +5 -0
  14. package/corpus/templates/crm/AGENTS.md +82 -0
  15. package/corpus/templates/crm/CHANGELOG.md +25 -0
  16. package/corpus/templates/crm/DEVELOPING.md +66 -0
  17. package/corpus/templates/crm/README.md +122 -0
  18. package/corpus/templates/crm/_gitignore +12 -0
  19. package/corpus/templates/crm/actions/_crm-action-utils.ts +98 -0
  20. package/corpus/templates/crm/actions/_crm-attribute-utils.ts +219 -0
  21. package/corpus/templates/crm/actions/_crm-dashboard.ts +69 -0
  22. package/corpus/templates/crm/actions/_crm-data-program-actions.ts +15 -0
  23. package/corpus/templates/crm/actions/_crm-list-utils.ts +775 -0
  24. package/corpus/templates/crm/actions/_crm-signal-utils.ts +79 -0
  25. package/corpus/templates/crm/actions/add-crm-record-to-list.ts +164 -0
  26. package/corpus/templates/crm/actions/apply-crm-proposals.ts +291 -0
  27. package/corpus/templates/crm/actions/archive-crm-attribute.ts +69 -0
  28. package/corpus/templates/crm/actions/attach-call-evidence.ts +170 -0
  29. package/corpus/templates/crm/actions/configure-crm-connection.ts +126 -0
  30. package/corpus/templates/crm/actions/configure-native-crm.ts +44 -0
  31. package/corpus/templates/crm/actions/create-crm-attribute.ts +350 -0
  32. package/corpus/templates/crm/actions/create-crm-list.ts +194 -0
  33. package/corpus/templates/crm/actions/create-crm-record.ts +148 -0
  34. package/corpus/templates/crm/actions/create-crm-signal-tracker.ts +58 -0
  35. package/corpus/templates/crm/actions/delete-crm-saved-view.ts +68 -0
  36. package/corpus/templates/crm/actions/delete-staged-dataset.ts +14 -0
  37. package/corpus/templates/crm/actions/estimate-crm-enrichment.ts +229 -0
  38. package/corpus/templates/crm/actions/find-crm-duplicates.ts +87 -0
  39. package/corpus/templates/crm/actions/get-crm-automation-recipe.ts +36 -0
  40. package/corpus/templates/crm/actions/get-crm-dashboard-panel.ts +35 -0
  41. package/corpus/templates/crm/actions/get-crm-dashboard.ts +14 -0
  42. package/corpus/templates/crm/actions/get-crm-overview.ts +13 -0
  43. package/corpus/templates/crm/actions/get-crm-pipeline-data.ts +61 -0
  44. package/corpus/templates/crm/actions/get-crm-record-page.ts +287 -0
  45. package/corpus/templates/crm/actions/get-crm-record.ts +75 -0
  46. package/corpus/templates/crm/actions/get-crm-workspace.ts +364 -0
  47. package/corpus/templates/crm/actions/install-crm-pipeline-dashboard.ts +81 -0
  48. package/corpus/templates/crm/actions/list-crm-attributes.ts +71 -0
  49. package/corpus/templates/crm/actions/list-crm-connections.ts +68 -0
  50. package/corpus/templates/crm/actions/list-crm-dashboard-revisions.ts +15 -0
  51. package/corpus/templates/crm/actions/list-crm-dashboards.ts +15 -0
  52. package/corpus/templates/crm/actions/list-crm-enrichment-slots.ts +28 -0
  53. package/corpus/templates/crm/actions/list-crm-list-entries.ts +192 -0
  54. package/corpus/templates/crm/actions/list-crm-lists.ts +80 -0
  55. package/corpus/templates/crm/actions/list-crm-proposals.ts +55 -0
  56. package/corpus/templates/crm/actions/list-crm-record-field-history.ts +201 -0
  57. package/corpus/templates/crm/actions/list-crm-record-values.ts +200 -0
  58. package/corpus/templates/crm/actions/list-crm-records.ts +103 -0
  59. package/corpus/templates/crm/actions/list-crm-saved-views.ts +66 -0
  60. package/corpus/templates/crm/actions/list-crm-signal-hits.ts +43 -0
  61. package/corpus/templates/crm/actions/list-crm-signal-trackers.ts +32 -0
  62. package/corpus/templates/crm/actions/list-crm-tasks.ts +22 -0
  63. package/corpus/templates/crm/actions/list-staged-datasets.ts +14 -0
  64. package/corpus/templates/crm/actions/list-workspace-connections.ts +47 -0
  65. package/corpus/templates/crm/actions/manage-crm-attribute-option.ts +272 -0
  66. package/corpus/templates/crm/actions/manage-crm-signal-tracker.ts +87 -0
  67. package/corpus/templates/crm/actions/manage-crm-task.ts +132 -0
  68. package/corpus/templates/crm/actions/merge-crm-records.ts +691 -0
  69. package/corpus/templates/crm/actions/navigate.ts +54 -0
  70. package/corpus/templates/crm/actions/provider-api-catalog.ts +22 -0
  71. package/corpus/templates/crm/actions/provider-api-docs.ts +54 -0
  72. package/corpus/templates/crm/actions/provider-api-request.ts +92 -0
  73. package/corpus/templates/crm/actions/query-staged-dataset.ts +44 -0
  74. package/corpus/templates/crm/actions/record-crm-call-insight.ts +160 -0
  75. package/corpus/templates/crm/actions/record-crm-smart-signal.ts +150 -0
  76. package/corpus/templates/crm/actions/remove-crm-list-entry.ts +79 -0
  77. package/corpus/templates/crm/actions/restore-crm-dashboard-revision.ts +23 -0
  78. package/corpus/templates/crm/actions/review-crm-signal.ts +31 -0
  79. package/corpus/templates/crm/actions/run-crm-attribute-fill.ts +443 -0
  80. package/corpus/templates/crm/actions/run-crm-enrichment.ts +647 -0
  81. package/corpus/templates/crm/actions/run-crm-saved-view-program.ts +103 -0
  82. package/corpus/templates/crm/actions/run-crm-signal-trackers.ts +265 -0
  83. package/corpus/templates/crm/actions/run.ts +7 -0
  84. package/corpus/templates/crm/actions/save-crm-dashboard.ts +51 -0
  85. package/corpus/templates/crm/actions/save-crm-saved-view.ts +296 -0
  86. package/corpus/templates/crm/actions/sync-crm.ts +146 -0
  87. package/corpus/templates/crm/actions/update-crm-attribute.ts +111 -0
  88. package/corpus/templates/crm/actions/update-crm-list-entry.ts +102 -0
  89. package/corpus/templates/crm/actions/update-crm-list.ts +97 -0
  90. package/corpus/templates/crm/actions/update-crm-record.ts +543 -0
  91. package/corpus/templates/crm/actions/view-screen.ts +324 -0
  92. package/corpus/templates/crm/agent-native.app-skill.json +69 -0
  93. package/corpus/templates/crm/app/components/crm/CreateCrmRecordDialog.tsx +158 -0
  94. package/corpus/templates/crm/app/components/crm/CrmDashboardPanel.tsx +78 -0
  95. package/corpus/templates/crm/app/components/crm/CrmSignalsPanel.tsx +220 -0
  96. package/corpus/templates/crm/app/components/crm/IntelligenceSettings.tsx +414 -0
  97. package/corpus/templates/crm/app/components/crm/RecordActions.tsx +648 -0
  98. package/corpus/templates/crm/app/components/crm/RecordGrid.tsx +488 -0
  99. package/corpus/templates/crm/app/components/crm/RecordWorkspace.tsx +232 -0
  100. package/corpus/templates/crm/app/components/crm/SavedViewDataProgram.tsx +93 -0
  101. package/corpus/templates/crm/app/components/crm/Surface.tsx +97 -0
  102. package/corpus/templates/crm/app/components/crm/WorkOverview.tsx +180 -0
  103. package/corpus/templates/crm/app/components/crm/board/CrmBoard.tsx +988 -0
  104. package/corpus/templates/crm/app/components/crm/board/board-model.ts +399 -0
  105. package/corpus/templates/crm/app/components/crm/board/entry-filter.ts +150 -0
  106. package/corpus/templates/crm/app/components/crm/board/view-draft.ts +227 -0
  107. package/corpus/templates/crm/app/components/crm/grid/CrmGrid.tsx +1033 -0
  108. package/corpus/templates/crm/app/components/crm/grid/GridCell.tsx +620 -0
  109. package/corpus/templates/crm/app/components/crm/grid/clipboard.ts +84 -0
  110. package/corpus/templates/crm/app/components/crm/grid/model.ts +304 -0
  111. package/corpus/templates/crm/app/components/crm/grid/navigation.ts +220 -0
  112. package/corpus/templates/crm/app/components/crm/grid/query.ts +214 -0
  113. package/corpus/templates/crm/app/components/crm/record/AttributePanel.tsx +192 -0
  114. package/corpus/templates/crm/app/components/crm/record/FieldHistory.tsx +179 -0
  115. package/corpus/templates/crm/app/components/crm/record/RecordHeader.tsx +103 -0
  116. package/corpus/templates/crm/app/components/crm/record/RecordLists.tsx +235 -0
  117. package/corpus/templates/crm/app/components/crm/record/RecordTabs.tsx +206 -0
  118. package/corpus/templates/crm/app/components/crm/record/field-editors.tsx +245 -0
  119. package/corpus/templates/crm/app/components/crm/record/record-data.ts +603 -0
  120. package/corpus/templates/crm/app/components/crm/settings/AdvancedSettings.tsx +49 -0
  121. package/corpus/templates/crm/app/components/crm/settings/AttributeOptionsEditor.tsx +441 -0
  122. package/corpus/templates/crm/app/components/crm/settings/ConnectionSettings.tsx +169 -0
  123. package/corpus/templates/crm/app/components/crm/settings/FieldsSettings.tsx +935 -0
  124. package/corpus/templates/crm/app/components/crm/settings/ListsSettings.tsx +339 -0
  125. package/corpus/templates/crm/app/components/crm/settings/settings-admin.ts +377 -0
  126. package/corpus/templates/crm/app/components/crm/shared/AttributeValueParts.tsx +69 -0
  127. package/corpus/templates/crm/app/components/crm/shared/attribute-value.ts +591 -0
  128. package/corpus/templates/crm/app/components/layout/CrmCommandMenu.tsx +556 -0
  129. package/corpus/templates/crm/app/components/layout/CrmLayout.tsx +130 -0
  130. package/corpus/templates/crm/app/components/layout/CrmSidebar.tsx +407 -0
  131. package/corpus/templates/crm/app/components/layout/command-palette-search.ts +126 -0
  132. package/corpus/templates/crm/app/components/layout/crm-ui-intents.ts +41 -0
  133. package/corpus/templates/crm/app/components/layout/sidebar-collapse.ts +40 -0
  134. package/corpus/templates/crm/app/components/layout/sidebar-lists.ts +109 -0
  135. package/corpus/templates/crm/app/components/ui/alert-dialog.tsx +1 -0
  136. package/corpus/templates/crm/app/components/ui/badge.tsx +1 -0
  137. package/corpus/templates/crm/app/components/ui/button.tsx +1 -0
  138. package/corpus/templates/crm/app/components/ui/card.tsx +1 -0
  139. package/corpus/templates/crm/app/components/ui/checkbox.tsx +1 -0
  140. package/corpus/templates/crm/app/components/ui/dialog.tsx +1 -0
  141. package/corpus/templates/crm/app/components/ui/dropdown-menu.tsx +1 -0
  142. package/corpus/templates/crm/app/components/ui/hover-card.tsx +1 -0
  143. package/corpus/templates/crm/app/components/ui/input.tsx +1 -0
  144. package/corpus/templates/crm/app/components/ui/label.tsx +1 -0
  145. package/corpus/templates/crm/app/components/ui/popover.tsx +1 -0
  146. package/corpus/templates/crm/app/components/ui/select.tsx +1 -0
  147. package/corpus/templates/crm/app/components/ui/sheet.tsx +1 -0
  148. package/corpus/templates/crm/app/components/ui/skeleton.tsx +1 -0
  149. package/corpus/templates/crm/app/components/ui/switch.tsx +1 -0
  150. package/corpus/templates/crm/app/components/ui/table.tsx +1 -0
  151. package/corpus/templates/crm/app/components/ui/tabs.tsx +1 -0
  152. package/corpus/templates/crm/app/components/ui/textarea.tsx +1 -0
  153. package/corpus/templates/crm/app/components/ui/toolkit-provider.tsx +17 -0
  154. package/corpus/templates/crm/app/components/ui/tooltip.tsx +1 -0
  155. package/corpus/templates/crm/app/design-system.ts +3 -0
  156. package/corpus/templates/crm/app/entry.client.tsx +19 -0
  157. package/corpus/templates/crm/app/entry.server.tsx +10 -0
  158. package/corpus/templates/crm/app/global.css +167 -0
  159. package/corpus/templates/crm/app/hooks/use-keyboard-shortcuts.ts +206 -0
  160. package/corpus/templates/crm/app/hooks/use-navigation-state.ts +48 -0
  161. package/corpus/templates/crm/app/i18n/en-US.ts +684 -0
  162. package/corpus/templates/crm/app/i18n/index.ts +34 -0
  163. package/corpus/templates/crm/app/lib/dashboard.ts +13 -0
  164. package/corpus/templates/crm/app/lib/navigation.ts +17 -0
  165. package/corpus/templates/crm/app/lib/tab-id.ts +1 -0
  166. package/corpus/templates/crm/app/lib/types.ts +172 -0
  167. package/corpus/templates/crm/app/lib/utils.ts +1 -0
  168. package/corpus/templates/crm/app/root.tsx +93 -0
  169. package/corpus/templates/crm/app/routes/_index.tsx +26 -0
  170. package/corpus/templates/crm/app/routes/accounts.tsx +41 -0
  171. package/corpus/templates/crm/app/routes/agent.tsx +5 -0
  172. package/corpus/templates/crm/app/routes/ask.tsx +53 -0
  173. package/corpus/templates/crm/app/routes/dashboard.tsx +118 -0
  174. package/corpus/templates/crm/app/routes/lists.tsx +228 -0
  175. package/corpus/templates/crm/app/routes/opportunities.tsx +41 -0
  176. package/corpus/templates/crm/app/routes/people.tsx +41 -0
  177. package/corpus/templates/crm/app/routes/proposals.tsx +388 -0
  178. package/corpus/templates/crm/app/routes/records.$recordId.tsx +53 -0
  179. package/corpus/templates/crm/app/routes/records.tsx +64 -0
  180. package/corpus/templates/crm/app/routes/settings.tsx +118 -0
  181. package/corpus/templates/crm/app/routes/setup.tsx +308 -0
  182. package/corpus/templates/crm/app/routes/tasks.tsx +252 -0
  183. package/corpus/templates/crm/app/routes/views.tsx +683 -0
  184. package/corpus/templates/crm/app/routes.ts +19 -0
  185. package/corpus/templates/crm/app/vite-env.d.ts +6 -0
  186. package/corpus/templates/crm/changelog/2026-07-26-any-list-or-view-can-be-shown-as-a-board-grouped-by-stage-so.md +6 -0
  187. package/corpus/templates/crm/changelog/2026-07-26-crm-now-finds-likely-duplicate-records-and-tells-you-exactly.md +6 -0
  188. package/corpus/templates/crm/changelog/2026-07-26-enrichment-now-shows-what-a-run-will-cost-before-it-starts-g.md +6 -0
  189. package/corpus/templates/crm/changelog/2026-07-26-lists-turn-any-set-of-records-into-a-pipeline-with-its-own-s.md +6 -0
  190. package/corpus/templates/crm/changelog/2026-07-26-moving-a-record-or-list-entry-into-a-stage-that-was-retired-.md +6 -0
  191. package/corpus/templates/crm/changelog/2026-07-26-opening-a-record-now-shows-one-page-with-its-fields-the-list.md +6 -0
  192. package/corpus/templates/crm/changelog/2026-07-26-press-cmd-k-to-jump-to-any-record-list-view-or-setting-and-u.md +6 -0
  193. package/corpus/templates/crm/changelog/2026-07-26-provider-record-edits-now-show-an-exact-before-after-diff-an.md +6 -0
  194. package/corpus/templates/crm/changelog/2026-07-26-record-page-attribute-labels-now-read-in-full-instead-of-bei.md +6 -0
  195. package/corpus/templates/crm/changelog/2026-07-26-records-now-use-typed-fields-currency-dates-ratings-status-s.md +6 -0
  196. package/corpus/templates/crm/changelog/2026-07-26-retyping-a-value-on-the-record-page-now-replaces-it-instead-.md +6 -0
  197. package/corpus/templates/crm/changelog/2026-07-26-settings-now-has-fields-and-lists-tabs-for-adding-fields-edi.md +6 -0
  198. package/corpus/templates/crm/changelog/2026-07-26-the-proposal-review-now-shows-the-field-that-actually-change.md +6 -0
  199. package/corpus/templates/crm/changelog/2026-07-26-the-record-grid-is-now-a-real-spreadsheet-filter-sort-and-pa.md +6 -0
  200. package/corpus/templates/crm/components.json +20 -0
  201. package/corpus/templates/crm/docs/architecture/crm-contract.md +214 -0
  202. package/corpus/templates/crm/docs/features/README.md +55 -0
  203. package/corpus/templates/crm/docs/features/f1-records-and-fields.mdx +52 -0
  204. package/corpus/templates/crm/docs/features/f2-lists-pipelines-and-views.mdx +48 -0
  205. package/corpus/templates/crm/docs/features/f3-work-and-record-quality.mdx +40 -0
  206. package/corpus/templates/crm/docs/features/f4-evidence-and-chat.mdx +47 -0
  207. package/corpus/templates/crm/learnings.defaults.md +5 -0
  208. package/corpus/templates/crm/netlify.toml +11 -0
  209. package/corpus/templates/crm/package.json +61 -0
  210. package/corpus/templates/crm/public/favicon.svg +1 -0
  211. package/corpus/templates/crm/public/icon-180.svg +1 -0
  212. package/corpus/templates/crm/public/manifest.json +11 -0
  213. package/corpus/templates/crm/react-router.config.ts +7 -0
  214. package/corpus/templates/crm/server/crm/adapter.ts +32 -0
  215. package/corpus/templates/crm/server/crm/crm-field-firewall.ts +64 -0
  216. package/corpus/templates/crm/server/crm/crm-mirror.ts +721 -0
  217. package/corpus/templates/crm/server/crm/field-policy-attributes.ts +48 -0
  218. package/corpus/templates/crm/server/crm/hubspot-adapter.ts +1146 -0
  219. package/corpus/templates/crm/server/crm/native-adapter.ts +1810 -0
  220. package/corpus/templates/crm/server/crm/provider-record-link.ts +264 -0
  221. package/corpus/templates/crm/server/crm/read-through.ts +207 -0
  222. package/corpus/templates/crm/server/crm/salesforce-adapter.ts +1255 -0
  223. package/corpus/templates/crm/server/db/crm-store.ts +1112 -0
  224. package/corpus/templates/crm/server/db/index.ts +191 -0
  225. package/corpus/templates/crm/server/db/schema.ts +647 -0
  226. package/corpus/templates/crm/server/lib/crm-query.ts +1558 -0
  227. package/corpus/templates/crm/server/lib/dedupe.ts +714 -0
  228. package/corpus/templates/crm/server/lib/enrichment-cost.ts +381 -0
  229. package/corpus/templates/crm/server/lib/enrichment-slots.ts +519 -0
  230. package/corpus/templates/crm/server/lib/intelligence/default-detectors.ts +65 -0
  231. package/corpus/templates/crm/server/lib/intelligence/evidence.ts +105 -0
  232. package/corpus/templates/crm/server/lib/intelligence/keyword-detector.ts +94 -0
  233. package/corpus/templates/crm/server/lib/intelligence/smart-detector.ts +39 -0
  234. package/corpus/templates/crm/server/lib/intelligence/summary.ts +19 -0
  235. package/corpus/templates/crm/server/lib/lifecycle.ts +634 -0
  236. package/corpus/templates/crm/server/lib/provider-api.ts +64 -0
  237. package/corpus/templates/crm/server/middleware/auth.ts +4 -0
  238. package/corpus/templates/crm/server/plugins/agent-chat.ts +65 -0
  239. package/corpus/templates/crm/server/plugins/auth.ts +14 -0
  240. package/corpus/templates/crm/server/plugins/core-routes.ts +17 -0
  241. package/corpus/templates/crm/server/plugins/db.ts +738 -0
  242. package/corpus/templates/crm/server/routes/[...page].get.ts +5 -0
  243. package/corpus/templates/crm/shared/crm-attributes.ts +469 -0
  244. package/corpus/templates/crm/shared/crm-automation-recipes.ts +132 -0
  245. package/corpus/templates/crm/shared/crm-contract.ts +417 -0
  246. package/corpus/templates/crm/shared/crm-navigation.ts +190 -0
  247. package/corpus/templates/crm/shared/crm-sales-config.ts +63 -0
  248. package/corpus/templates/crm/ssr-entry.ts +11 -0
  249. package/corpus/templates/crm/tsconfig.json +24 -0
  250. package/corpus/templates/crm/vite.config.ts +15 -0
  251. package/corpus/templates/crm/vitest.config.ts +5 -0
  252. package/corpus/templates/design/app/i18n-data.ts +12 -12
  253. package/corpus/templates/design/changelog/2026-07-26-apply-design-updates-from-a-centered-top-toolbar-without-cov.md +6 -0
  254. package/dist/cli/templates-meta.d.ts.map +1 -1
  255. package/dist/cli/templates-meta.js +12 -0
  256. package/dist/cli/templates-meta.js.map +1 -1
  257. package/dist/collab/routes.d.ts +1 -1
  258. package/dist/observability/routes.d.ts +3 -3
  259. package/dist/resources/handlers.d.ts +1 -1
  260. package/dist/server/realtime-token.d.ts +1 -1
  261. package/dist/server/transcribe-voice.d.ts +1 -1
  262. package/package.json +1 -1
  263. package/src/cli/templates-meta.ts +12 -0
@@ -0,0 +1,714 @@
1
+ /**
2
+ * Duplicate-candidate detection over the sparse sub-field columns.
3
+ *
4
+ * Every predicate here reads an INDEXABLE column that the attribute writer
5
+ * already populated (`email_local`/`email_domain`, `email_root_domain`,
6
+ * `domain_root`, `name_first`/`name_last`) rather than pattern-matching a JSON
7
+ * blob at query time — `json_extract` in a WHERE clause is dialect-divergent,
8
+ * and a full scan over `crm_record_fields` is not something a grid can afford.
9
+ *
10
+ * Detection NEVER merges. It returns scored candidates with the reason each one
11
+ * matched, because a duplicate the reviewer cannot see the reason for is a
12
+ * duplicate the reviewer cannot act on — and a "known contact" silently dropped
13
+ * at the end of a workflow is exactly the failure this module exists to avoid.
14
+ */
15
+
16
+ import { accessFilter } from "@agent-native/core/sharing";
17
+ import { and, eq, inArray, isNull, ne, or, sql, type SQL } from "drizzle-orm";
18
+
19
+ import { parseEmail, rootDomainOf } from "../../shared/crm-attributes.js";
20
+ import { schema } from "../db/index.js";
21
+ import type { CrmFieldWriteDb } from "./record-fields.js";
22
+
23
+ export const CRM_DUPLICATE_MATCH_REASONS = [
24
+ "email",
25
+ "domain",
26
+ "name-and-location",
27
+ "email-root-domain",
28
+ ] as const;
29
+
30
+ export type CrmDuplicateMatchReason =
31
+ (typeof CRM_DUPLICATE_MATCH_REASONS)[number];
32
+
33
+ /**
34
+ * Per-reason confidence, combined with noisy-OR so two weak signals add up to
35
+ * more than either alone without ever reaching certainty.
36
+ *
37
+ * `email` is near-certain because a mailbox is one person. `domain` is strong
38
+ * for companies and deliberately never applied to people (colleagues share a
39
+ * company domain — that is a relationship, not a duplicate). `email-root-domain`
40
+ * is a hint only, for the same reason.
41
+ */
42
+ const REASON_CONFIDENCE: Record<CrmDuplicateMatchReason, number> = {
43
+ email: 0.95,
44
+ domain: 0.7,
45
+ "name-and-location": 0.6,
46
+ "email-root-domain": 0.2,
47
+ };
48
+
49
+ /**
50
+ * Domains that identify a mailbox provider rather than an organization. Without
51
+ * this list every personal Gmail contact is a "duplicate" of every other.
52
+ *
53
+ * ponytail: hand-list, not a public mailbox-provider dataset. Symptom of a gap
54
+ * is a low-confidence pile of `email-root-domain` candidates sharing one host;
55
+ * add the host here rather than lowering the weight.
56
+ */
57
+ const CONSUMER_EMAIL_DOMAINS = new Set([
58
+ "gmail.com",
59
+ "googlemail.com",
60
+ "outlook.com",
61
+ "hotmail.com",
62
+ "live.com",
63
+ "msn.com",
64
+ "yahoo.com",
65
+ "ymail.com",
66
+ "icloud.com",
67
+ "me.com",
68
+ "mac.com",
69
+ "aol.com",
70
+ "proton.me",
71
+ "protonmail.com",
72
+ "gmx.com",
73
+ "gmx.de",
74
+ "mail.com",
75
+ "zoho.com",
76
+ "yandex.ru",
77
+ "qq.com",
78
+ "163.com",
79
+ "126.com",
80
+ ]);
81
+
82
+ /** Legal-form suffixes that carry no identity — "Acme Inc" is "Acme". */
83
+ const COMPANY_SUFFIXES = new Set([
84
+ "inc",
85
+ "incorporated",
86
+ "llc",
87
+ "llp",
88
+ "ltd",
89
+ "limited",
90
+ "plc",
91
+ "corp",
92
+ "corporation",
93
+ "co",
94
+ "company",
95
+ "gmbh",
96
+ "ag",
97
+ "sa",
98
+ "sas",
99
+ "bv",
100
+ "nv",
101
+ "ab",
102
+ "oy",
103
+ "as",
104
+ "pty",
105
+ "pte",
106
+ "srl",
107
+ "spa",
108
+ "kk",
109
+ "holdings",
110
+ "group",
111
+ "the",
112
+ ]);
113
+
114
+ /** Rows scanned per match class. Detection is a bounded probe, never a sweep. */
115
+ export const MAX_DEDUPE_SCAN = 500;
116
+
117
+ export interface CrmDuplicateSignal {
118
+ reason: CrmDuplicateMatchReason;
119
+ /** The shared value that matched, so a reviewer can see WHY. */
120
+ value: string;
121
+ confidence: number;
122
+ }
123
+
124
+ export interface CrmDuplicateCandidate {
125
+ recordId: string;
126
+ displayName: string;
127
+ objectType: string;
128
+ kind: string;
129
+ connectionId: string;
130
+ signals: CrmDuplicateSignal[];
131
+ confidence: number;
132
+ }
133
+
134
+ export interface CrmDuplicateSeed {
135
+ recordId: string;
136
+ displayName: string;
137
+ objectType: string;
138
+ kind: string;
139
+ connectionId: string;
140
+ candidates: CrmDuplicateCandidate[];
141
+ }
142
+
143
+ interface SeedRow {
144
+ id: string;
145
+ displayName: string;
146
+ objectType: string;
147
+ kind: string;
148
+ connectionId: string;
149
+ primaryEmail: string | null;
150
+ domain: string | null;
151
+ }
152
+
153
+ interface SeedKeys {
154
+ /** Full normalized addresses, e.g. `ada@example.com`. */
155
+ emails: Set<string>;
156
+ emailRootDomains: Set<string>;
157
+ domainRoots: Set<string>;
158
+ normalizedName: string | null;
159
+ /** Canonical location string, or null when the record declares none. */
160
+ location: string | null;
161
+ }
162
+
163
+ /**
164
+ * Comparable form of a display name: diacritics folded, punctuation dropped,
165
+ * legal-form suffixes removed. This IS the fuzz — trigram similarity is not
166
+ * expressible in dialect-agnostic SQL, so candidates are narrowed by an indexed
167
+ * prefix and compared here.
168
+ */
169
+ export function normalizeCrmDisplayName(value: string): string {
170
+ const words = value
171
+ .normalize("NFKD")
172
+ .replace(/[\u0300-\u036f]/g, "")
173
+ .toLowerCase()
174
+ .replace(/[^a-z0-9]+/g, " ")
175
+ .trim()
176
+ .split(" ")
177
+ .filter(Boolean)
178
+ .filter((word) => !COMPANY_SUFFIXES.has(word));
179
+ return words.join(" ");
180
+ }
181
+
182
+ /** Canonical location key. `null` when nothing usable was declared. */
183
+ export function normalizeCrmLocation(value: unknown): string | null {
184
+ if (typeof value === "string") {
185
+ const normalized = normalizeCrmDisplayName(value);
186
+ return normalized || null;
187
+ }
188
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
189
+ const record = value as Record<string, unknown>;
190
+ const parts = ["locality", "city", "region", "state", "country"]
191
+ .map((key) => record[key])
192
+ .filter((part): part is string => typeof part === "string" && !!part.trim())
193
+ .map((part) => normalizeCrmDisplayName(part))
194
+ .filter(Boolean);
195
+ return parts.length ? parts.join(" ") : null;
196
+ }
197
+
198
+ function isOrganizationKind(kind: string): boolean {
199
+ return kind === "account";
200
+ }
201
+
202
+ async function loadSeedRows(
203
+ db: CrmFieldWriteDb,
204
+ recordIds: string[],
205
+ ): Promise<SeedRow[]> {
206
+ return db
207
+ .select({
208
+ id: schema.crmRecords.id,
209
+ displayName: schema.crmRecords.displayName,
210
+ objectType: schema.crmRecords.objectType,
211
+ kind: schema.crmRecords.kind,
212
+ connectionId: schema.crmRecords.connectionId,
213
+ primaryEmail: schema.crmRecords.primaryEmail,
214
+ domain: schema.crmRecords.domain,
215
+ })
216
+ .from(schema.crmRecords)
217
+ .where(
218
+ and(
219
+ inArray(schema.crmRecords.id, recordIds),
220
+ eq(schema.crmRecords.tombstone, false),
221
+ accessFilter(schema.crmRecords, schema.crmRecordShares),
222
+ ),
223
+ );
224
+ }
225
+
226
+ /** Current sub-field values for a set of records, one query. */
227
+ async function loadIdentityFields(
228
+ db: CrmFieldWriteDb,
229
+ recordIds: string[],
230
+ ): Promise<
231
+ Array<{
232
+ recordId: string;
233
+ emailLocal: string | null;
234
+ emailDomain: string | null;
235
+ emailRootDomain: string | null;
236
+ domainRoot: string | null;
237
+ jsonValue: string | null;
238
+ valueType: string;
239
+ }>
240
+ > {
241
+ if (!recordIds.length) return [];
242
+ return db
243
+ .select({
244
+ recordId: schema.crmRecordFields.recordId,
245
+ emailLocal: schema.crmRecordFields.emailLocal,
246
+ emailDomain: schema.crmRecordFields.emailDomain,
247
+ emailRootDomain: schema.crmRecordFields.emailRootDomain,
248
+ domainRoot: schema.crmRecordFields.domainRoot,
249
+ jsonValue: schema.crmRecordFields.jsonValue,
250
+ valueType: schema.crmRecordFields.valueType,
251
+ })
252
+ .from(schema.crmRecordFields)
253
+ .where(
254
+ and(
255
+ inArray(schema.crmRecordFields.recordId, recordIds),
256
+ isNull(schema.crmRecordFields.entryId),
257
+ isNull(schema.crmRecordFields.activeUntil),
258
+ accessFilter(schema.crmRecordFields, schema.crmRecordFieldShares),
259
+ ),
260
+ )
261
+ .limit(MAX_DEDUPE_SCAN);
262
+ }
263
+
264
+ function seedKeysFor(
265
+ row: SeedRow,
266
+ fields: Array<{
267
+ emailLocal: string | null;
268
+ emailDomain: string | null;
269
+ emailRootDomain: string | null;
270
+ domainRoot: string | null;
271
+ jsonValue: string | null;
272
+ valueType: string;
273
+ }>,
274
+ ): SeedKeys {
275
+ const keys: SeedKeys = {
276
+ emails: new Set(),
277
+ emailRootDomains: new Set(),
278
+ domainRoots: new Set(),
279
+ normalizedName: normalizeCrmDisplayName(row.displayName) || null,
280
+ location: null,
281
+ };
282
+
283
+ const addEmail = (local: string | null, domain: string | null) => {
284
+ if (!local || !domain) return;
285
+ keys.emails.add(`${local}@${domain}`);
286
+ };
287
+
288
+ const mirrored = parseEmail(row.primaryEmail);
289
+ if (mirrored.status === "parsed") {
290
+ addEmail(mirrored.local, mirrored.domain);
291
+ if (!CONSUMER_EMAIL_DOMAINS.has(mirrored.rootDomain)) {
292
+ keys.emailRootDomains.add(mirrored.rootDomain);
293
+ }
294
+ }
295
+ const mirroredDomain = rootDomainOf(row.domain);
296
+ if (mirroredDomain) keys.domainRoots.add(mirroredDomain);
297
+
298
+ for (const field of fields) {
299
+ addEmail(field.emailLocal, field.emailDomain);
300
+ if (
301
+ field.emailRootDomain &&
302
+ !CONSUMER_EMAIL_DOMAINS.has(field.emailRootDomain)
303
+ ) {
304
+ keys.emailRootDomains.add(field.emailRootDomain);
305
+ }
306
+ if (field.domainRoot) keys.domainRoots.add(field.domainRoot);
307
+ if (field.valueType === "json" && field.jsonValue && !keys.location) {
308
+ try {
309
+ keys.location = normalizeCrmLocation(JSON.parse(field.jsonValue));
310
+ } catch {
311
+ // A value the location parser cannot read is not a location match and
312
+ // is not silently treated as one; it simply contributes no key.
313
+ }
314
+ }
315
+ }
316
+
317
+ // A consumer mailbox host is a company domain for nobody.
318
+ for (const domain of [...keys.domainRoots]) {
319
+ if (CONSUMER_EMAIL_DOMAINS.has(domain)) keys.domainRoots.delete(domain);
320
+ }
321
+ return keys;
322
+ }
323
+
324
+ interface CandidateAccumulator {
325
+ signals: Map<CrmDuplicateMatchReason, CrmDuplicateSignal>;
326
+ }
327
+
328
+ function addSignal(
329
+ bucket: Map<string, CandidateAccumulator>,
330
+ recordId: string,
331
+ signal: CrmDuplicateSignal,
332
+ ): void {
333
+ const existing = bucket.get(recordId) ?? { signals: new Map() };
334
+ const prior = existing.signals.get(signal.reason);
335
+ if (!prior || prior.confidence < signal.confidence) {
336
+ existing.signals.set(signal.reason, signal);
337
+ }
338
+ bucket.set(recordId, existing);
339
+ }
340
+
341
+ /** Noisy-OR: independent weak signals accumulate but never reach certainty. */
342
+ function combineConfidence(signals: CrmDuplicateSignal[]): number {
343
+ const combined = signals.reduce(
344
+ (miss, signal) => miss * (1 - signal.confidence),
345
+ 1,
346
+ );
347
+ return Math.round((1 - combined) * 1000) / 1000;
348
+ }
349
+
350
+ export interface FindCrmDuplicatesInput {
351
+ db: CrmFieldWriteDb;
352
+ recordIds: string[];
353
+ /** Candidates below this are not returned at all. */
354
+ minConfidence?: number;
355
+ /** Candidates per seed. */
356
+ limit?: number;
357
+ }
358
+
359
+ /**
360
+ * Scored duplicate candidates for each seed record, newest signal wins per
361
+ * reason. Seeds the caller cannot read are absent from the result — an empty
362
+ * candidate list means "checked, nothing matched", never "could not check".
363
+ */
364
+ export async function findCrmDuplicateCandidates(
365
+ input: FindCrmDuplicatesInput,
366
+ ): Promise<CrmDuplicateSeed[]> {
367
+ const limit = input.limit ?? 10;
368
+ const minConfidence = input.minConfidence ?? 0.2;
369
+ const seedRows = await loadSeedRows(input.db, input.recordIds);
370
+ if (!seedRows.length) return [];
371
+
372
+ const identityFields = await loadIdentityFields(
373
+ input.db,
374
+ seedRows.map((row) => row.id),
375
+ );
376
+ const fieldsByRecord = new Map<string, typeof identityFields>();
377
+ for (const field of identityFields) {
378
+ const bucket = fieldsByRecord.get(field.recordId) ?? [];
379
+ bucket.push(field);
380
+ fieldsByRecord.set(field.recordId, bucket);
381
+ }
382
+
383
+ const seeds: CrmDuplicateSeed[] = [];
384
+ for (const row of seedRows) {
385
+ const keys = seedKeysFor(row, fieldsByRecord.get(row.id) ?? []);
386
+ const bucket = new Map<string, CandidateAccumulator>();
387
+
388
+ await matchEmails(input.db, row, keys, bucket);
389
+ await matchDomains(input.db, row, keys, bucket);
390
+ await matchNameAndLocation(input.db, row, keys, bucket);
391
+
392
+ const candidateIds = [...bucket.keys()];
393
+ const candidateRows = candidateIds.length
394
+ ? await input.db
395
+ .select({
396
+ id: schema.crmRecords.id,
397
+ displayName: schema.crmRecords.displayName,
398
+ objectType: schema.crmRecords.objectType,
399
+ kind: schema.crmRecords.kind,
400
+ connectionId: schema.crmRecords.connectionId,
401
+ })
402
+ .from(schema.crmRecords)
403
+ .where(
404
+ and(
405
+ inArray(schema.crmRecords.id, candidateIds),
406
+ eq(schema.crmRecords.tombstone, false),
407
+ accessFilter(schema.crmRecords, schema.crmRecordShares),
408
+ ),
409
+ )
410
+ : [];
411
+
412
+ const candidates: CrmDuplicateCandidate[] = [];
413
+ for (const candidate of candidateRows) {
414
+ const signals = [...(bucket.get(candidate.id)?.signals.values() ?? [])];
415
+ const confidence = combineConfidence(signals);
416
+ if (confidence < minConfidence) continue;
417
+ candidates.push({
418
+ recordId: candidate.id,
419
+ displayName: candidate.displayName,
420
+ objectType: candidate.objectType,
421
+ kind: candidate.kind,
422
+ connectionId: candidate.connectionId,
423
+ signals: signals.sort((a, b) => b.confidence - a.confidence),
424
+ confidence,
425
+ });
426
+ }
427
+ candidates.sort(
428
+ (a, b) =>
429
+ b.confidence - a.confidence || a.recordId.localeCompare(b.recordId),
430
+ );
431
+
432
+ seeds.push({
433
+ recordId: row.id,
434
+ displayName: row.displayName,
435
+ objectType: row.objectType,
436
+ kind: row.kind,
437
+ connectionId: row.connectionId,
438
+ candidates: candidates.slice(0, limit),
439
+ });
440
+ }
441
+ return seeds;
442
+ }
443
+
444
+ /**
445
+ * Records of the same object type that are not the seed and not tombstoned.
446
+ *
447
+ * Access scoping is deliberately NOT folded in here: every caller spells its own
448
+ * `accessFilter` out at the query, because a scan that leaks other people's
449
+ * records is exactly the bug this shape would hide behind one helper name.
450
+ */
451
+ function comparableRecords(seed: SeedRow): SQL {
452
+ return and(
453
+ eq(schema.crmRecords.objectType, seed.objectType),
454
+ ne(schema.crmRecords.id, seed.id),
455
+ eq(schema.crmRecords.tombstone, false),
456
+ ) as SQL;
457
+ }
458
+
459
+ async function matchEmails(
460
+ db: CrmFieldWriteDb,
461
+ seed: SeedRow,
462
+ keys: SeedKeys,
463
+ bucket: Map<string, CandidateAccumulator>,
464
+ ): Promise<void> {
465
+ if (!keys.emails.size && !keys.emailRootDomains.size) return;
466
+
467
+ const emailPairs = [...keys.emails].map((email) => {
468
+ const at = email.lastIndexOf("@");
469
+ return { local: email.slice(0, at), domain: email.slice(at + 1) };
470
+ });
471
+
472
+ const shapes: SQL[] = [];
473
+ if (emailPairs.length) {
474
+ shapes.push(
475
+ or(
476
+ ...emailPairs.map(
477
+ (pair) =>
478
+ and(
479
+ eq(schema.crmRecordFields.emailLocal, pair.local),
480
+ eq(schema.crmRecordFields.emailDomain, pair.domain),
481
+ ) as SQL,
482
+ ),
483
+ ) as SQL,
484
+ );
485
+ }
486
+ if (keys.emailRootDomains.size) {
487
+ shapes.push(
488
+ inArray(schema.crmRecordFields.emailRootDomain, [
489
+ ...keys.emailRootDomains,
490
+ ]) as SQL,
491
+ );
492
+ }
493
+
494
+ const rows = await db
495
+ .select({
496
+ recordId: schema.crmRecordFields.recordId,
497
+ emailLocal: schema.crmRecordFields.emailLocal,
498
+ emailDomain: schema.crmRecordFields.emailDomain,
499
+ emailRootDomain: schema.crmRecordFields.emailRootDomain,
500
+ })
501
+ .from(schema.crmRecordFields)
502
+ .innerJoin(
503
+ schema.crmRecords,
504
+ eq(schema.crmRecords.id, schema.crmRecordFields.recordId),
505
+ )
506
+ .where(
507
+ and(
508
+ or(...shapes),
509
+ isNull(schema.crmRecordFields.entryId),
510
+ isNull(schema.crmRecordFields.activeUntil),
511
+ accessFilter(schema.crmRecordFields, schema.crmRecordFieldShares),
512
+ accessFilter(schema.crmRecords, schema.crmRecordShares),
513
+ comparableRecords(seed),
514
+ ),
515
+ )
516
+ .limit(MAX_DEDUPE_SCAN);
517
+
518
+ for (const row of rows) {
519
+ const email =
520
+ row.emailLocal && row.emailDomain
521
+ ? `${row.emailLocal}@${row.emailDomain}`
522
+ : null;
523
+ if (email && keys.emails.has(email)) {
524
+ addSignal(bucket, row.recordId, {
525
+ reason: "email",
526
+ value: email,
527
+ confidence: REASON_CONFIDENCE.email,
528
+ });
529
+ continue;
530
+ }
531
+ if (
532
+ row.emailRootDomain &&
533
+ keys.emailRootDomains.has(row.emailRootDomain) &&
534
+ !isOrganizationKind(seed.kind)
535
+ ) {
536
+ addSignal(bucket, row.recordId, {
537
+ reason: "email-root-domain",
538
+ value: row.emailRootDomain,
539
+ confidence: REASON_CONFIDENCE["email-root-domain"],
540
+ });
541
+ }
542
+ }
543
+
544
+ // The denormalized mirror column carries an address for records whose email
545
+ // attribute was never mirrored; skipping it would miss the obvious duplicate.
546
+ if (keys.emails.size) {
547
+ const mirrorRows = await db
548
+ .select({
549
+ id: schema.crmRecords.id,
550
+ primaryEmail: schema.crmRecords.primaryEmail,
551
+ })
552
+ .from(schema.crmRecords)
553
+ .where(
554
+ and(
555
+ inArray(sql`lower(${schema.crmRecords.primaryEmail})`, [
556
+ ...keys.emails,
557
+ ]),
558
+ accessFilter(schema.crmRecords, schema.crmRecordShares),
559
+ comparableRecords(seed),
560
+ ),
561
+ )
562
+ .limit(MAX_DEDUPE_SCAN);
563
+ for (const row of mirrorRows) {
564
+ const parsed = parseEmail(row.primaryEmail);
565
+ if (parsed.status !== "parsed") continue;
566
+ addSignal(bucket, row.id, {
567
+ reason: "email",
568
+ value: `${parsed.local}@${parsed.domain}`,
569
+ confidence: REASON_CONFIDENCE.email,
570
+ });
571
+ }
572
+ }
573
+ }
574
+
575
+ async function matchDomains(
576
+ db: CrmFieldWriteDb,
577
+ seed: SeedRow,
578
+ keys: SeedKeys,
579
+ bucket: Map<string, CandidateAccumulator>,
580
+ ): Promise<void> {
581
+ // A shared company domain identifies an ORGANIZATION. Two people at the same
582
+ // domain are colleagues; reporting that as a duplicate is how a real contact
583
+ // gets merged away.
584
+ if (!keys.domainRoots.size || !isOrganizationKind(seed.kind)) return;
585
+ const roots = [...keys.domainRoots];
586
+
587
+ const fieldRows = await db
588
+ .select({
589
+ recordId: schema.crmRecordFields.recordId,
590
+ domainRoot: schema.crmRecordFields.domainRoot,
591
+ })
592
+ .from(schema.crmRecordFields)
593
+ .innerJoin(
594
+ schema.crmRecords,
595
+ eq(schema.crmRecords.id, schema.crmRecordFields.recordId),
596
+ )
597
+ .where(
598
+ and(
599
+ inArray(schema.crmRecordFields.domainRoot, roots),
600
+ isNull(schema.crmRecordFields.entryId),
601
+ isNull(schema.crmRecordFields.activeUntil),
602
+ accessFilter(schema.crmRecordFields, schema.crmRecordFieldShares),
603
+ accessFilter(schema.crmRecords, schema.crmRecordShares),
604
+ comparableRecords(seed),
605
+ ),
606
+ )
607
+ .limit(MAX_DEDUPE_SCAN);
608
+ for (const row of fieldRows) {
609
+ if (!row.domainRoot) continue;
610
+ addSignal(bucket, row.recordId, {
611
+ reason: "domain",
612
+ value: row.domainRoot,
613
+ confidence: REASON_CONFIDENCE.domain,
614
+ });
615
+ }
616
+
617
+ const mirrorRows = await db
618
+ .select({ id: schema.crmRecords.id, domain: schema.crmRecords.domain })
619
+ .from(schema.crmRecords)
620
+ .where(
621
+ and(
622
+ inArray(sql`lower(${schema.crmRecords.domain})`, roots),
623
+ accessFilter(schema.crmRecords, schema.crmRecordShares),
624
+ comparableRecords(seed),
625
+ ),
626
+ )
627
+ .limit(MAX_DEDUPE_SCAN);
628
+ for (const row of mirrorRows) {
629
+ const root = rootDomainOf(row.domain);
630
+ if (!root) continue;
631
+ addSignal(bucket, row.id, {
632
+ reason: "domain",
633
+ value: root,
634
+ confidence: REASON_CONFIDENCE.domain,
635
+ });
636
+ }
637
+ }
638
+
639
+ async function matchNameAndLocation(
640
+ db: CrmFieldWriteDb,
641
+ seed: SeedRow,
642
+ keys: SeedKeys,
643
+ bucket: Map<string, CandidateAccumulator>,
644
+ ): Promise<void> {
645
+ if (!keys.normalizedName) return;
646
+ const firstWord = keys.normalizedName.split(" ")[0];
647
+ if (!firstWord) return;
648
+
649
+ // `lower(...)` on both sides: SQLite LIKE folds ASCII case, Postgres LIKE
650
+ // does not, and a match that differs by dialect is a bug.
651
+ //
652
+ // ponytail: a leading-wildcard LIKE cannot use an index, so this is a bounded
653
+ // scan capped at MAX_DEDUPE_SCAN rows of the same object type. The upgrade is
654
+ // a normalized-name column written by the attribute writer; add it when the
655
+ // record count makes this probe show up in a slow query log.
656
+ const rows = await db
657
+ .select({
658
+ id: schema.crmRecords.id,
659
+ displayName: schema.crmRecords.displayName,
660
+ })
661
+ .from(schema.crmRecords)
662
+ .where(
663
+ and(
664
+ sql`lower(${schema.crmRecords.displayName}) like ${`%${firstWord}%`}`,
665
+ accessFilter(schema.crmRecords, schema.crmRecordShares),
666
+ comparableRecords(seed),
667
+ ),
668
+ )
669
+ .limit(MAX_DEDUPE_SCAN);
670
+ if (!rows.length) return;
671
+
672
+ const nameMatches = rows.filter(
673
+ (row) => normalizeCrmDisplayName(row.displayName) === keys.normalizedName,
674
+ );
675
+ if (!nameMatches.length) return;
676
+
677
+ const locations = await loadIdentityFields(
678
+ db,
679
+ nameMatches.map((row) => row.id),
680
+ );
681
+ const locationByRecord = new Map<string, string | null>();
682
+ for (const field of locations) {
683
+ if (field.valueType !== "json" || !field.jsonValue) continue;
684
+ if (locationByRecord.has(field.recordId)) continue;
685
+ try {
686
+ locationByRecord.set(
687
+ field.recordId,
688
+ normalizeCrmLocation(JSON.parse(field.jsonValue)),
689
+ );
690
+ } catch {
691
+ locationByRecord.set(field.recordId, null);
692
+ }
693
+ }
694
+
695
+ for (const row of nameMatches) {
696
+ // Name alone is not enough — two "John Smith" people are common. The pair
697
+ // only counts when the location agrees, or when neither side declares one
698
+ // for an organization (where the name itself is far more distinguishing).
699
+ const candidateLocation = locationByRecord.get(row.id) ?? null;
700
+ const agrees =
701
+ keys.location !== null && candidateLocation !== null
702
+ ? keys.location === candidateLocation
703
+ : isOrganizationKind(seed.kind);
704
+ if (!agrees) continue;
705
+ addSignal(bucket, row.id, {
706
+ reason: "name-and-location",
707
+ value:
708
+ keys.location && candidateLocation
709
+ ? `${keys.normalizedName} @ ${keys.location}`
710
+ : keys.normalizedName,
711
+ confidence: REASON_CONFIDENCE["name-and-location"],
712
+ });
713
+ }
714
+ }