fullstackgtm 0.11.0 → 0.11.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,36 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
  The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
7
7
 
8
+ ## [0.11.1] — 2026-06-11
9
+
10
+ Write-path integrity: fixes our own dupe faucets, found auditing the apply
11
+ path for the new [CRM-health lifecycle](./docs/crm-health-lifecycle.md) doc.
12
+
13
+ ### Added
14
+
15
+ - **docs/crm-health-lifecycle.md**: the full CRUD lifecycle for keeping a
16
+ CRM healthy — Prevent → Detect → Remediate → Verify/Attribute — grounded
17
+ in verified platform behavior (HubSpot/Salesforce dedupe and merge
18
+ support) and the build order toward governed merges and a resolve gate.
19
+
20
+ ### Fixed
21
+
22
+ - **`create:<Name>` is now resolve-first**: it links to an unambiguous
23
+ existing company/account instead of creating, refuses on ambiguity, and
24
+ creates only on a confirmed miss — and never creates the same name twice
25
+ within one apply run (HubSpot search is eventually consistent, so the
26
+ same-run record is authoritative).
27
+ - **HubSpot compare-and-set on `link_record` is no longer blind**:
28
+ `readField("deal"|"contact", id, "accountId")` reads the actual company
29
+ association (it is not a property), so replaying an applied link returns
30
+ `conflict` instead of silently re-creating companies.
31
+ - **`create_task` is idempotent**: the operation id is stamped into the
32
+ task body as a token and pre-checked (fail-open), so replayed plans no
33
+ longer duplicate merge-review tasks on either provider.
34
+ - **`duplicate-account-domain` normalizes domains** the same way merge
35
+ does — `www.acme.com`, `https://acme.com/about`, and `acme.com` now
36
+ group as duplicates.
37
+
8
38
  ## [0.11.0] — 2026-06-11
9
39
 
10
40
  Canonicalizes the paths discovered dogfooding against a real portal: the
