fullstackgtm 0.11.1 → 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,35 @@ 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
+
8
37
  ## [0.11.1] — 2026-06-11
9
38
 
10
39
  Write-path integrity: fixes our own dupe faucets, found auditing the apply
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
@@ -440,6 +440,74 @@ export function createHubspotConnector(options) {
440
440
  detail: `Archived ${objectPath}/${operation.objectId}.`,
441
441
  };
442
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
+ }
443
511
  async function applyOperation(operation) {
444
512
  try {
445
513
  switch (operation.operation) {
@@ -450,6 +518,8 @@ export function createHubspotConnector(options) {
450
518
  return await linkRecord(operation);
451
519
  case "create_task":
452
520
  return await createTask(operation);
521
+ case "merge_records":
522
+ return await mergeRecords(operation);
453
523
  case "archive_record":
454
524
  return await archiveRecord(operation);
455
525
  default:
@@ -338,6 +338,15 @@ export function createSalesforceConnector(options) {
338
338
  }
339
339
  case "create_task":
340
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
+ };
341
350
  case "archive_record":
342
351
  return await archiveRecord(operation);
343
352
  default:
package/dist/rules.js CHANGED
@@ -333,13 +333,14 @@ export const duplicateAccountDomainRule = {
333
333
  id: patchOperationId("duplicate-account-domain", anchor.id),
334
334
  objectType: "account",
335
335
  objectId: anchor.id,
336
- operation: "create_task",
337
- field: "merge_review_task",
338
- beforeValue: null,
339
- afterValue: `Review ${accounts.length} accounts sharing ${domain} and merge duplicates`,
340
- reason: "Duplicate accounts split pipeline, attribution, and ownership.",
341
- 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",
342
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.",
343
344
  });
344
345
  }
345
346
  return { findings, operations };
@@ -369,13 +370,14 @@ export const duplicateContactEmailRule = {
369
370
  id: patchOperationId("duplicate-contact-email", anchor.id),
370
371
  objectType: "contact",
371
372
  objectId: anchor.id,
372
- operation: "create_task",
373
- field: "merge_review_task",
374
- beforeValue: null,
375
- afterValue: `Review ${contacts.length} contacts sharing ${email} and merge duplicates`,
376
- reason: "Duplicate contacts fragment engagement history and double-route outreach.",
377
- 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",
378
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.",
379
381
  });
380
382
  }
381
383
  return { findings, operations };
@@ -415,13 +417,14 @@ export const duplicateOpenDealRule = {
415
417
  id: patchOperationId("duplicate-open-deal", anchor.id),
416
418
  objectType: "deal",
417
419
  objectId: anchor.id,
418
- operation: "create_task",
419
- field: "merge_review_task",
420
- beforeValue: null,
421
- afterValue: `Review ${deals.length} duplicate open deals named "${anchor.name}" — keep one, archive ${deals.length - 1}`,
422
- reason: "Duplicate open deals inflate pipeline and forecast the same revenue more than once.",
423
- 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",
424
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.",
425
428
  });
426
429
  }
427
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
 
@@ -130,6 +130,6 @@ Lessons from auditing our own apply path:
130
130
  | Release | Scope |
131
131
  | --- | --- |
132
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 |
133
+ | 0.12 (shipped) | `merge_records` (HubSpot contacts/companies/deals) + survivor suggestions capped at low confidence; the three duplicate rules emit governed merges instead of review tasks |
134
134
  | 0.13 | `resolve` gate (CLI/lib/MCP), provenance capture + attribution in findings, prevention-posture checks |
135
135
  | docs | The nightly watch recipe (existing flags, documented as CRM CI) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
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",
@@ -542,6 +542,75 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
542
542
  };
543
543
  }
544
544
 
