fullstackgtm 0.11.0 → 0.12.0

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,65 @@ 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.12.0] — 2026-06-11
9
+
10
+ Governed merges: the Remediate layer of the
11
+ [CRM-health lifecycle](./docs/crm-health-lifecycle.md). Duplicate detection
12
+ now ends in an approvable merge, not a chore.
13
+
14
+ ### Added
15
+
16
+ - **`merge_records` operation type**: merges a duplicate group
17
+ (`beforeValue` = group ids) into an approved survivor (`afterValue`).
18
+ HubSpot connector implements it via the v3 merge API for companies,
19
+ contacts, and deals — pairwise, survivor's values win, losers archived by
20
+ HubSpot, **irreversible** (called out in every operation's rollback text).
21
+ Refuses survivors outside the group; treats 404 losers as already merged,
22
+ so replayed plans are safe. Salesforce skips honestly (merge is SOAP/Apex
23
+ only on that platform).
24
+ - **Survivor suggestions in `suggest`**: deterministic ranking — most
25
+ complete record, then most recent activity — with the evidence written
26
+ into the reason. **Capped at low confidence by design**: an irreversible
27
+ merge can never clear the default bulk-approval bar; accepting one takes
28
+ `--min-confidence low` or an explicit `--value`.
29
+
30
+ ### Changed
31
+
32
+ - **The three duplicate rules now emit `merge_records`** (high risk,
33
+ approval required, irreversibility in the rollback text) instead of
34
+ `create_task` review chores — detection now connects to remediation
35
+ inside the governed loop.
36
+
37
+ ## [0.11.1] — 2026-06-11
38
+
39
+ Write-path integrity: fixes our own dupe faucets, found auditing the apply
40
+ path for the new [CRM-health lifecycle](./docs/crm-health-lifecycle.md) doc.
41
+
42
+ ### Added
43
+
44
+ - **docs/crm-health-lifecycle.md**: the full CRUD lifecycle for keeping a
45
+ CRM healthy — Prevent → Detect → Remediate → Verify/Attribute — grounded
46
+ in verified platform behavior (HubSpot/Salesforce dedupe and merge
47
+ support) and the build order toward governed merges and a resolve gate.
48
+
49
+ ### Fixed
50
+
51
+ - **`create:<Name>` is now resolve-first**: it links to an unambiguous
52
+ existing company/account instead of creating, refuses on ambiguity, and
53
+ creates only on a confirmed miss — and never creates the same name twice
54
+ within one apply run (HubSpot search is eventually consistent, so the
55
+ same-run record is authoritative).
56
+ - **HubSpot compare-and-set on `link_record` is no longer blind**:
57
+ `readField("deal"|"contact", id, "accountId")` reads the actual company
58
+ association (it is not a property), so replaying an applied link returns
59
+ `conflict` instead of silently re-creating companies.
60
+ - **`create_task` is idempotent**: the operation id is stamped into the
61
+ task body as a token and pre-checked (fail-open), so replayed plans no
62
+ longer duplicate merge-review tasks on either provider.
63
+ - **`duplicate-account-domain` normalizes domains** the same way merge
64
+ does — `www.acme.com`, `https://acme.com/about`, and `acme.com` now
65
+ group as duplicates.
66
+
8
67
  ## [0.11.0] — 2026-06-11
9
68
 
10
69
  Canonicalizes the paths discovered dogfooding against a real portal: the
package/README.md CHANGED
@@ -59,7 +59,7 @@ fullstackgtm plans approve patch_plan_abc123 --values-from suggestions.json #
59
59
  fullstackgtm apply --plan-id patch_plan_abc123 --provider hubspot
60
60
  ```
61
61
 
62
- Widen the bar deliberately: `--min-confidence low` accepts single-signal matches; `--include-creates` accepts `create:<Name>` values — approving one **creates the missing company/account record and links to it** in a single audited operation, so even record creation stays inside the typed, human-approved model. Conflicting or ambiguous evidence always yields *no* suggestion with an explanation, never a guess.
62
+ Widen the bar deliberately: `--min-confidence low` accepts single-signal matches and **merge survivor suggestions** (irreversible merges are capped at low confidence by design, so the default bar never bulk-approves one); `--include-creates` accepts `create:<Name>` values — approving one **creates the missing company/account record and links to it** in a single audited operation, so even record creation stays inside the typed, human-approved model. Conflicting or ambiguous evidence always yields *no* suggestion with an explanation, never a guess.
63
63
 
64
64
  ```bash
65
65
  # 3. Hand the findings to whoever owns the CRM: a client-ready report
@@ -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({
@@ -368,6 +440,74 @@ export function createHubspotConnector(options) {
368
440
  detail: `Archived ${objectPath}/${operation.objectId}.`,
369
441
  };
370
442
  }
443
+ /**
444
+ * Merge a duplicate group into the approved survivor via HubSpot's v3
445
+ * merge API (supported for contacts, companies, deals, and tickets).
446
+ * Merges are pairwise and IRREVERSIBLE; the survivor's values win on
447
+ * conflict and each loser is archived by HubSpot. A loser that is already
448
+ * gone (404 — e.g. a replayed plan) is treated as already merged.
449
+ */
450
+ async function mergeRecords(operation) {
451
+ const objectPath = OBJECT_PATHS[operation.objectType];
452
+ if (!objectPath || operation.objectType === "user" || operation.objectType === "activity") {
453
+ return {
454
+ operationId: operation.id,
455
+ status: "skipped",
456
+ detail: "merge_records is supported for accounts, contacts, and deals.",
457
+ };
458
+ }
459
+ const survivorId = String(operation.afterValue ?? "");
460
+ const groupIds = Array.isArray(operation.beforeValue)
461
+ ? operation.beforeValue.map((id) => String(id))
462
+ : [];
463
+ if (!survivorId || groupIds.length < 2) {
464
+ return {
465
+ operationId: operation.id,
466
+ status: "skipped",
467
+ detail: "merge_records needs a survivor id (afterValue) and the duplicate group ids (beforeValue).",
468
+ };
469
+ }
470
+ if (!groupIds.includes(survivorId)) {
471
+ return {
472
+ operationId: operation.id,
473
+ status: "skipped",
474
+ detail: `Survivor ${survivorId} is not in the duplicate group (${groupIds.join(", ")}); refusing to merge into an unrelated record.`,
475
+ };
476
+ }
477
+ const losers = groupIds.filter((id) => id !== survivorId);
478
+ const mergedIds = [];
479
+ const alreadyGoneIds = [];
480
+ for (const loser of losers) {
481
+ try {
482
+ await request(`/crm/v3/objects/${objectPath}/merge`, {
483
+ method: "POST",
484
+ body: JSON.stringify({ primaryObjectId: survivorId, objectIdToMerge: loser }),
485
+ });
486
+ mergedIds.push(loser);
487
+ }
488
+ catch (error) {
489
+ const message = error instanceof Error ? error.message : String(error);
490
+ if (message.includes(" 404")) {
491
+ alreadyGoneIds.push(loser); // replayed plan: loser already merged/archived
492
+ continue;
493
+ }
494
+ return {
495
+ operationId: operation.id,
496
+ status: "failed",
497
+ detail: `Merged ${mergedIds.length} of ${losers.length} into ${survivorId}, then failed on ${loser}: ${message}`,
498
+ providerData: { survivorId, mergedIds, alreadyGoneIds },
499
+ };
500
+ }
501
+ }
502
+ return {
503
+ operationId: operation.id,
504
+ status: mergedIds.length === 0 && alreadyGoneIds.length === losers.length ? "skipped" : "applied",
505
+ detail: mergedIds.length === 0 && alreadyGoneIds.length === losers.length
506
+ ? `All ${losers.length} duplicates were already merged into ${survivorId}; nothing to do.`
507
+ : `Merged ${mergedIds.length} duplicate ${objectPath} into ${survivorId}${alreadyGoneIds.length ? ` (${alreadyGoneIds.length} already gone)` : ""}. Irreversible.`,
508
+ providerData: { survivorId, mergedIds, alreadyGoneIds },
509
+ };
510
+ }
371
511
  async function applyOperation(operation) {
372
512
  try {
373
513
  switch (operation.operation) {
@@ -378,6 +518,8 @@ export function createHubspotConnector(options) {
378
518
  return await linkRecord(operation);
379
519
  case "create_task":
380
520
  return await createTask(operation);
521
+ case "merge_records":
522
+ return await mergeRecords(operation);
381
523
  case "archive_record":
382
524
  return await archiveRecord(operation);
383
525
  default:
@@ -405,6 +547,13 @@ export function createHubspotConnector(options) {
405
547
  if (!objectPath || !mappingType) {
406
548
  throw new Error(`Field reads are only supported for accounts, contacts, and deals.`);
407
549
  }
550
+ // accountId is an association in HubSpot, not a property — without this
551
+ // branch the compare-and-set on link_record reads null and passes blind.
552
+ if (field === "accountId" && (objectType === "deal" || objectType === "contact")) {
553
+ const data = await request(`/crm/v4/objects/${objectPath}/${encodeURIComponent(objectId)}/associations/companies?limit=1`);
554
+ const first = (data?.results ?? [])[0];
555
+ return first?.toObjectId !== undefined ? String(first.toObjectId) : null;
556
+ }
408
557
  const defaults = HUBSPOT_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
409
558
  const property = mappedField(mappings, mappingType, field, defaults[field] ?? field);
410
559
  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,27 +288,65 @@ 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" });
290
338
  }
291
339
  case "create_task":
292
340
  return await createTask(operation);
341
+ case "merge_records":
342
+ // Salesforce merge exists only in the SOAP API and Apex (Lead,
343
+ // Contact, Account, Case; max 3 records) — there is no REST merge
344
+ // resource. Surface that honestly instead of half-merging.
345
+ return {
346
+ operationId: operation.id,
347
+ status: "skipped",
348
+ detail: "Salesforce merge requires the SOAP API or Apex (Lead/Contact/Account/Case only) — this REST connector cannot merge. Merge in the Salesforce UI, or archive the duplicates explicitly.",
349
+ };
293
350
  case "archive_record":
294
351
  return await archiveRecord(operation);
295
352
  default:
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),
@@ -332,13 +333,14 @@ export const duplicateAccountDomainRule = {
332
333
  id: patchOperationId("duplicate-account-domain", anchor.id),
333
334
  objectType: "account",
334
335
  objectId: anchor.id,
335
- operation: "create_task",
336
- field: "merge_review_task",
337
- beforeValue: null,
338
- afterValue: `Review ${accounts.length} accounts sharing ${domain} and merge duplicates`,
339
- reason: "Duplicate accounts split pipeline, attribution, and ownership.",
340
- riskLevel: "medium",
336
+ operation: "merge_records",
337
+ field: "merge",
338
+ beforeValue: accounts.map((account) => account.id),
339
+ afterValue: "requires_human_survivor_selection",
340
+ reason: `Duplicate accounts split pipeline, attribution, and ownership. Merge the ${accounts.length} accounts sharing ${domain} into one survivor.`,
341
+ riskLevel: "high",
341
342
  approvalRequired: true,
343
+ rollback: "IRREVERSIBLE: provider merges cannot be unmerged. The pre-apply snapshot retains every record's field values; recreate a record manually from it if a merge was wrong.",
342
344
  });
343
345
  }
344
346
  return { findings, operations };
@@ -368,13 +370,14 @@ export const duplicateContactEmailRule = {
368
370
  id: patchOperationId("duplicate-contact-email", anchor.id),
369
371
  objectType: "contact",
370
372
  objectId: anchor.id,
371
- operation: "create_task",
372
- field: "merge_review_task",
373
- beforeValue: null,
374
- afterValue: `Review ${contacts.length} contacts sharing ${email} and merge duplicates`,
375
- reason: "Duplicate contacts fragment engagement history and double-route outreach.",
376
- riskLevel: "low",
373
+ operation: "merge_records",
374
+ field: "merge",
375
+ beforeValue: contacts.map((contact) => contact.id),
376
+ afterValue: "requires_human_survivor_selection",
377
+ reason: `Duplicate contacts fragment engagement history and double-route outreach. Merge the ${contacts.length} contacts sharing ${email} into one survivor.`,
378
+ riskLevel: "high",
377
379
  approvalRequired: true,
380
+ rollback: "IRREVERSIBLE: provider merges cannot be unmerged. The pre-apply snapshot retains every record's field values; recreate a record manually from it if a merge was wrong.",
378
381
  });
379
382
  }
380
383
  return { findings, operations };
@@ -414,13 +417,14 @@ export const duplicateOpenDealRule = {
414
417
  id: patchOperationId("duplicate-open-deal", anchor.id),
415
418
  objectType: "deal",
416
419
  objectId: anchor.id,
417
- operation: "create_task",
418
- field: "merge_review_task",
419
- beforeValue: null,
420
- afterValue: `Review ${deals.length} duplicate open deals named "${anchor.name}" — keep one, archive ${deals.length - 1}`,
421
- reason: "Duplicate open deals inflate pipeline and forecast the same revenue more than once.",
422
- riskLevel: "medium",
420
+ operation: "merge_records",
421
+ field: "merge",
422
+ beforeValue: deals.map((deal) => deal.id),
423
+ afterValue: "requires_human_survivor_selection",
424
+ reason: `Duplicate open deals inflate pipeline and forecast the same revenue more than once. Merge the ${deals.length} deals named "${anchor.name}" into one survivor.`,
425
+ riskLevel: "high",
423
426
  approvalRequired: true,
427
+ rollback: "IRREVERSIBLE: provider merges cannot be unmerged. The pre-apply snapshot retains every record's field values; recreate a record manually from it if a merge was wrong.",
424
428
  });