@@ -22,6 +22,10 @@ export function createHubspotConnector(options) {
22
22
  const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
23
23
  const fetchImpl = options.fetchImpl ?? fetch;
24
24
  const mappings = options.fieldMappings;
25
+ // create:<Name> dedup within one connector lifetime (one apply run): the
26
+ // search API is eventually consistent, so a just-created company is
27
+ // invisible to search — this map is the authoritative same-run record.
28
+ const createdCompaniesByName = new Map();
25
29
  async function request(path, init = {}) {
26
30
  const token = await options.getAccessToken();
27
31
  let response;
@@ -286,21 +290,48 @@ export function createHubspotConnector(options) {
286
290
  if (!companyId) {
287
291
  return { operationId: operation.id, status: "skipped", detail: "link_record needs a target company id." };
288
292
  }
289
- // `create:<Name>` creates the company first, then links the approved
290
- // value spells out exactly what will happen, so creation stays inside
291
- // the typed, human-approved operation model.
293
+ // `create:<Name>` is resolve-first: link to an existing company when one
294
+ // unambiguously matches, refuse on ambiguity, create only on a confirmed
295
+ // miss — and never create the same name twice within one apply run
296
+ // (HubSpot's search API is eventually consistent, so a just-created
297
+ // record is invisible to search for several seconds).
292
298
  let createdCompanyName = null;
299
+ let resolvedExisting = false;
293
300
  if (companyId.startsWith("create:")) {
294
301
  const name = companyId.slice("create:".length).trim();
295
302
  if (!name) {
296
303
  return { operationId: operation.id, status: "skipped", detail: "create: needs a company name (create:<Name>)." };
297
304
  }
298
- const created = await request(`/crm/v3/objects/companies`, {
299
- method: "POST",
300
- body: JSON.stringify({ properties: { name } }),
301
- });
302
- companyId = String(created.id);
303
- createdCompanyName = name;
305
+ const nameKey = name.toLowerCase();
306
+ const alreadyCreated = createdCompaniesByName.get(nameKey);
307
+ if (alreadyCreated) {
308
+ companyId = alreadyCreated;
309
+ resolvedExisting = true;
310
+ }
311
+ else {
312
+ const matches = await searchCompaniesByName(name);
313
+ if (matches.length > 1) {
314
+ return {
315
+ operationId: operation.id,
316
+ status: "skipped",
317
+ detail: `create:${name} is ambiguous — ${matches.length} companies already named "${name}" (ids ${matches.join(", ")}). Link an explicit company id instead.`,
318
+ };
319
+ }
320
+ if (matches.length === 1) {
321
+ companyId = matches[0];
322
+ resolvedExisting = true;
323
+ createdCompaniesByName.set(nameKey, companyId);
324
+ }
325
+ else {
326
+ const created = await request(`/crm/v3/objects/companies`, {
327
+ method: "POST",
328
+ body: JSON.stringify({ properties: { name } }),
329
+ });
330
+ companyId = String(created.id);
331
+ createdCompanyName = name;
332
+ createdCompaniesByName.set(nameKey, companyId);
333
+ }
334
+ }
304
335
  }
305
336
  await request(`/crm/v4/objects/${fromPath}/${encodeURIComponent(operation.objectId)}/associations/default/companies/${encodeURIComponent(companyId)}`, { method: "PUT" });
306
337
  return {
@@ -308,10 +339,24 @@ export function createHubspotConnector(options) {
308
339
  status: "applied",
309
340
  detail: createdCompanyName
310
341
  ? `Created company "${createdCompanyName}" (${companyId}) and linked ${fromPath}/${operation.objectId} to it.`
311
- : `Linked ${fromPath}/${operation.objectId} to company ${companyId}.`,
342
+ : resolvedExisting
343
+ ? `Linked ${fromPath}/${operation.objectId} to existing company ${companyId} (resolved by name, nothing created).`
344
+ : `Linked ${fromPath}/${operation.objectId} to company ${companyId}.`,
312
345
  providerData: { companyId, ...(createdCompanyName ? { createdCompany: true } : {}) },
313
346
  };
314
347
  }
348
+ /** Exact-name company lookup for resolve-first creates. Returns matching ids (max 3 fetched). */
349
+ async function searchCompaniesByName(name) {
350
+ const data = await request(`/crm/v3/objects/companies/search`, {
351
+ method: "POST",
352
+ body: JSON.stringify({
353
+ filterGroups: [{ filters: [{ propertyName: "name", operator: "EQ", value: name }] }],
354
+ properties: ["name"],
355
+ limit: 3,
356
+ }),
357
+ });
358
+ return (data?.results ?? []).map((row) => String(row.id));
359
+ }
315
360
  async function createTask(operation) {
316
361
  const associationTypeId = TASK_ASSOCIATION_TYPE_IDS[operation.objectType];
317
362
  if (associationTypeId === undefined) {
@@ -322,7 +367,34 @@ export function createHubspotConnector(options) {
322
367
  };
323
368
  }
324
369
  const subject = operation.field ? humanizeField(operation.field) : "Follow up";
325
- const body = String(operation.afterValue ?? operation.reason ?? "");
370
+ // The operation id doubles as an idempotency token: it is stamped into
371
+ // the task body and pre-checked so a replayed plan does not create the
372
+ // same task twice. Fail-open — a search hiccup must not block the apply.
373
+ const token = `fsgtm ${operation.id.replace(/^op_/, "")}`;
374
+ try {
375
+ const existing = await request(`/crm/v3/objects/tasks/search`, {
376
+ method: "POST",
377
+ body: JSON.stringify({
378
+ filterGroups: [
379
+ { filters: [{ propertyName: "hs_task_body", operator: "CONTAINS_TOKEN", value: token.split(" ")[1] }] },
380
+ ],
381
+ limit: 1,
382
+ }),
383
+ });
384
+ const hit = (existing?.results ?? [])[0];
385
+ if (hit?.id) {
386
+ return {
387
+ operationId: operation.id,
388
+ status: "skipped",
389
+ detail: `Task for this operation already exists (task ${hit.id}); not creating a duplicate.`,
390
+ providerData: { id: hit.id, existing: true },
391
+ };
392
+ }
393
+ }
394
+ catch {
395
+ // fall through to create
396
+ }
397
+ const body = `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`;
326
398
  const response = await request(`/crm/v3/objects/tasks`, {
327
399
  method: "POST",
328
400
  body: JSON.stringify({
@@ -405,6 +477,13 @@ export function createHubspotConnector(options) {
405
477
  if (!objectPath || !mappingType) {
406
478
  throw new Error(`Field reads are only supported for accounts, contacts, and deals.`);
407
479
  }
480
+ // accountId is an association in HubSpot, not a property — without this
481
+ // branch the compare-and-set on link_record reads null and passes blind.
482
+ if (field === "accountId" && (objectType === "deal" || objectType === "contact")) {
483
+ const data = await request(`/crm/v4/objects/${objectPath}/${encodeURIComponent(objectId)}/associations/companies?limit=1`);
484
+ const first = (data?.results ?? [])[0];
485
+ return first?.toObjectId !== undefined ? String(first.toObjectId) : null;
486
+ }
408
487
  const defaults = HUBSPOT_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
409
488
  const property = mappedField(mappings, mappingType, field, defaults[field] ?? field);
410
489
  const data = await request(`/crm/v3/objects/${objectPath}/${encodeURIComponent(objectId)}?properties=${encodeURIComponent(property)}`);
@@ -23,6 +23,8 @@ export function createSalesforceConnector(options) {
23
23
  const apiVersion = options.apiVersion ?? DEFAULT_API_VERSION;
24
24
  const fetchImpl = options.fetchImpl ?? fetch;
25
25
  const mappings = options.fieldMappings;
26
+ // create:<Name> dedup within one connector lifetime (one apply run).
27
+ const createdAccountsByName = new Map();
26
28
  async function request(path, init = {}) {
27
29
  const connection = await options.getConnection();
28
30
  const url = path.startsWith("http")
@@ -228,11 +230,28 @@ export function createSalesforceConnector(options) {
228
230
  detail: "Tasks can be attached to accounts, contacts, and deals.",
229
231
  };
230
232
  }
233
+ // Idempotency: the operation id is stamped into the Description and
234
+ // pre-checked, so replaying a plan does not duplicate tasks. Fail-open.
235
+ const token = `fsgtm:${operation.id}`;
236
+ try {
237
+ const existing = await query(`SELECT Id FROM Task WHERE Description LIKE '%${token.replace(/'/g, "\\'")}%' LIMIT 1`);
238
+ if (existing.length > 0) {
239
+ return {
240
+ operationId: operation.id,
241
+ status: "skipped",
242
+ detail: `Task for this operation already exists (task ${existing[0].Id}); not creating a duplicate.`,
243
+ providerData: { id: String(existing[0].Id), existing: true },
244
+ };
245
+ }
246
+ }
247
+ catch {
248
+ // fall through to create
249
+ }
231
250
  const response = await request(`/services/data/${apiVersion}/sobjects/Task`, {
232
251
  method: "POST",
233
252
  body: JSON.stringify({
234
253
  Subject: operation.field ? humanizeField(operation.field) : "Follow up",
235
- Description: String(operation.afterValue ?? operation.reason ?? ""),
254
+ Description: `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`,
236
255
  Status: "Not Started",
237
256
  Priority: "Normal",
238
257
  ...reference,
@@ -269,21 +288,50 @@ export function createSalesforceConnector(options) {
269
288
  // link_record on a deal is just setting AccountId in Salesforce.
270
289
  return await setField(operation);
271
290
  case "link_record": {
272
- // `create:<Name>` creates the Account first, then links — creation
273
- // stays inside the typed, human-approved operation model.
291
+ // `create:<Name>` is resolve-first: link to an unambiguous existing
292
+ // Account, refuse on ambiguity, create only on a confirmed miss —
293
+ // and never create the same name twice within one apply run.
274
294
  const value = String(operation.afterValue ?? "");
275
295
  if (value.startsWith("create:")) {
276
296
  const name = value.slice("create:".length).trim();
277
297
  if (!name) {
278
298
  return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
279
299
  }
280
- const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
281
- method: "POST",
282
- body: JSON.stringify({ Name: name }),
283
- });
284
- const result = await setField({ ...operation, operation: "set_field", afterValue: String(created.id) });
300
+ const nameKey = name.toLowerCase();
301
+ let accountId = createdAccountsByName.get(nameKey);
302
+ let createdNew = false;
303
+ if (!accountId) {
304
+ const soqlName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
305
+ const matches = await query(`SELECT Id FROM Account WHERE Name = '${soqlName}' LIMIT 3`);
306
+ if (matches.length > 1) {
307
+ return {
308
+ operationId: operation.id,
309
+ status: "skipped",
310
+ detail: `create:${name} is ambiguous — ${matches.length} accounts already named "${name}". Link an explicit account id instead.`,
311
+ };
312
+ }
313
+ if (matches.length === 1) {
314
+ accountId = String(matches[0].Id);
315
+ }
316
+ else {
317
+ const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
318
+ method: "POST",
319
+ body: JSON.stringify({ Name: name }),
320
+ });
321
+ accountId = String(created.id);
322
+ createdNew = true;
323
+ }
324
+ createdAccountsByName.set(nameKey, accountId);
325
+ }
326
+ const result = await setField({ ...operation, operation: "set_field", afterValue: accountId });
285
327
  return result.status === "applied"
286
- ? { ...result, detail: `Created account "${name}" (${created.id}) and linked ${operation.objectType}/${operation.objectId} to it.`, providerData: { accountId: String(created.id), createdAccount: true } }
328
+ ? {
329
+ ...result,
330
+ detail: createdNew
331
+ ? `Created account "${name}" (${accountId}) and linked ${operation.objectType}/${operation.objectId} to it.`
332
+ : `Linked ${operation.objectType}/${operation.objectId} to existing account ${accountId} (resolved by name, nothing created).`,
333
+ providerData: { accountId, ...(createdNew ? { createdAccount: true } : {}) },
334
+ }
287
335
  : result;
288
336
  }
289
337
  return await setField({ ...operation, operation: "set_field" });
package/dist/merge.d.ts CHANGED
@@ -42,6 +42,7 @@ export type MergeReport = {
42
42
  conflicts: MergeConflict[];
43
43
  suggestions: MergeSuggestion[];
44
44
  };
45
+ export declare function normalizeDomain(domain?: string): string | undefined;
45
46
  export declare function mergeSnapshots(snapshots: CanonicalGtmSnapshot[]): {
46
47
  snapshot: CanonicalGtmSnapshot;
47
48
  report: MergeReport;
package/dist/merge.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const CONFLICT_IGNORED_FIELDS = new Set([
2
2
  "id", "provider", "crmId", "identities", "raw", "lastSyncAt", "lastActivityAt", "ownerId", "accountId",
3
3
  ]);
4
- function normalizeDomain(domain) {
4
+ export function normalizeDomain(domain) {
5
5
  if (!domain)
6
6
  return undefined;
7
7
  return domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/.*$/, "") || undefined;
package/dist/rules.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { normalizeDomain } from "./merge.js";
1
2
  /**
2
3
  * Placeholder used as `afterValue` when the right value is a human decision
3
4
  * (e.g. which owner to assign). Apply orchestration refuses to write these
@@ -316,7 +317,7 @@ export const duplicateAccountDomainRule = {
316
317
  evaluate: ({ snapshot }) => {
317
318
  const findings = [];
318
319
  const operations = [];
319
- for (const [domain, accounts] of duplicateGroups(snapshot.accounts, (account) => account.domain)) {
320
+ for (const [domain, accounts] of duplicateGroups(snapshot.accounts, (account) => normalizeDomain(account.domain))) {
320
321
  const anchor = accounts[0];
321
322
  findings.push({
322
323
  id: auditFindingId("duplicate-account-domain", anchor.id),
@@ -0,0 +1,135 @@
1
+ # The CRM-health CRUD lifecycle: no new dupes
2
+
3
+ How fullstackgtm keeps a CRM healthy over time — not just episodically
4
+ audited. Grounded in dogfooding against a real portal (an outreach sync
5
+ tripled five open deals; our own `create:` path nearly minted a duplicate
6
+ company) and in what the platforms actually support today (verified 2026-06).
7
+
8
+ ## The model: Prevent → Detect → Remediate → Verify/Attribute
9
+
10
+ Every mature dedupe stack (RingLead/ZoomInfo Ops, Insycle, Openprise,
11
+ Dedupely) converged on three layers, because each one leaks:
12
+
13
+ 1. **Prevent** at write time — unique keys, upserts, point-of-entry gates.
14
+ Leaks because: HubSpot does not dedupe companies created via API at all,
15
+ deals have no native dedupe on any platform, and Salesforce duplicate
16
+ rules skip standard lead conversion.
17
+ 2. **Detect** continuously — scheduled scans, because prevention leaked.
18
+ 3. **Remediate** via survivor-rule-driven merge — irreversible on both
19
+ platforms, which is why preview/dry-run is table stakes.
20
+
21
+ fullstackgtm adds the layer the industry mostly lacks: **Verify/Attribute** —
22
+ typed, approved operations with compare-and-set, readback, drift diffs, and
23
+ record-source provenance that names *which writer* created the mess, so you
24
+ fix the faucet instead of mopping the puddle.
25
+
26
+ ## C — Create: gate the faucet
27
+
28
+ **Platform facts to exploit before building anything:**
29
+
30
+ | | HubSpot | Salesforce |
31
+ | --- | --- | --- |
32
+ | Contacts | API create with existing email → **409 with existing ID** (a free find-or-create) | Duplicate Rules fire on API writes (`DUPLICATES_DETECTED`); Alert-action rules bypassable via `DuplicateRuleHeader`, Block never |
33
+ | Companies/Accounts | **No API dedupe** (UI/import domain-dedupe does not apply to API) | Same duplicate-rule machinery |
34
+ | Deals/Opps | **No native dedupe anywhere** | No standard duplicate rule shipped |
35
+ | Idempotent writes | `batch/upsert` keyed on unique-value custom properties (≤10/object) | Upsert by External ID field — explicitly idempotent |
36
+ | Leak paths | API company creates; anything without email | Standard lead conversion, Quick Create, undelete |
37
+
38
+ **Our primitives and their duties:**
39
+ - `create:<Name>` link values must be **resolve-first**: search the live CRM
40
+ (and the current plan run) for an existing record before creating; link to
41
+ a unique match, refuse on ambiguity, create only on a confirmed miss.
42
+ HubSpot's search API is eventually consistent (~5–10s), so same-run
43
+ creations are deduped in memory, not via search.
44
+ - A standalone **`resolve` gate** (planned, 0.13): given a candidate
45
+ record, return existing match(es) or "safe to create" — for the CLI, the
46
+ library, MCP, and any external writer (sync jobs, agents, webhook
47
+ handlers). Identity keys are the ones the package already uses:
48
+ contact email, normalized account domain, and the open-deal key
49
+ (account + normalized name).
50
+ - **Stamp provenance on our own creates** (HubSpot allows integrations to
51
+ set `hs_object_source_detail_2/3` at create time).
52
+ - Recommend native config in `doctor`/audit: Salesforce duplicate rules
53
+ active? HubSpot unique-value properties defined? Prevention posture is
54
+ auditable configuration, not just record state.
55
+
56
+ ## R — Read: watch continuously, attribute the source
57
+
58
+ - The regression primitive exists: `fullstackgtm diff --before a.json
59
+ --after b.json --fail-on-new-findings` exits 2 when a (rule, record) pair
60
+ fires that didn't before. "New" is a stable finding id — the hash of
61
+ (ruleId, objectId).
62
+ - **The nightly watch recipe** ("CRM CI"): scheduled
63
+ `snapshot → audit → diff` against yesterday's snapshot, alert on exit 2.
64
+ - **Attribution** (planned, 0.13): capture HubSpot's read-only
65
+ `hs_object_source`, `hs_object_source_label`, `hs_object_source_id` into
66
+ the canonical model so duplicate findings can say *"all five created by
67
+ integration X"*. The fix for recurring dupes is upstream, in the writer.
68
+ - Incremental reads (`snapshot --since`) exist for all three connectors;
69
+ caveats: HubSpot deltas carry no associations and cap at 10k per object,
70
+ Stripe deltas catch creations only.
71
+
72
+ ## U — Update: governed merge
73
+
74
+ **Platform facts:** HubSpot's v3 merge endpoint
75
+ (`POST /crm/v3/objects/{type}/merge`) supports contacts, companies,
76
+ **deals**, and tickets today (the 2019 "no deal merge API" changelog is
77
+ obsolete). Merges are pairwise, the loser is auto-archived, primary's
78
+ values win, **merges cannot be undone**, and a record stops merging after
79
+ 250 cumulative merges. Salesforce merge is SOAP/Apex only (no REST), only
80
+ Lead/Contact/Account/Case, max 3 records per call.
81
+
82
+ **The gap:** our three duplicate rules (`duplicate-account-domain`,
83
+ `duplicate-contact-email`, `duplicate-open-deal`) detect groups but emit
84
+ only merge-review *tasks* — detection without remediation.
85
+
86
+ **The plan (0.12):** a `merge_records` operation type —
87
+ `requires_human_survivor_selection` placeholder, survivor heuristics in
88
+ `suggest` (ordered, evidence-based: most engagements → oldest → most
89
+ complete, each with a written reason), high risk, approval required, with
90
+ the irreversibility called out in the plan text. The dry-run plan is the
91
+ preview every commercial tool charges for; the pre-apply snapshot is the
92
+ loser-record archive. HubSpot first; Salesforce merge documented as
93
+ unsupported until an Apex path justifies itself.
94
+
95
+ ## D — Delete/Archive: the exit ramp
96
+
97
+ - `archive_record` exists in both connectors (HubSpot DELETE = archive,
98
+ restorable ~90 days; Salesforce DELETE = recycle bin) but no built-in
99
+ rule emits it. It is the endpoint for reviewed orphans; merge losers are
100
+ archived by the merge APIs themselves.
101
+ - All destructive operations stay high-risk, approval-required, and behind
102
+ compare-and-set where the platform exposes a readable before-value.
103
+
104
+ ## Write-path integrity rules (our own faucet)
105
+
106
+ Lessons from auditing our own apply path:
107
+
108
+ 1. Field writes (`set_field`/`clear_field`/`link_record`) are protected by
109
+ compare-and-set: the live value is read back and a drifted value returns
110
+ `conflict` without writing.
111
+ 2. CAS is only as good as `readField` — for HubSpot deals, `accountId` is
112
+ an association, not a property, and must be read via the associations
113
+ API or CAS silently passes on replay (fixed in 0.11.1).
114
+ 3. `create:` values are resolve-first and deduped within a plan run
115
+ (0.11.1); `create_task` operations carry an idempotency token in the
116
+ task body and pre-check for it (0.11.1, fail-open on search errors).
117
+ 4. Plan replays are blocked at the store (`--plan-id` of an applied plan
118
+ refuses); the `--plan <file>` path relies on CAS — prefer the store.
119
+
120
+ ## The operating cadence
121
+
122
+ **Gate** creates (resolve-first, upserts, native rules on) →
123
+ **Watch** nightly (snapshot, diff, exit-2 alert) →
124
+ **Fix** governed (audit → suggest → approve → apply, incl. merge) →
125
+ **Verify** (readback, re-audit, drift report) →
126
+ **Attribute** (provenance names the writer; fix the faucet).
127
+
128
+ ## Build order
129
+
130
+ | Release | Scope |
131
+ | --- | --- |
132
+ | 0.11.1 | Fix our own faucet: resolve-first `create:` + plan-scoped dedup, HubSpot association-aware CAS for `link_record`, domain normalization in `duplicate-account-domain`, `create_task` idempotency token |
133
+ | 0.12 | `merge_records` (HubSpot contacts/companies/deals) + survivor suggestions; rules emit governed merges for dupe groups |
134
+ | 0.13 | `resolve` gate (CLI/lib/MCP), provenance capture + attribution in findings, prevention-posture checks |
135
+ | docs | The nightly watch recipe (existing flags, documented as CRM CI) |
package/llms.txt CHANGED
@@ -15,6 +15,7 @@ at/above `--fail-on`.
15
15
  - [README](https://github.com/fullstackgtm/core/blob/main/README.md): install, five-minute loop, auth ladder, MCP setup, programmatic use
16
16
  - [INSTALL_FOR_AGENTS](https://github.com/fullstackgtm/core/blob/main/INSTALL_FOR_AGENTS.md): deterministic install-and-verify steps with expected outputs
17
17
  - [API reference](https://github.com/fullstackgtm/core/blob/main/docs/api.md): semver-covered surfaces — canonical model, rule interface, plan/apply contract, connector contract, config, CLI, MCP tools
18
+ - [CRM-health lifecycle](https://github.com/fullstackgtm/core/blob/main/docs/crm-health-lifecycle.md): the Prevent → Detect → Remediate → Verify/Attribute model; no-new-dupes design
18
19
  - [CHANGELOG](https://github.com/fullstackgtm/core/blob/main/CHANGELOG.md): release history
19
20
 
20
21
  ## Key invariants
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM",
@@ -54,6 +54,10 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
54
54
  const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
55
55
  const fetchImpl = options.fetchImpl ?? fetch;
56
56
  const mappings = options.fieldMappings;
57
+ // create:<Name> dedup within one connector lifetime (one apply run): the
58
+ // search API is eventually consistent, so a just-created company is
59
+ // invisible to search — this map is the authoritative same-run record.
60
+ const createdCompaniesByName = new Map<string, string>();
57
61
 
58
62
  async function request(path: string, init: RequestInit = {}): Promise<any> {
59
63
  const token = await options.getAccessToken();
@@ -384,21 +388,46 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
384
388
  if (!companyId) {
385
389
  return { operationId: operation.id, status: "skipped", detail: "link_record needs a target company id." };
386
390
  }
387
- // `create:<Name>` creates the company first, then links the approved
388
- // value spells out exactly what will happen, so creation stays inside
389
- // the typed, human-approved operation model.
391
+ // `create:<Name>` is resolve-first: link to an existing company when one
392
+ // unambiguously matches, refuse on ambiguity, create only on a confirmed
393
+ // miss — and never create the same name twice within one apply run
394
+ // (HubSpot's search API is eventually consistent, so a just-created
395
+ // record is invisible to search for several seconds).
390
396
  let createdCompanyName: string | null = null;
397
+ let resolvedExisting = false;
391
398
  if (companyId.startsWith("create:")) {
392
399
  const name = companyId.slice("create:".length).trim();
393
400
  if (!name) {
394
401
  return { operationId: operation.id, status: "skipped", detail: "create: needs a company name (create:<Name>)." };
395
402
  }
396
- const created = await request(`/crm/v3/objects/companies`, {
397
- method: "POST",
398
- body: JSON.stringify({ properties: { name } }),
399
- });
400
- companyId = String(created.id);
401
- createdCompanyName = name;
403
+ const nameKey = name.toLowerCase();
404
+ const alreadyCreated = createdCompaniesByName.get(nameKey);
405
+ if (alreadyCreated) {
406
+ companyId = alreadyCreated;
407
+ resolvedExisting = true;
408
+ } else {
409
+ const matches = await searchCompaniesByName(name);
410
+ if (matches.length > 1) {
411
+ return {
412
+ operationId: operation.id,
413
+ status: "skipped",
414
+ detail: `create:${name} is ambiguous — ${matches.length} companies already named "${name}" (ids ${matches.join(", ")}). Link an explicit company id instead.`,
415
+ };
416
+ }
417
+ if (matches.length === 1) {
418
+ companyId = matches[0];
419
+ resolvedExisting = true;
420
+ createdCompaniesByName.set(nameKey, companyId);
421
+ } else {
422
+ const created = await request(`/crm/v3/objects/companies`, {
423
+ method: "POST",
424
+ body: JSON.stringify({ properties: { name } }),
425
+ });
426
+ companyId = String(created.id);
427
+ createdCompanyName = name;
428
+ createdCompaniesByName.set(nameKey, companyId);
429
+ }
430
+ }
402
431
  }
403
432
  await request(
404
433
  `/crm/v4/objects/${fromPath}/${encodeURIComponent(operation.objectId)}/associations/default/companies/${encodeURIComponent(companyId)}`,
@@ -409,11 +438,26 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
409
438
  status: "applied",
410
439
  detail: createdCompanyName
411
440
  ? `Created company "${createdCompanyName}" (${companyId}) and linked ${fromPath}/${operation.objectId} to it.`
412
- : `Linked ${fromPath}/${operation.objectId} to company ${companyId}.`,
441
+ : resolvedExisting
442
+ ? `Linked ${fromPath}/${operation.objectId} to existing company ${companyId} (resolved by name, nothing created).`
443
+ : `Linked ${fromPath}/${operation.objectId} to company ${companyId}.`,
413
444
  providerData: { companyId, ...(createdCompanyName ? { createdCompany: true } : {}) },
414
445
  };
415
446
  }
416
447
 
448
+ /** Exact-name company lookup for resolve-first creates. Returns matching ids (max 3 fetched). */
449
+ async function searchCompaniesByName(name: string): Promise<string[]> {
450
+ const data = await request(`/crm/v3/objects/companies/search`, {
451
+ method: "POST",
452
+ body: JSON.stringify({
453
+ filterGroups: [{ filters: [{ propertyName: "name", operator: "EQ", value: name }] }],
454
+ properties: ["name"],
455
+ limit: 3,
456
+ }),
457
+ });
458
+ return ((data?.results ?? []) as Array<{ id: string }>).map((row) => String(row.id));
459
+ }
460
+
417
461
  async function createTask(operation: PatchOperation): Promise<PatchOperationResult> {
418
462
  const associationTypeId = TASK_ASSOCIATION_TYPE_IDS[operation.objectType];
419
463
  if (associationTypeId === undefined) {
@@ -424,7 +468,33 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
424
468
  };
425
469
  }
426
470
  const subject = operation.field ? humanizeField(operation.field) : "Follow up";
427
- const body = String(operation.afterValue ?? operation.reason ?? "");
471
+ // The operation id doubles as an idempotency token: it is stamped into
472
+ // the task body and pre-checked so a replayed plan does not create the
473
+ // same task twice. Fail-open — a search hiccup must not block the apply.
474
+ const token = `fsgtm ${operation.id.replace(/^op_/, "")}`;
475
+ try {
476
+ const existing = await request(`/crm/v3/objects/tasks/search`, {
477
+ method: "POST",
478
+ body: JSON.stringify({
479
+ filterGroups: [
480
+ { filters: [{ propertyName: "hs_task_body", operator: "CONTAINS_TOKEN", value: token.split(" ")[1] }] },
481
+ ],
482
+ limit: 1,
483
+ }),
484
+ });
485
+ const hit = (existing?.results ?? [])[0] as { id?: string } | undefined;
486
+ if (hit?.id) {
487
+ return {
488
+ operationId: operation.id,
489
+ status: "skipped",
490
+ detail: `Task for this operation already exists (task ${hit.id}); not creating a duplicate.`,
491
+ providerData: { id: hit.id, existing: true },
492
+ };
493
+ }
494
+ } catch {
495
+ // fall through to create
496
+ }
497
+ const body = `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`;
428
498
  const response = await request(`/crm/v3/objects/tasks`, {
429
499
  method: "POST",
430
500
  body: JSON.stringify({
@@ -519,6 +589,15 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
519
589
  if (!objectPath || !mappingType) {
520
590
  throw new Error(`Field reads are only supported for accounts, contacts, and deals.`);
521
591
  }
592
+ // accountId is an association in HubSpot, not a property — without this
593
+ // branch the compare-and-set on link_record reads null and passes blind.
594
+ if (field === "accountId" && (objectType === "deal" || objectType === "contact")) {
595
+ const data = await request(
596
+ `/crm/v4/objects/${objectPath}/${encodeURIComponent(objectId)}/associations/companies?limit=1`,
597
+ );
598
+ const first = (data?.results ?? [])[0] as { toObjectId?: number | string } | undefined;
599
+ return first?.toObjectId !== undefined ? String(first.toObjectId) : null;
600
+ }
522
601
  const defaults = HUBSPOT_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
523
602
  const property = mappedField(mappings, mappingType, field, defaults[field] ?? field);
524
603
  const data = await request(
@@ -63,6 +63,8 @@ export function createSalesforceConnector(
63
63
  const apiVersion = options.apiVersion ?? DEFAULT_API_VERSION;
64
64
  const fetchImpl = options.fetchImpl ?? fetch;
65
65
  const mappings = options.fieldMappings;
66
+ // create:<Name> dedup within one connector lifetime (one apply run).
67
+ const createdAccountsByName = new Map<string, string>();
66
68
 
67
69
  async function request(path: string, init: RequestInit = {}): Promise<any> {
68
70
  const connection = await options.getConnection();
@@ -318,11 +320,29 @@ export function createSalesforceConnector(
318
320
  detail: "Tasks can be attached to accounts, contacts, and deals.",
319
321
  };
320
322
  }
323
+ // Idempotency: the operation id is stamped into the Description and
324
+ // pre-checked, so replaying a plan does not duplicate tasks. Fail-open.
325
+ const token = `fsgtm:${operation.id}`;
326
+ try {
327
+ const existing = await query(
328
+ `SELECT Id FROM Task WHERE Description LIKE '%${token.replace(/'/g, "\\'")}%' LIMIT 1`,
329
+ );
330
+ if (existing.length > 0) {
331
+ return {
332
+ operationId: operation.id,
333
+ status: "skipped",
334
+ detail: `Task for this operation already exists (task ${existing[0].Id}); not creating a duplicate.`,
335
+ providerData: { id: String(existing[0].Id), existing: true },
336
+ };
337
+ }
338
+ } catch {
339
+ // fall through to create
340
+ }
321
341
  const response = await request(`/services/data/${apiVersion}/sobjects/Task`, {
322
342
  method: "POST",
323
343
  body: JSON.stringify({
324
344
  Subject: operation.field ? humanizeField(operation.field) : "Follow up",
325
- Description: String(operation.afterValue ?? operation.reason ?? ""),
345
+ Description: `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`,
326
346
  Status: "Not Started",
327
347
  Priority: "Normal",
328
348
  ...reference,
@@ -364,21 +384,51 @@ export function createSalesforceConnector(
364
384
  // link_record on a deal is just setting AccountId in Salesforce.
365
385
  return await setField(operation);
366
386
  case "link_record": {
367
- // `create:<Name>` creates the Account first, then links — creation
368
- // stays inside the typed, human-approved operation model.
387
+ // `create:<Name>` is resolve-first: link to an unambiguous existing
388
+ // Account, refuse on ambiguity, create only on a confirmed miss —
389
+ // and never create the same name twice within one apply run.
369
390
  const value = String(operation.afterValue ?? "");
370
391
  if (value.startsWith("create:")) {
371
392
  const name = value.slice("create:".length).trim();
372
393
  if (!name) {
373
394
  return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
374
395
  }
375
- const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
376
- method: "POST",
377
- body: JSON.stringify({ Name: name }),
378
- });
379
- const result = await setField({ ...operation, operation: "set_field", afterValue: String(created.id) });
396
+ const nameKey = name.toLowerCase();
397
+ let accountId = createdAccountsByName.get(nameKey);
398
+ let createdNew = false;
399
+ if (!accountId) {
400
+ const soqlName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
401
+ const matches = await query(
402
+ `SELECT Id FROM Account WHERE Name = '${soqlName}' LIMIT 3`,
403
+ );
404
+ if (matches.length > 1) {
405
+ return {
406
+ operationId: operation.id,
407
+ status: "skipped",
408
+ detail: `create:${name} is ambiguous — ${matches.length} accounts already named "${name}". Link an explicit account id instead.`,
409
+ };
410
+ }
411
+ if (matches.length === 1) {
412
+ accountId = String(matches[0].Id);
413
+ } else {
414
+ const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
415
+ method: "POST",
416
+ body: JSON.stringify({ Name: name }),
417
+ });
418
+ accountId = String(created.id);
419
+ createdNew = true;
420
+ }
421
+ createdAccountsByName.set(nameKey, accountId);
422
+ }
423
+ const result = await setField({ ...operation, operation: "set_field", afterValue: accountId });
380
424
  return result.status === "applied"
381
- ? { ...result, detail: `Created account "${name}" (${created.id}) and linked ${operation.objectType}/${operation.objectId} to it.`, providerData: { accountId: String(created.id), createdAccount: true } }
425
+ ? {
426
+ ...result,
427
+ detail: createdNew
428
+ ? `Created account "${name}" (${accountId}) and linked ${operation.objectType}/${operation.objectId} to it.`
429
+ : `Linked ${operation.objectType}/${operation.objectId} to existing account ${accountId} (resolved by name, nothing created).`,
430
+ providerData: { accountId, ...(createdNew ? { createdAccount: true } : {}) },
431
+ }
382
432
  : result;
383
433
  }
384
434
  return await setField({ ...operation, operation: "set_field" });
package/src/merge.ts CHANGED
@@ -55,7 +55,7 @@ const CONFLICT_IGNORED_FIELDS = new Set([
55
55
  "id", "provider", "crmId", "identities", "raw", "lastSyncAt", "lastActivityAt", "ownerId", "accountId",
56
56
  ]);
57
57
 
58
- function normalizeDomain(domain?: string): string | undefined {
58
+ export function normalizeDomain(domain?: string): string | undefined {
59
59
  if (!domain) return undefined;
60
60
  return domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/.*$/, "") || undefined;
61
61
  }
package/src/rules.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { normalizeDomain } from "./merge.ts";
1
2
  import type {
2
3
  AuditFinding,
3
4
  CanonicalActivity,
@@ -333,7 +334,7 @@ export const duplicateAccountDomainRule: GtmAuditRule = {
333
334
  evaluate: ({ snapshot }) => {
334
335
  const findings: AuditFinding[] = [];
335
336
  const operations = [];
336
- for (const [domain, accounts] of duplicateGroups(snapshot.accounts, (account) => account.domain)) {
337
+ for (const [domain, accounts] of duplicateGroups(snapshot.accounts, (account) => normalizeDomain(account.domain))) {
337
338
  const anchor = accounts[0];
338
339
  findings.push({
339
340
  id: auditFindingId("duplicate-account-domain", anchor.id),