545
+ /**
546
+ * Merge a duplicate group into the approved survivor via HubSpot's v3
547
+ * merge API (supported for contacts, companies, deals, and tickets).
548
+ * Merges are pairwise and IRREVERSIBLE; the survivor's values win on
549
+ * conflict and each loser is archived by HubSpot. A loser that is already
550
+ * gone (404 — e.g. a replayed plan) is treated as already merged.
551
+ */
552
+ async function mergeRecords(operation: PatchOperation): Promise<PatchOperationResult> {
553
+ const objectPath = OBJECT_PATHS[operation.objectType];
554
+ if (!objectPath || operation.objectType === "user" || operation.objectType === "activity") {
555
+ return {
556
+ operationId: operation.id,
557
+ status: "skipped",
558
+ detail: "merge_records is supported for accounts, contacts, and deals.",
559
+ };
560
+ }
561
+ const survivorId = String(operation.afterValue ?? "");
562
+ const groupIds = Array.isArray(operation.beforeValue)
563
+ ? operation.beforeValue.map((id) => String(id))
564
+ : [];
565
+ if (!survivorId || groupIds.length < 2) {
566
+ return {
567
+ operationId: operation.id,
568
+ status: "skipped",
569
+ detail: "merge_records needs a survivor id (afterValue) and the duplicate group ids (beforeValue).",
570
+ };
571
+ }
572
+ if (!groupIds.includes(survivorId)) {
573
+ return {
574
+ operationId: operation.id,
575
+ status: "skipped",
576
+ detail: `Survivor ${survivorId} is not in the duplicate group (${groupIds.join(", ")}); refusing to merge into an unrelated record.`,
577
+ };
578
+ }
579
+ const losers = groupIds.filter((id) => id !== survivorId);
580
+ const mergedIds: string[] = [];
581
+ const alreadyGoneIds: string[] = [];
582
+ for (const loser of losers) {
583
+ try {
584
+ await request(`/crm/v3/objects/${objectPath}/merge`, {
585
+ method: "POST",
586
+ body: JSON.stringify({ primaryObjectId: survivorId, objectIdToMerge: loser }),
587
+ });
588
+ mergedIds.push(loser);
589
+ } catch (error) {
590
+ const message = error instanceof Error ? error.message : String(error);
591
+ if (message.includes(" 404")) {
592
+ alreadyGoneIds.push(loser); // replayed plan: loser already merged/archived
593
+ continue;
594
+ }
595
+ return {
596
+ operationId: operation.id,
597
+ status: "failed",
598
+ detail: `Merged ${mergedIds.length} of ${losers.length} into ${survivorId}, then failed on ${loser}: ${message}`,
599
+ providerData: { survivorId, mergedIds, alreadyGoneIds },
600
+ };
601
+ }
602
+ }
603
+ return {
604
+ operationId: operation.id,
605
+ status: mergedIds.length === 0 && alreadyGoneIds.length === losers.length ? "skipped" : "applied",
606
+ detail:
607
+ mergedIds.length === 0 && alreadyGoneIds.length === losers.length
608
+ ? `All ${losers.length} duplicates were already merged into ${survivorId}; nothing to do.`
609
+ : `Merged ${mergedIds.length} duplicate ${objectPath} into ${survivorId}${alreadyGoneIds.length ? ` (${alreadyGoneIds.length} already gone)` : ""}. Irreversible.`,
610
+ providerData: { survivorId, mergedIds, alreadyGoneIds },
611
+ };
612
+ }
613
+
545
614
  async function applyOperation(operation: PatchOperation): Promise<PatchOperationResult> {
546
615
  try {
547
616
  switch (operation.operation) {
@@ -552,6 +621,8 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
552
621
  return await linkRecord(operation);
553
622
  case "create_task":
554
623
  return await createTask(operation);
624
+ case "merge_records":
625
+ return await mergeRecords(operation);
555
626
  case "archive_record":
556
627
  return await archiveRecord(operation);
557
628
  default:
@@ -435,6 +435,16 @@ export function createSalesforceConnector(
435
435
  }
436
436
  case "create_task":
437
437
  return await createTask(operation);
438
+ case "merge_records":
439
+ // Salesforce merge exists only in the SOAP API and Apex (Lead,
440
+ // Contact, Account, Case; max 3 records) — there is no REST merge
441
+ // resource. Surface that honestly instead of half-merging.
442
+ return {
443
+ operationId: operation.id,
444
+ status: "skipped",
445
+ detail:
446
+ "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.",
447
+ };
438
448
  case "archive_record":
439
449
  return await archiveRecord(operation);
440
450
  default:
package/src/rules.ts CHANGED
@@ -350,13 +350,15 @@ export const duplicateAccountDomainRule: GtmAuditRule = {
350
350
  id: patchOperationId("duplicate-account-domain", anchor.id),
351
351
  objectType: "account" as const,
352
352
  objectId: anchor.id,
353
- operation: "create_task" as const,
354
- field: "merge_review_task",
355
- beforeValue: null,
356
- afterValue: `Review ${accounts.length} accounts sharing ${domain} and merge duplicates`,
357
- reason: "Duplicate accounts split pipeline, attribution, and ownership.",
358
- riskLevel: "medium" as const,
353
+ operation: "merge_records" as const,
354
+ field: "merge",
355
+ beforeValue: accounts.map((account) => account.id),
356
+ afterValue: "requires_human_survivor_selection",
357
+ reason: `Duplicate accounts split pipeline, attribution, and ownership. Merge the ${accounts.length} accounts sharing ${domain} into one survivor.`,
358
+ riskLevel: "high" as const,
359
359
  approvalRequired: true,
360
+ rollback:
361
+ "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.",
360
362
  });
361
363
  }
362
364
  return { findings, operations };
@@ -388,13 +390,15 @@ export const duplicateContactEmailRule: GtmAuditRule = {
388
390
  id: patchOperationId("duplicate-contact-email", anchor.id),
389
391
  objectType: "contact" as const,
390
392
  objectId: anchor.id,
391
- operation: "create_task" as const,
392
- field: "merge_review_task",
393
- beforeValue: null,
394
- afterValue: `Review ${contacts.length} contacts sharing ${email} and merge duplicates`,
395
- reason: "Duplicate contacts fragment engagement history and double-route outreach.",
396
- riskLevel: "low" as const,
393
+ operation: "merge_records" as const,
394
+ field: "merge",
395
+ beforeValue: contacts.map((contact) => contact.id),
396
+ afterValue: "requires_human_survivor_selection",
397
+ reason: `Duplicate contacts fragment engagement history and double-route outreach. Merge the ${contacts.length} contacts sharing ${email} into one survivor.`,
398
+ riskLevel: "high" as const,
397
399
  approvalRequired: true,
400
+ rollback:
401
+ "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.",
398
402
  });