425
429
  }
426
430
  return { findings, operations };
package/dist/suggest.js CHANGED
@@ -22,6 +22,10 @@ export function suggestValues(plan, snapshot) {
22
22
  suggestions.push(suggestDealAccount(operation, dealsById, accountsByNorm, accountsById, contactsByName));
23
23
  continue;
24
24
  }
25
+ if (placeholder === "requires_human_survivor_selection") {
26
+ suggestions.push(suggestSurvivor(operation, snapshot, dealsById));
27
+ continue;
28
+ }
25
29
  if (placeholder === "requires_human_owner_selection" && activeUsers.length === 1) {
26
30
  suggestions.push({
27
31
  operationId: operation.id,
@@ -140,6 +144,79 @@ function suggestDealAccount(operation, dealsById, accountsByNorm, accountsById,
140
144
  : `Deal name "${deal.name}" has no "Contact - Company" pattern to derive a company from. Supply --value ${operation.id}=<accountId> or create:<Company Name>.`,
141
145
  };
142
146
  }
147
+ /**
148
+ * Survivor selection for merge_records. Ranking is deterministic and
149
+ * evidence-based: most complete record first (count of populated canonical
150
+ * fields), most recent activity as the tiebreaker, group order last.
151
+ * Confidence is capped at "low" by design: merges are irreversible, so a
152
+ * survivor suggestion must never clear the default bulk-approval bar —
153
+ * accepting one requires --min-confidence low or an explicit --value.
154
+ */
155
+ function suggestSurvivor(operation, snapshot, dealsById) {
156
+ const base = {
157
+ operationId: operation.id,
158
+ objectType: operation.objectType,
159
+ objectId: operation.objectId,
160
+ objectName: dealsById.get(operation.objectId)?.name,
161
+ placeholder: "requires_human_survivor_selection",
162
+ };
163
+ const groupIds = Array.isArray(operation.beforeValue)
164
+ ? operation.beforeValue.map((id) => String(id))
165
+ : [];
166
+ if (groupIds.length < 2) {
167
+ return {
168
+ ...base,
169
+ suggestedValue: null,
170
+ confidence: "none",
171
+ reason: "Operation does not carry a duplicate group (expected ids in beforeValue).",
172
+ };
173
+ }
174
+ const collection = operation.objectType === "account"
175
+ ? snapshot.accounts
176
+ : operation.objectType === "contact"
177
+ ? snapshot.contacts
178
+ : operation.objectType === "deal"
179
+ ? snapshot.deals
180
+ : [];
181
+ const records = groupIds
182
+ .map((id) => collection.find((row) => row.id === id))
183
+ .filter((row) => row !== undefined);
184
+ if (records.length !== groupIds.length) {
185
+ return {
186
+ ...base,
187
+ suggestedValue: null,
188
+ confidence: "none",
189
+ reason: "Not every group member is present in the snapshot; re-run the audit before merging.",
190
+ };
191
+ }
192
+ const ranked = [...records].sort((a, b) => {
193
+ const completeness = populatedFields(b) - populatedFields(a);
194
+ if (completeness !== 0)
195
+ return completeness;
196
+ return activityMs(b) - activityMs(a);
197
+ });
198
+ const winner = ranked[0];
199
+ const runnerUp = ranked[1];
200
+ const name = "name" in winner && typeof winner.name === "string" ? winner.name : winner.id;
201
+ return {
202
+ ...base,
203
+ suggestedValue: winner.id,
204
+ confidence: "low",
205
+ reason: `"${name}" (${winner.id}) is the most complete record in the group ` +
206
+ `(${populatedFields(winner)} populated fields vs ${populatedFields(runnerUp)}` +
207
+ `${activityMs(winner) > activityMs(runnerUp) ? ", and more recent activity" : ""}). ` +
208
+ `Merging is IRREVERSIBLE — survivor suggestions never exceed low confidence; ` +
209
+ `approve with --min-confidence low or an explicit --value after review.`,
210
+ };
211
+ }
212
+ function populatedFields(record) {
213
+ return Object.values(record).filter((value) => value !== undefined && value !== null && value !== "").length;
214
+ }
215
+ function activityMs(record) {
216
+ const value = record.lastActivityAt;
217
+ const parsed = value ? Date.parse(value) : NaN;
218
+ return Number.isFinite(parsed) ? parsed : 0;
219
+ }
143
220
  function normalize(value) {
144
221
  return value
145
222
  .toLowerCase()
package/dist/types.d.ts CHANGED
@@ -10,7 +10,7 @@ export type RiskLevel = "low" | "medium" | "high";
10
10
  export type ApprovalStatus = "draft" | "needs_approval" | "approved" | "rejected" | "applied";
11
11
  export type GtmObjectType = "account" | "contact" | "deal" | "user" | "activity";
12
12
  export type GtmEvidenceSourceSystem = "salesforce" | "hubspot" | "gong" | "chorus" | "fathom" | "manual" | "csv" | "mock" | "unknown";
13
- export type PatchOperationType = "set_field" | "clear_field" | "link_record" | "archive_record" | "create_task";
13
+ export type PatchOperationType = "set_field" | "clear_field" | "link_record" | "archive_record" | "create_task" | "merge_records";
14
14
  export type AuditFindingSeverity = "info" | "warning" | "critical";
15
15
  /**
16
16
  * One claim that a canonical record exists in an external system. A record
package/docs/api.md CHANGED
@@ -40,7 +40,7 @@ release.
40
40
  - `GtmConnector` — `{ provider, fetchSnapshot(), applyOperation?, readField?, fetchChanges? }`.
41
41
  - Connectors never silently drop unresolvable records; audits surface them.
42
42
  - `fetchChanges(sinceIso)` returns a partial snapshot; change feeds may omit associations.
43
- - `createHubspotConnector(options)` — read/write/readField/fetchChanges. `applyOperation` implements every `PatchOperationType`: `set_field`, `clear_field`, `link_record`, `create_task`, `archive_record`.
43
+ - `createHubspotConnector(options)` — read/write/readField/fetchChanges. `applyOperation` implements every `PatchOperationType`: `set_field`, `clear_field`, `link_record`, `create_task`, `archive_record`, `merge_records` (HubSpot v3 merge — pairwise, irreversible; survivor must belong to the duplicate group). The Salesforce connector skips `merge_records` honestly (SOAP/Apex-only on that platform).
44
44
  - `createSalesforceConnector(options)` — read/write/readField/fetchChanges; probabilities normalized to 0..1; `applyOperation` implements every operation type.
45
45
  - `createStripeConnector(options)` — read-only billing by design (`applyOperation` returns `skipped`); email domains are the cross-system merge keys. Implements `fetchChanges` (incremental via `created[gte]`).
46
46