399
403
  }
400
404
  return { findings, operations };
@@ -437,13 +441,15 @@ export const duplicateOpenDealRule: GtmAuditRule = {
437
441
  id: patchOperationId("duplicate-open-deal", anchor.id),
438
442
  objectType: "deal" as const,
439
443
  objectId: anchor.id,
440
- operation: "create_task" as const,
441
- field: "merge_review_task",
442
- beforeValue: null,
443
- afterValue: `Review ${deals.length} duplicate open deals named "${anchor.name}" — keep one, archive ${deals.length - 1}`,
444
- reason: "Duplicate open deals inflate pipeline and forecast the same revenue more than once.",
445
- riskLevel: "medium" as const,
444
+ operation: "merge_records" as const,
445
+ field: "merge",
446
+ beforeValue: deals.map((deal) => deal.id),
447
+ afterValue: "requires_human_survivor_selection",
448
+ 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.`,
449
+ riskLevel: "high" as const,
446
450
  approvalRequired: true,
451
+ rollback:
452
+ "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.",
447
453
  });
448
454
  }
449
455
  return { findings, operations };
package/src/suggest.ts CHANGED
@@ -62,6 +62,11 @@ export function suggestValues(
62
62
  continue;
63
63
  }
64
64
 
65
+ if (placeholder === "requires_human_survivor_selection") {
66
+ suggestions.push(suggestSurvivor(operation, snapshot, dealsById));
67
+ continue;
68
+ }
69
+
65
70
  if (placeholder === "requires_human_owner_selection" && activeUsers.length === 1) {
66
71
  suggestions.push({
67
72
  operationId: operation.id,
@@ -194,6 +199,89 @@ function suggestDealAccount(
194
199
  };
195
200
  }
196
201
 
202
+ /**
203
+ * Survivor selection for merge_records. Ranking is deterministic and
204
+ * evidence-based: most complete record first (count of populated canonical
205
+ * fields), most recent activity as the tiebreaker, group order last.
206
+ * Confidence is capped at "low" by design: merges are irreversible, so a
207
+ * survivor suggestion must never clear the default bulk-approval bar —
208
+ * accepting one requires --min-confidence low or an explicit --value.
209
+ */
210
+ function suggestSurvivor(
211
+ operation: PatchOperation,
212
+ snapshot: CanonicalGtmSnapshot,
213
+ dealsById: Map<string, { name: string }>,
214
+ ): ValueSuggestion {
215
+ const base = {
216
+ operationId: operation.id,
217
+ objectType: operation.objectType,
218
+ objectId: operation.objectId,
219
+ objectName: dealsById.get(operation.objectId)?.name,
220
+ placeholder: "requires_human_survivor_selection",
221
+ };
222
+ const groupIds = Array.isArray(operation.beforeValue)
223
+ ? operation.beforeValue.map((id) => String(id))
224
+ : [];
225
+ if (groupIds.length < 2) {
226
+ return {
227
+ ...base,
228
+ suggestedValue: null,
229
+ confidence: "none",
230
+ reason: "Operation does not carry a duplicate group (expected ids in beforeValue).",
231
+ };
232
+ }
233
+ const collection =
234
+ operation.objectType === "account"
235
+ ? snapshot.accounts
236
+ : operation.objectType === "contact"
237
+ ? snapshot.contacts
238
+ : operation.objectType === "deal"
239
+ ? snapshot.deals
240
+ : [];
241
+ const records = groupIds
242
+ .map((id) => collection.find((row) => row.id === id))
243
+ .filter((row): row is NonNullable<typeof row> => row !== undefined);
244
+ if (records.length !== groupIds.length) {
245
+ return {
246
+ ...base,
247
+ suggestedValue: null,
248
+ confidence: "none",
249
+ reason: "Not every group member is present in the snapshot; re-run the audit before merging.",
250
+ };
251
+ }
252
+ const ranked = [...records].sort((a, b) => {
253
+ const completeness = populatedFields(b) - populatedFields(a);
254
+ if (completeness !== 0) return completeness;
255
+ return activityMs(b) - activityMs(a);
256
+ });
257
+ const winner = ranked[0];
258
+ const runnerUp = ranked[1];
259
+ const name = "name" in winner && typeof winner.name === "string" ? winner.name : winner.id;
260
+ return {
261
+ ...base,
262
+ suggestedValue: winner.id,
263
+ confidence: "low",
264
+ reason:
265
+ `"${name}" (${winner.id}) is the most complete record in the group ` +
266
+ `(${populatedFields(winner)} populated fields vs ${populatedFields(runnerUp)}` +
267
+ `${activityMs(winner) > activityMs(runnerUp) ? ", and more recent activity" : ""}). ` +
268
+ `Merging is IRREVERSIBLE — survivor suggestions never exceed low confidence; ` +
269
+ `approve with --min-confidence low or an explicit --value after review.`,
270
+ };
271
+ }
272
+
273
+ function populatedFields(record: object) {
274
+ return Object.values(record).filter(
275
+ (value) => value !== undefined && value !== null && value !== "",
276
+ ).length;
277
+ }
278
+
279
+ function activityMs(record: object) {
280
+ const value = (record as { lastActivityAt?: string }).lastActivityAt;
281
+ const parsed = value ? Date.parse(value) : NaN;
282
+ return Number.isFinite(parsed) ? parsed : 0;
283
+ }
284
+
197
285
  function normalize(value: string) {
198
286
  return value
199
287
  .toLowerCase()
package/src/types.ts CHANGED
@@ -41,7 +41,11 @@ export type PatchOperationType =
41
41
  | "clear_field"
42
42
  | "link_record"
43
43
  | "archive_record"
44
- | "create_task";
44
+ | "create_task"
45
+ // Merge a duplicate group into a survivor. beforeValue is the group's
46
+ // record ids; afterValue is the survivor id (requires_human_survivor_selection
47
+ // until a human picks). IRREVERSIBLE on every provider that supports it.
48
+ | "merge_records";
45
49
 
46
50
  export type AuditFindingSeverity = "info" | "warning" | "critical";
47
51