fullstackgtm 0.14.1 → 0.15.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,28 @@ 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.15.0] — 2026-06-11
9
+
10
+ The Prevent layer: stop creating duplicates, and name the writer that does.
11
+
12
+ ### Added
13
+
14
+ - **`fullstackgtm resolve <account|contact|deal>`** — the create gate.
15
+ Deterministic verdicts (exists / ambiguous / safe_to_create) with matches
16
+ and reasons, using the same identity keys as the audit/merge engines:
17
+ normalized account domain, contact email, open-deal key. Names alone are
18
+ never identity (ambiguous, with candidates). Gate-shaped exit codes for
19
+ scripts: 0 = safe to create, 2 = match found, 1 = error. Exposed as
20
+ `resolveRecord()` and MCP `fullstackgtm_resolve`.
21
+ - **Record provenance** (`RecordProvenance` on accounts/contacts/deals):
22
+ HubSpot snapshots capture the read-only `hs_object_source`,
23
+ `hs_object_source_label`, `hs_object_source_id` fields, and the three
24
+ duplicate rules append writer attribution to findings — "Created by:
25
+ Gojiberry (app-123) ×2, CRM_UI" — so recurring dupes are fixed at the
26
+ faucet. Provenance is exempt from merge conflicts and diff drift, and
27
+ records created by the CLI's own `create:` path stamp
28
+ `hs_object_source_detail_2` (best-effort).
29
+
8
30
  ## [0.14.1] — 2026-06-11
9
31
 
10
32
  Fixes from the 0.14.0 journey verification (4 agents, 21 checks).
package/README.md CHANGED
@@ -74,6 +74,20 @@ for t in transcripts/*; do fullstackgtm call parse --transcript "$t" --ndjson --
74
74
 
75
75
  The boundary that remains: Slack/Notion/warehouse sinks are *your* pipeline, composed around the JSON — and your rubrics and keys stay yours.
76
76
 
77
+ ## The create gate: no new dupes
78
+
79
+ Detection cleans up yesterday's duplicates; the **resolve gate** prevents tomorrow's. Before any writer — a sync job, a webhook handler, an agent, your own script — creates a record, ask the gate:
80
+
81
+ ```bash
82
+ fullstackgtm resolve contact --email jane@acme.com --input snap.json # exit 0 = safe to create
83
+ fullstackgtm resolve account --domain acme.com --provider hubspot # exit 2 = exists/ambiguous: do NOT create
84
+ fullstackgtm resolve deal --name "Acme Expansion" --account-id 123 --input snap.json
85
+ ```
86
+
87
+ Identity keys match the audit/merge engines exactly: account domain (normalized), contact email, and the open-deal key (account + normalized name). Names alone are never identity — they return `ambiguous` with the candidates, not a guess. Exit codes are gate-shaped for scripts: `0` safe to create, `2` match found, `1` error. For high-volume writers, pair it with a cron-refreshed snapshot file rather than a live `--provider` fetch per call. Also exposed as `resolveRecord()` and the MCP tool `fullstackgtm_resolve`.
88
+
89
+ **Provenance attribution** closes the loop on recurring dupes: snapshots now capture each record's source (HubSpot's read-only `hs_object_source*` fields), and duplicate findings name the writer — `"3 accounts share acme.com … Created by: Gojiberry (app-123) ×2, CRM_UI"` — so you fix the integration, not just the records. Records created by this CLI stamp their own provenance (`hs_object_source_detail_2`, best-effort).
90
+
77
91
  ## From findings to fixes: the suggest chain
78
92
 
79
93
  Most placeholder answers are already derivable from your own CRM data. `suggest` computes them deterministically — account-name matching cross-checked against contact associations — with a confidence level and a written reason per operation, so you (or an agent) approve evidence, not guesses:
package/dist/cli.js CHANGED
@@ -19,6 +19,7 @@ import { builtinAuditRules } from "./rules.js";
19
19
  import { sampleSnapshot } from "./sampleData.js";
20
20
  import { normalizeTranscript, parseCall, suggestCallDeal } from "./calls.js";
21
21
  import { DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
22
+ import { resolveRecord } from "./resolve.js";
22
23
  import { suggestValues } from "./suggest.js";
23
24
  function usage() {
24
25
  return `FullStackGTM — audit GTM data across providers, propose reviewable patch plans,
@@ -51,6 +52,9 @@ Usage:
51
52
  ANTHROPIC_API_KEY/OPENAI_API_KEY, or \`login anthropic|openai\`);
52
53
  --deterministic uses the free keyword baseline. Then link the call
53
54
  to its deal and propose governed next-step writes.
55
+ fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]
56
+ the create gate: exit 0 = safe to create, exit 2 = match
57
+ found (exists/ambiguous) — call before ANY record creation
54
58
  fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
55
59
  derive values for requires_human_* placeholders
56
60
  from snapshot evidence, with confidence + reasons
@@ -711,6 +715,38 @@ function buildCallPlan(parsed, deal, proposed, current, extraNextSteps) {
711
715
  operations,
712
716
  };
713
717
  }
718
+ /**
719
+ * The resolve gate: exit 0 = safe to create, exit 2 = match found (exists or
720
+ * ambiguous — do NOT blind-create), exit 1 = error. Built for sync jobs and
721
+ * webhook handlers to call before any record creation.
722
+ */
723
+ async function resolveCommand(args) {
724
+ const [objectType, ...rest] = args;
725
+ if (!objectType || !["account", "contact", "deal"].includes(objectType)) {
726
+ throw new Error("Usage: fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]");
727
+ }
728
+ const candidate = {
729
+ objectType: objectType,
730
+ name: option(rest, "--name") ?? undefined,
731
+ domain: option(rest, "--domain") ?? undefined,
732
+ email: option(rest, "--email") ?? undefined,
733
+ accountId: option(rest, "--account-id") ?? undefined,
734
+ };
735
+ const snapshot = await readSnapshot(rest);
736
+ const result = resolveRecord(snapshot, candidate);
737
+ if (rest.includes("--json")) {
738
+ console.log(JSON.stringify(result, null, 2));
739
+ }
740
+ else {
741
+ const marker = result.verdict === "safe_to_create" ? "✓" : result.verdict === "exists" ? "=" : "?";
742
+ console.log(`${marker} [${result.verdict}] ${result.reason}`);
743
+ for (const m of result.matches) {
744
+ console.log(` ${m.id} "${m.name}" — matched by ${m.matchedBy}: ${m.detail}`);
745
+ }
746
+ }
747
+ if (result.verdict !== "safe_to_create")
748
+ process.exitCode = 2;
749
+ }
714
750
  async function suggest(args) {
715
751
  const planId = option(args, "--plan-id");
716
752
  const planPath = option(args, "--plan");
@@ -1456,6 +1492,10 @@ export async function runCli(argv) {
1456
1492
  await callCommand(args);
1457
1493
  return;
1458
1494
  }
1495
+ if (command === "resolve") {
1496
+ await resolveCommand(args);
1497
+ return;
1498
+ }
1459
1499
  if (command === "profiles") {
1460
1500
  profilesCommand(args);
1461
1501
  return;
@@ -84,7 +84,10 @@ export function createHubspotConnector(options) {
84
84
  email: stringOrUndefined(owner.email),
85
85
  active: owner.archived !== true,
86
86
  }));
87
- const companyProperties = mappedFields(mappings, "accounts", HUBSPOT_DEFAULT_FIELD_MAPPINGS.accounts).join(",");
87
+ // Read-only record-source fields power duplicate-finding attribution
88
+ // ("all five created by integration X") — see RecordProvenance.
89
+ const PROVENANCE_PROPERTIES = "hs_object_source,hs_object_source_label,hs_object_source_id";
90
+ const companyProperties = `${mappedFields(mappings, "accounts", HUBSPOT_DEFAULT_FIELD_MAPPINGS.accounts).join(",")},${PROVENANCE_PROPERTIES}`;
88
91
  const companies = await fetchObjects("companies", companyProperties, false);
89
92
  const accounts = companies
90
93
  .filter((company) => company.id)
@@ -101,11 +104,12 @@ export function createHubspotConnector(options) {
101
104
  employeeCount: numberOrUndefined(readMapped(props, "accounts", "employeeCount", "numberofemployees")),
102
105
  annualRevenue: numberOrUndefined(readMapped(props, "accounts", "annualRevenue", "annualrevenue")),
103
106
  ownerId: stringOrUndefined(readMapped(props, "accounts", "ownerId", "hubspot_owner_id")),
107
+ provenance: provenanceFrom(props),
104
108
  lastSyncAt: stringOrUndefined(company.updatedAt),
105
109
  raw: company,
106
110
  };
107
111
  });
108
- const contactProperties = mappedFields(mappings, "contacts", HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts).join(",");
112
+ const contactProperties = `${mappedFields(mappings, "contacts", HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts).join(",")},${PROVENANCE_PROPERTIES}`;
109
113
  const hubspotContacts = await fetchObjects("contacts", contactProperties, true);
110
114
  const contacts = hubspotContacts
111
115
  .filter((contact) => contact.id)
@@ -124,11 +128,12 @@ export function createHubspotConnector(options) {
124
128
  phone: stringOrUndefined(readMapped(props, "contacts", "phone", "phone")),
125
129
  title: stringOrUndefined(readMapped(props, "contacts", "title", "jobtitle")),
126
130
  ownerId: stringOrUndefined(readMapped(props, "contacts", "ownerId", "hubspot_owner_id")),
131
+ provenance: provenanceFrom(props),
127
132
  lastSyncAt: stringOrUndefined(contact.updatedAt),
128
133
  raw: contact,
129
134
  };
130
135
  });
131
- const dealProperties = mappedFields(mappings, "deals", HUBSPOT_DEFAULT_FIELD_MAPPINGS.deals).join(",");
136
+ const dealProperties = `${mappedFields(mappings, "deals", HUBSPOT_DEFAULT_FIELD_MAPPINGS.deals).join(",")},${PROVENANCE_PROPERTIES}`;
132
137
  const hubspotDeals = await fetchObjects("deals", dealProperties, true);
133
138
  const deals = hubspotDeals
134
139
  .filter((deal) => deal.id)
@@ -152,6 +157,7 @@ export function createHubspotConnector(options) {
152
157
  identities: [{ provider: "hubspot", externalId: String(deal.id) }],
153
158
  accountId: companyId ? String(companyId) : undefined,
154
159
  ownerId: stringOrUndefined(readMapped(props, "deals", "ownerId", "hubspot_owner_id")),
160
+ provenance: provenanceFrom(props),
155
161
  name: stringOrFallback(readMapped(props, "deals", "name", "dealname"), "Untitled Deal"),
156
162
  amount: numberOrUndefined(readMapped(props, "deals", "amount", "amount")),
157
163
  stage,
@@ -323,10 +329,23 @@ export function createHubspotConnector(options) {
323
329
  createdCompaniesByName.set(nameKey, companyId);
324
330
  }
325
331
  else {
326
- const created = await request(`/crm/v3/objects/companies`, {
327
- method: "POST",
328
- body: JSON.stringify({ properties: { name } }),
329
- });
332
+ let created;
333
+ try {
334
+ created = await request(`/crm/v3/objects/companies`, {
335
+ method: "POST",
336
+ body: JSON.stringify({
337
+ properties: { name, hs_object_source_detail_2: "fullstackgtm create: operation" },
338
+ }),
339
+ });
340
+ }
341
+ catch {
342
+ // Some portals reject writes to source-detail properties — the
343
+ // provenance stamp is best-effort, the create is not.
344
+ created = await request(`/crm/v3/objects/companies`, {
345
+ method: "POST",
346
+ body: JSON.stringify({ properties: { name } }),
347
+ });
348
+ }
330
349
  companyId = String(created.id);
331
350
  createdCompanyName = name;
332
351
  createdCompaniesByName.set(nameKey, companyId);
@@ -394,6 +413,34 @@ export function createHubspotConnector(options) {
394
413
  catch {
395
414
  // fall through to create
396
415
  }
416
+ // A live CRM often already carries a human-created follow-up for the same
417
+ // record (a previous partial run, or a rep's own task). Creating another
418
+ // on top is duplicate noise — skip when the object already has an open
419
+ // task, regardless of who created it. Fail-open: a lookup hiccup must
420
+ // not block the apply.
421
+ try {
422
+ const objectPath = OBJECT_PATHS[operation.objectType];
423
+ const assoc = await request(`/crm/v4/objects/${objectPath}/${encodeURIComponent(operation.objectId)}/associations/tasks?limit=20`);
424
+ const taskIds = (assoc?.results ?? [])
425
+ .map((row) => String(row.toObjectId ?? ""))
426
+ .filter(Boolean)
427
+ .slice(0, 10);
428
+ for (const taskId of taskIds) {
429
+ const existingTask = await request(`/crm/v3/objects/tasks/${encodeURIComponent(taskId)}?properties=hs_task_status`);
430
+ const status = String(existingTask?.properties?.hs_task_status ?? "");
431
+ if (status !== "COMPLETED" && status !== "DELETED") {
432
+ return {
433
+ operationId: operation.id,
434
+ status: "skipped",
435
+ detail: `An open task (task ${taskId}) already exists on ${operation.objectType}/${operation.objectId}; not creating a duplicate follow-up.`,
436
+ providerData: { id: taskId, existing: true },
437
+ };
438
+ }
439
+ }
440
+ }
441
+ catch {
442
+ // fall through to create
443
+ }
397
444
  const body = `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`;
398
445
  const response = await request(`/crm/v3/objects/tasks`, {
399
446
  method: "POST",
@@ -567,6 +614,14 @@ export function createHubspotConnector(options) {
567
614
  readField,
568
615
  };
569
616
  }
617
+ function provenanceFrom(props) {
618
+ const source = stringOrUndefined(props.hs_object_source);
619
+ const sourceLabel = stringOrUndefined(props.hs_object_source_label);
620
+ const sourceId = stringOrUndefined(props.hs_object_source_id);
621
+ if (!source && !sourceLabel && !sourceId)
622
+ return undefined;
623
+ return { source, sourceLabel, sourceId };
624
+ }
570
625
  function stringOrUndefined(value) {
571
626
  if (value === undefined || value === null || value === "")
572
627
  return undefined;
package/dist/diff.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * appeared, what disappeared, what changed — and whether hygiene regressed.
5
5
  */
6
6
  // Fields that change on every sync without semantic meaning.
7
- const IGNORED_FIELDS = new Set(["raw", "lastSyncAt", "identities"]);
7
+ const IGNORED_FIELDS = new Set(["raw", "lastSyncAt", "identities", "provenance"]);
8
8
  function labelOf(record) {
9
9
  return record.name ?? record.email ?? record.id;
10
10
  }
package/dist/index.d.ts CHANGED
@@ -14,9 +14,10 @@ export { createFilePlanStore, type PlanStore, type StoredPlan } from "./planStor
14
14
  export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
15
15
  export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
16
16
  export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, type CrmObjectType, type FieldMappings, } from "./mappings.ts";
17
- export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.ts";
17
+ export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, provenanceSummary, requiresHumanInput, staleDealRule, } from "./rules.ts";
18
18
  export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, type CallDealSuggestion, type CallInsightType, type ExtractedCallInsight, type ParsedCall, type ParsedTranscriptSegment, } from "./calls.ts";
19
19
  export { sampleSnapshot } from "./sampleData.ts";
20
20
  export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, type CallScorecard, type LlmCredential, type LlmExtractedInsight, type LlmProvider, type Rubric, type ScoredDimension, } from "./llm.ts";
21
+ export { resolveRecord, type ResolveCandidate, type ResolveMatch, type ResolveResult } from "./resolve.ts";
21
22
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
22
23
  export type { ApprovalStatus, AuditFinding, AuditFindingSeverity, CanonicalAccount, CanonicalActivity, CanonicalContact, CanonicalDeal, CanonicalGtmSnapshot, CanonicalUser, CrmProvider, GtmAuditRule, GtmConnector, GtmEvidence, GtmEvidenceSourceSystem, GtmObjectType, GtmPolicy, GtmRuleContext, GtmRuleResult, GtmSnapshotIndex, PatchOperation, PatchOperationResult, PatchOperationType, PatchPlan, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
package/dist/index.js CHANGED
@@ -14,8 +14,9 @@ export { createFilePlanStore } from "./planStore.js";
14
14
  export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
15
15
  export { auditReportToHtml, auditReportToMarkdown } from "./report.js";
16
16
  export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, } from "./mappings.js";
17
- export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.js";
17
+ export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, provenanceSummary, requiresHumanInput, staleDealRule, } from "./rules.js";
18
18
  export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, } from "./calls.js";
19
19
  export { sampleSnapshot } from "./sampleData.js";
20
20
  export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
21
+ export { resolveRecord } from "./resolve.js";
21
22
  export { suggestValues } from "./suggest.js";
package/dist/mcp.js CHANGED
@@ -47,6 +47,7 @@ import { builtinAuditRules } from "./rules.js";
47
47
  import { sampleSnapshot } from "./sampleData.js";
48
48
  import { normalizeTranscript, parseCall } from "./calls.js";
49
49
  import { extractInsightsLlm, resolveLlmCredential } from "./llm.js";
50
+ import { resolveRecord } from "./resolve.js";
50
51
  import { suggestValues } from "./suggest.js";
51
52
  function content(value) {
52
53
  return {
@@ -197,6 +198,25 @@ export async function startMcpServer() {
197
198
  }
198
199
  return content(parseCall(raw, { title, sourceSystem: source }));
199
200
  });
201
+ server.registerTool("fullstackgtm_resolve", {
202
+ title: "Resolve Record (create gate)",
203
+ description: "Before creating a CRM record, check whether it already exists. Returns a verdict " +
204
+ "(exists | ambiguous | safe_to_create) with matches and a reason, using the same " +
205
+ "identity keys as the audit/merge engines (account domain, contact email, open-deal " +
206
+ "key). Read-only. Never create on 'exists' or 'ambiguous'.",
207
+ inputSchema: {
208
+ objectType: z.enum(["account", "contact", "deal"]),
209
+ name: z.string().optional(),
210
+ domain: z.string().optional(),
211
+ email: z.string().optional(),
212
+ accountId: z.string().optional(),
213
+ provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
214
+ inputPath: z.string().optional(),
215
+ },
216
+ }, async ({ objectType, name, domain, email, accountId, provider, inputPath }) => {
217
+ const snapshot = await readSnapshot(provider, inputPath);
218
+ return content(resolveRecord(snapshot, { objectType, name, domain, email, accountId }));
219
+ });
200
220
  server.registerTool("fullstackgtm_rules", {
201
221
  title: "List Audit Rules",
202
222
  description: "List the built-in deterministic audit rules with ids and descriptions.",
package/dist/merge.js CHANGED
@@ -1,5 +1,5 @@
1
1
  const CONFLICT_IGNORED_FIELDS = new Set([
2
- "id", "provider", "crmId", "identities", "raw", "lastSyncAt", "lastActivityAt", "ownerId", "accountId",
2
+ "id", "provider", "crmId", "identities", "raw", "lastSyncAt", "lastActivityAt", "ownerId", "accountId", "provenance",
3
3
  ]);
4
4
  export function normalizeDomain(domain) {
5
5
  if (!domain)
@@ -0,0 +1,37 @@
1
+ import type { CanonicalGtmSnapshot } from "./types.ts";
2
+ /**
3
+ * The resolve gate — the Prevent layer of the CRM-health lifecycle.
4
+ *
5
+ * Before any writer (sync job, webhook handler, agent, this CLI's own
6
+ * `create:` path) creates a record, it should ask: does this record already
7
+ * exist? The gate answers deterministically from a snapshot using the same
8
+ * identity keys the audit and merge engines use:
9
+ *
10
+ * account → normalized domain (exact), then normalized name
11
+ * contact → normalized email (exact), then full name
12
+ * deal → open-deal key: (accountId | "unlinked") + normalized name
13
+ *
14
+ * Verdicts are gate-shaped: `exists` (link to it, don't create),
15
+ * `ambiguous` (a human must pick — do NOT blind-create), `safe_to_create`.
16
+ */
17
+ export type ResolveCandidate = {
18
+ objectType: "account" | "contact" | "deal";
19
+ name?: string;
20
+ domain?: string;
21
+ email?: string;
22
+ /** For deals: scope the duplicate key to an account. */
23
+ accountId?: string;
24
+ };
25
+ export type ResolveMatch = {
26
+ id: string;
27
+ name: string;
28
+ matchedBy: "domain" | "email" | "name" | "deal_key";
29
+ detail: string;
30
+ };
31
+ export type ResolveResult = {
32
+ objectType: ResolveCandidate["objectType"];
33
+ verdict: "exists" | "ambiguous" | "safe_to_create";
34
+ matches: ResolveMatch[];
35
+ reason: string;
36
+ };
37
+ export declare function resolveRecord(snapshot: CanonicalGtmSnapshot, candidate: ResolveCandidate): ResolveResult;
@@ -0,0 +1,107 @@
1
+ import { normalizeDomain } from "./merge.js";
2
+ export function resolveRecord(snapshot, candidate) {
3
+ if (candidate.objectType === "account")
4
+ return resolveAccount(snapshot, candidate);
5
+ if (candidate.objectType === "contact")
6
+ return resolveContact(snapshot, candidate);
7
+ return resolveDeal(snapshot, candidate);
8
+ }
9
+ function resolveAccount(snapshot, c) {
10
+ const base = { objectType: "account" };
11
+ const domain = normalizeDomain(c.domain);
12
+ if (domain) {
13
+ const matches = snapshot.accounts
14
+ .filter((a) => normalizeDomain(a.domain) === domain)
15
+ .map((a) => match(a.id, a.name, "domain", `account domain ${a.domain} normalizes to ${domain}`));
16
+ if (matches.length === 1) {
17
+ return { ...base, verdict: "exists", matches, reason: `An account with domain ${domain} already exists: "${matches[0].name}" (${matches[0].id}). Link to it instead of creating.` };
18
+ }
19
+ if (matches.length > 1) {
20
+ return { ...base, verdict: "ambiguous", matches, reason: `${matches.length} accounts already share domain ${domain} — that's a duplicate group; merge it before adding more. Do not create.` };
21
+ }
22
+ }
23
+ if (c.name) {
24
+ const key = normalizeName(c.name);
25
+ const matches = snapshot.accounts
26
+ .filter((a) => normalizeName(a.name) === key)
27
+ .map((a) => match(a.id, a.name, "name", `account name matches "${c.name}" (domain: ${a.domain ?? "none"})`));
28
+ if (matches.length > 0) {
29
+ // Name alone is suggestive, not identity — two real companies can share
30
+ // a name (the merge engine treats this the same way).
31
+ return {
32
+ ...base,
33
+ verdict: "ambiguous",
34
+ matches,
35
+ reason: `${matches.length} account(s) named "${c.name}" exist but ${domain ? `none share domain ${domain}` : "no domain was supplied to confirm identity"}. Confirm before creating — supply a domain to disambiguate.`,
36
+ };
37
+ }
38
+ }
39
+ if (!domain && !c.name) {
40
+ return { ...base, verdict: "ambiguous", matches: [], reason: "Supply --domain and/or --name to resolve an account." };
41
+ }
42
+ return { ...base, verdict: "safe_to_create", matches: [], reason: `No account matches ${domain ? `domain ${domain}` : `name "${c.name}"`}. Safe to create.` };
43
+ }
44
+ function resolveContact(snapshot, c) {
45
+ const base = { objectType: "contact" };
46
+ const email = c.email?.trim().toLowerCase();
47
+ if (email) {
48
+ const matches = snapshot.contacts
49
+ .filter((row) => row.email?.trim().toLowerCase() === email)
50
+ .map((row) => match(row.id, contactName(row), "email", `contact email matches ${email}`));
51
+ if (matches.length === 1) {
52
+ return { ...base, verdict: "exists", matches, reason: `A contact with email ${email} already exists: "${matches[0].name}" (${matches[0].id}). Update it instead of creating.` };
53
+ }
54
+ if (matches.length > 1) {
55
+ return { ...base, verdict: "ambiguous", matches, reason: `${matches.length} contacts already share ${email} — a duplicate group; merge before adding more. Do not create.` };
56
+ }
57
+ return { ...base, verdict: "safe_to_create", matches: [], reason: `No contact matches ${email}. Safe to create.` };
58
+ }
59
+ if (c.name) {
60
+ const key = normalizeName(c.name);
61
+ const matches = snapshot.contacts
62
+ .filter((row) => normalizeName(contactName(row)) === key)
63
+ .map((row) => match(row.id, contactName(row), "name", `contact name matches "${c.name}" (email: ${row.email ?? "none"})`));
64
+ if (matches.length > 0) {
65
+ return { ...base, verdict: "ambiguous", matches, reason: `${matches.length} contact(s) named "${c.name}" exist; names are not identity. Supply --email to resolve definitively.` };
66
+ }
67
+ return { ...base, verdict: "safe_to_create", matches: [], reason: `No contact named "${c.name}". Safe to create — but prefer resolving by email.` };
68
+ }
69
+ return { ...base, verdict: "ambiguous", matches: [], reason: "Supply --email (preferred) or --name to resolve a contact." };
70
+ }
71
+ function resolveDeal(snapshot, c) {
72
+ const base = { objectType: "deal" };
73
+ if (!c.name) {
74
+ return { ...base, verdict: "ambiguous", matches: [], reason: "Supply --name (and ideally --account-id) to resolve a deal." };
75
+ }
76
+ const key = `${c.accountId ?? "unlinked"}:${normalizeName(c.name)}`;
77
+ const open = snapshot.deals.filter((d) => d.isClosed !== true && d.isWon !== true);
78
+ const matches = open
79
+ .filter((d) => `${d.accountId ?? "unlinked"}:${normalizeName(d.name)}` === key)
80
+ .map((d) => match(d.id, d.name, "deal_key", `open deal with the same name on ${c.accountId ? `account ${c.accountId}` : "no account"}`));
81
+ if (matches.length > 0) {
82
+ return {
83
+ ...base,
84
+ verdict: "exists",
85
+ matches,
86
+ reason: `${matches.length} open deal(s) already match "${c.name}" on ${c.accountId ? `account ${c.accountId}` : "unlinked"} — creating another would double-count pipeline. Update the existing deal.`,
87
+ };
88
+ }
89
+ const closedSameName = snapshot.deals.filter((d) => (d.isClosed === true || d.isWon === true) && normalizeName(d.name) === normalizeName(c.name));
90
+ return {
91
+ ...base,
92
+ verdict: "safe_to_create",
93
+ matches: [],
94
+ reason: closedSameName.length > 0
95
+ ? `No open deal matches; ${closedSameName.length} closed deal(s) share the name (a re-open/renewal may be intended). Safe to create.`
96
+ : `No open deal matches "${c.name}". Safe to create.`,
97
+ };
98
+ }
99
+ function contactName(row) {
100
+ return [row.firstName, row.lastName].filter(Boolean).join(" ") || "(unnamed)";
101
+ }
102
+ function normalizeName(value) {
103
+ return value.trim().toLowerCase().replace(/\s+/g, " ");
104
+ }
105
+ function match(id, name, matchedBy, detail) {
106
+ return { id, name, matchedBy, detail };
107
+ }
package/dist/rules.d.ts CHANGED
@@ -6,6 +6,18 @@ import type { CanonicalGtmSnapshot, GtmAuditRule, GtmSnapshotIndex } from "./typ
6
6
  */
7
7
  export declare const REQUIRES_HUMAN_PREFIX = "requires_human_";
8
8
  export declare function requiresHumanInput(value: unknown): boolean;
9
+ /**
10
+ * Attribution for duplicate groups: when the provider exposes record-source
11
+ * provenance (RecordProvenance), name the writer(s) that created the group —
12
+ * the fix for recurring dupes is upstream in the writer, not in the records.
13
+ */
14
+ export declare function provenanceSummary(records: Array<{
15
+ provenance?: {
16
+ source?: string;
17
+ sourceLabel?: string;
18
+ sourceId?: string;
19
+ };
20
+ }>): string;
9
21
  export declare function auditFindingId(ruleId: string, objectId: string): string;
10
22
  export declare function patchOperationId(ruleId: string, objectId: string): string;
11
23
  export declare function stableHash(value: string): string;
package/dist/rules.js CHANGED
@@ -8,6 +8,28 @@ export const REQUIRES_HUMAN_PREFIX = "requires_human_";
8
8
  export function requiresHumanInput(value) {
9
9
  return typeof value === "string" && value.startsWith(REQUIRES_HUMAN_PREFIX);
10
10
  }
11
+ /**
12
+ * Attribution for duplicate groups: when the provider exposes record-source
13
+ * provenance (RecordProvenance), name the writer(s) that created the group —
14
+ * the fix for recurring dupes is upstream in the writer, not in the records.
15
+ */
16
+ export function provenanceSummary(records) {
17
+ const counts = new Map();
18
+ for (const record of records) {
19
+ const p = record.provenance;
20
+ if (!p)
21
+ continue;
22
+ const label = p.sourceLabel ?? p.source ?? "unknown source";
23
+ const key = p.sourceId ? `${label} (${p.sourceId})` : label;
24
+ counts.set(key, (counts.get(key) ?? 0) + 1);
25
+ }
26
+ if (counts.size === 0)
27
+ return "";
28
+ const parts = [...counts.entries()]
29
+ .sort((a, b) => b[1] - a[1])
30
+ .map(([key, count]) => (count > 1 ? `${key} ×${count}` : key));
31
+ return ` Created by: ${parts.join(", ")}.`;
32
+ }
11
33
  export function auditFindingId(ruleId, objectId) {
12
34
  return `finding_${stableHash(`${ruleId}:${objectId}`)}`;
13
35
  }
@@ -326,7 +348,7 @@ export const duplicateAccountDomainRule = {
326
348
  ruleId: "duplicate-account-domain",
327
349
  title: "Accounts share the same domain",
328
350
  severity: "warning",
329
- summary: `${accounts.length} accounts share ${domain}: ${accounts.map((account) => account.name).join(", ")}.`,
351
+ summary: `${accounts.length} accounts share ${domain}: ${accounts.map((account) => account.name).join(", ")}.${provenanceSummary(accounts)}`,
330
352
  recommendation: "Review the group and merge duplicates so activity and deals roll up once.",
331
353
  });
332
354
  operations.push({
@@ -363,7 +385,7 @@ export const duplicateContactEmailRule = {
363
385
  ruleId: "duplicate-contact-email",
364
386
  title: "Contacts share the same email",
365
387
  severity: "warning",
366
- summary: `${contacts.length} contacts share ${email}.`,
388
+ summary: `${contacts.length} contacts share ${email}.${provenanceSummary(contacts)}`,
367
389
  recommendation: "Merge the duplicates so engagement history and routing stay coherent.",
368
390
  });
369
391
  operations.push({
@@ -410,7 +432,7 @@ export const duplicateOpenDealRule = {
410
432
  ruleId: "duplicate-open-deal",
411
433
  title: "Open deals duplicate the same opportunity",
412
434
  severity: "warning",
413
- summary: `${deals.length} open deals named "${anchor.name}"${anchor.accountId ? " on the same account" : ""}: ${deals.map((deal) => deal.id).join(", ")}.`,
435
+ summary: `${deals.length} open deals named "${anchor.name}"${anchor.accountId ? " on the same account" : ""}: ${deals.map((deal) => deal.id).join(", ")}.${provenanceSummary(deals)}`,
414
436
  recommendation: "Keep one deal, archive the copies, and fix the integration that is re-creating them.",
415
437
  });
416
438
  operations.push({
package/dist/types.d.ts CHANGED
@@ -83,11 +83,25 @@ export type CanonicalUser = {
83
83
  title?: string;
84
84
  active?: boolean;
85
85
  };
86
+ /**
87
+ * Who created a record, per the provider's read-only record-source fields
88
+ * (HubSpot: hs_object_source / _label / _id). Populated on read; used to
89
+ * attribute duplicate findings to the writer that produced them.
90
+ */
91
+ export type RecordProvenance = {
92
+ /** Provider source code, e.g. INTEGRATION, API, CRM_UI, IMPORT, FORM. */
93
+ source?: string;
94
+ /** Human label, e.g. an integration's name. */
95
+ sourceLabel?: string;
96
+ /** Provider-side id of the source (e.g. app id, import id). */
97
+ sourceId?: string;
98
+ };
86
99
  export type CanonicalAccount = {
87
100
  id: string;
88
101
  provider?: CrmProvider;
89
102
  crmId?: string;
90
103
  identities?: ProviderIdentity[];
104
+ provenance?: RecordProvenance;
91
105
  name: string;
92
106
  domain?: string;
93
107
  industry?: string;
@@ -104,6 +118,7 @@ export type CanonicalContact = {
104
118
  provider?: CrmProvider;
105
119
  crmId?: string;
106
120
  identities?: ProviderIdentity[];
121
+ provenance?: RecordProvenance;
107
122
  accountId?: string;
108
123
  firstName?: string;
109
124
  lastName?: string;
@@ -121,6 +136,7 @@ export type CanonicalDeal = {
121
136
  provider?: CrmProvider;
122
137
  crmId?: string;
123
138
  identities?: ProviderIdentity[];
139
+ provenance?: RecordProvenance;
124
140
  accountId?: string;
125
141
  ownerId?: string;
126
142
  name: string;
@@ -41,12 +41,11 @@ fix the faucet instead of mopping the puddle.
41
41
  a unique match, refuse on ambiguity, create only on a confirmed miss.
42
42
  HubSpot's search API is eventually consistent (~5–10s), so same-run
43
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).
44
+ - The **`resolve` gate** (shipped 0.15): `fullstackgtm resolve
45
+ <account|contact|deal>` returns exists/ambiguous/safe_to_create with
46
+ matches and reasons; exit 0 = safe, exit 2 = do not create. Same identity
47
+ keys as the audit/merge engines. Also `resolveRecord()` and MCP
48
+ `fullstackgtm_resolve`.
50
49
  - **Stamp provenance on our own creates** (HubSpot allows integrations to
51
50
  set `hs_object_source_detail_2/3` at create time).
52
51
  - Recommend native config in `doctor`/audit: Salesforce duplicate rules
@@ -61,10 +60,10 @@ fix the faucet instead of mopping the puddle.
61
60
  (ruleId, objectId).
62
61
  - **The nightly watch recipe** ("CRM CI"): scheduled
63
62
  `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.
63
+ - **Attribution** (shipped 0.15): snapshots capture HubSpot's read-only
64
+ `hs_object_source*` into `RecordProvenance`; duplicate findings append
65
+ *"Created by: …"* naming the writer(s). The fix for recurring dupes is
66
+ upstream, in the writer.
68
67
  - Incremental reads (`snapshot --since`) exist for all three connectors;
69
68
  caveats: HubSpot deltas carry no associations and cap at 10k per object,
70
69
  Stripe deltas catch creations only.
@@ -131,5 +130,6 @@ Lessons from auditing our own apply path:
131
130
  | --- | --- |
132
131
  | 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
132
  | 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
- | 0.13 | `resolve` gate (CLI/lib/MCP), provenance capture + attribution in findings, prevention-posture checks |
133
+ | 0.15 (shipped) | `resolve` gate (CLI/lib/MCP, gate exit codes), provenance capture (`hs_object_source*` → `RecordProvenance`) + attribution in duplicate findings, self-stamped creates |
134
+ | 0.16 | prevention-posture checks (native duplicate rules active? unique-value properties defined?) · live targeted resolve lookups |
135
135
  | docs | The nightly watch recipe (existing flags, documented as CRM CI) |
package/llms.txt CHANGED
@@ -18,6 +18,10 @@ at/above `--fail-on`.
18
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
19
19
  - [CHANGELOG](https://github.com/fullstackgtm/core/blob/main/CHANGELOG.md): release history
20
20
 
21
+ `fullstackgtm resolve <type>` is the create gate: exit 0 = safe to create,
22
+ exit 2 = exists/ambiguous (never blind-create); duplicate findings carry
23
+ "Created by:" writer attribution when the provider exposes record source.
24
+
21
25
  ## Key invariants (calls)
22
26
 
23
27
  `fullstackgtm call parse` defaults to LLM extraction (BYO Anthropic/OpenAI
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.14.1",
3
+ "version": "0.15.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",
package/src/cli.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  type CallScorecard,
51
51
  type LlmProvider,
52
52
  } from "./llm.ts";
53
+ import { resolveRecord, type ResolveCandidate } from "./resolve.ts";
53
54
  import { suggestValues, type ValueSuggestion } from "./suggest.ts";
54
55
  import type { FieldMappings } from "./mappings.ts";
55
56
  import type {
@@ -90,6 +91,9 @@ Usage:
90
91
  ANTHROPIC_API_KEY/OPENAI_API_KEY, or \`login anthropic|openai\`);
91
92
  --deterministic uses the free keyword baseline. Then link the call
92
93
  to its deal and propose governed next-step writes.
94
+ fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]
95
+ the create gate: exit 0 = safe to create, exit 2 = match
96
+ found (exists/ambiguous) — call before ANY record creation
93
97
  fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
94
98
  derive values for requires_human_* placeholders
95
99
  from snapshot evidence, with confidence + reasons
@@ -797,6 +801,37 @@ function buildCallPlan(
797
801
  };
798
802
  }
799
803
 
804
+ /**
805
+ * The resolve gate: exit 0 = safe to create, exit 2 = match found (exists or
806
+ * ambiguous — do NOT blind-create), exit 1 = error. Built for sync jobs and
807
+ * webhook handlers to call before any record creation.
808
+ */
809
+ async function resolveCommand(args: string[]) {
810
+ const [objectType, ...rest] = args;
811
+ if (!objectType || !["account", "contact", "deal"].includes(objectType)) {
812
+ throw new Error("Usage: fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]");
813
+ }
814
+ const candidate: ResolveCandidate = {
815
+ objectType: objectType as ResolveCandidate["objectType"],
816
+ name: option(rest, "--name") ?? undefined,
817
+ domain: option(rest, "--domain") ?? undefined,
818
+ email: option(rest, "--email") ?? undefined,
819
+ accountId: option(rest, "--account-id") ?? undefined,
820
+ };
821
+ const snapshot = await readSnapshot(rest);
822
+ const result = resolveRecord(snapshot, candidate);
823
+ if (rest.includes("--json")) {
824
+ console.log(JSON.stringify(result, null, 2));
825
+ } else {
826
+ const marker = result.verdict === "safe_to_create" ? "✓" : result.verdict === "exists" ? "=" : "?";
827
+ console.log(`${marker} [${result.verdict}] ${result.reason}`);
828
+ for (const m of result.matches) {
829
+ console.log(` ${m.id} "${m.name}" — matched by ${m.matchedBy}: ${m.detail}`);
830
+ }
831
+ }
832
+ if (result.verdict !== "safe_to_create") process.exitCode = 2;
833
+ }
834
+
800
835
  async function suggest(args: string[]) {
801
836
  const planId = option(args, "--plan-id");
802
837
  const planPath = option(args, "--plan");
@@ -1626,6 +1661,10 @@ export async function runCli(argv: string[]) {
1626
1661
  await callCommand(args);
1627
1662
  return;
1628
1663
  }
1664
+ if (command === "resolve") {
1665
+ await resolveCommand(args);
1666
+ return;
1667
+ }
1629
1668
  if (command === "profiles") {
1630
1669
  profilesCommand(args);
1631
1670
  return;
@@ -10,6 +10,7 @@ import type {
10
10
  CanonicalAccount,
11
11
  CanonicalContact,
12
12
  CanonicalDeal,
13
+ RecordProvenance,
13
14
  CanonicalGtmSnapshot,
14
15
  CanonicalUser,
15
16
  GtmConnector,
@@ -125,11 +126,14 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
125
126
  active: owner.archived !== true,
126
127
  }));
127
128
 
128
- const companyProperties = mappedFields(
129
+ // Read-only record-source fields power duplicate-finding attribution
130
+ // ("all five created by integration X") — see RecordProvenance.
131
+ const PROVENANCE_PROPERTIES = "hs_object_source,hs_object_source_label,hs_object_source_id";
132
+ const companyProperties = `${mappedFields(
129
133
  mappings,
130
134
  "accounts",
131
135
  HUBSPOT_DEFAULT_FIELD_MAPPINGS.accounts,
132
- ).join(",");
136
+ ).join(",")},${PROVENANCE_PROPERTIES}`;
133
137
  const companies = await fetchObjects("companies", companyProperties, false);
134
138
  const accounts: CanonicalAccount[] = companies
135
139
  .filter((company) => company.id)
@@ -155,16 +159,17 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
155
159
  ownerId: stringOrUndefined(
156
160
  readMapped(props, "accounts", "ownerId", "hubspot_owner_id"),
157
161
  ),
162
+ provenance: provenanceFrom(props),
158
163
  lastSyncAt: stringOrUndefined(company.updatedAt),
159
164
  raw: company,
160
165
  };
161
166
  });
162
167
 
163
- const contactProperties = mappedFields(
168
+ const contactProperties = `${mappedFields(
164
169
  mappings,
165
170
  "contacts",
166
171
  HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts,
167
- ).join(",");
172
+ ).join(",")},${PROVENANCE_PROPERTIES}`;
168
173
  const hubspotContacts = await fetchObjects("contacts", contactProperties, true);
169
174
  const contacts: CanonicalContact[] = hubspotContacts
170
175
  .filter((contact) => contact.id)
@@ -185,16 +190,17 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
185
190
  ownerId: stringOrUndefined(
186
191
  readMapped(props, "contacts", "ownerId", "hubspot_owner_id"),
187
192
  ),
193
+ provenance: provenanceFrom(props),
188
194
  lastSyncAt: stringOrUndefined(contact.updatedAt),
189
195
  raw: contact,
190
196
  };
191
197
  });
192
198
 
193
- const dealProperties = mappedFields(
199
+ const dealProperties = `${mappedFields(
194
200
  mappings,
195
201
  "deals",
196
202
  HUBSPOT_DEFAULT_FIELD_MAPPINGS.deals,
197
- ).join(",");
203
+ ).join(",")},${PROVENANCE_PROPERTIES}`;
198
204
  const hubspotDeals = await fetchObjects("deals", dealProperties, true);
199
205
  const deals: CanonicalDeal[] = hubspotDeals
200
206
  .filter((deal) => deal.id)
@@ -220,6 +226,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
220
226
  identities: [{ provider: "hubspot", externalId: String(deal.id) }],
221
227
  accountId: companyId ? String(companyId) : undefined,
222
228
  ownerId: stringOrUndefined(readMapped(props, "deals", "ownerId", "hubspot_owner_id")),
229
+ provenance: provenanceFrom(props),
223
230
  name: stringOrFallback(readMapped(props, "deals", "name", "dealname"), "Untitled Deal"),
224
231
  amount: numberOrUndefined(readMapped(props, "deals", "amount", "amount")),
225
232
  stage,
@@ -419,10 +426,22 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
419
426
  resolvedExisting = true;
420
427
  createdCompaniesByName.set(nameKey, companyId);
421
428
  } else {
422
- const created = await request(`/crm/v3/objects/companies`, {
423
- method: "POST",
424
- body: JSON.stringify({ properties: { name } }),
425
- });
429
+ let created;
430
+ try {
431
+ created = await request(`/crm/v3/objects/companies`, {
432
+ method: "POST",
433
+ body: JSON.stringify({
434
+ properties: { name, hs_object_source_detail_2: "fullstackgtm create: operation" },
435
+ }),
436
+ });
437
+ } catch {
438
+ // Some portals reject writes to source-detail properties — the
439
+ // provenance stamp is best-effort, the create is not.
440
+ created = await request(`/crm/v3/objects/companies`, {
441
+ method: "POST",
442
+ body: JSON.stringify({ properties: { name } }),
443
+ });
444
+ }
426
445
  companyId = String(created.id);
427
446
  createdCompanyName = name;
428
447
  createdCompaniesByName.set(nameKey, companyId);
@@ -494,6 +513,37 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
494
513
  } catch {
495
514
  // fall through to create
496
515
  }
516
+ // A live CRM often already carries a human-created follow-up for the same
517
+ // record (a previous partial run, or a rep's own task). Creating another
518
+ // on top is duplicate noise — skip when the object already has an open
519
+ // task, regardless of who created it. Fail-open: a lookup hiccup must
520
+ // not block the apply.
521
+ try {
522
+ const objectPath = OBJECT_PATHS[operation.objectType];
523
+ const assoc = await request(
524
+ `/crm/v4/objects/${objectPath}/${encodeURIComponent(operation.objectId)}/associations/tasks?limit=20`,
525
+ );
526
+ const taskIds = ((assoc?.results ?? []) as Array<{ toObjectId?: number | string }>)
527
+ .map((row) => String(row.toObjectId ?? ""))
528
+ .filter(Boolean)
529
+ .slice(0, 10);
530
+ for (const taskId of taskIds) {
531
+ const existingTask = await request(
532
+ `/crm/v3/objects/tasks/${encodeURIComponent(taskId)}?properties=hs_task_status`,
533
+ );
534
+ const status = String(existingTask?.properties?.hs_task_status ?? "");
535
+ if (status !== "COMPLETED" && status !== "DELETED") {
536
+ return {
537
+ operationId: operation.id,
538
+ status: "skipped",
539
+ detail: `An open task (task ${taskId}) already exists on ${operation.objectType}/${operation.objectId}; not creating a duplicate follow-up.`,
540
+ providerData: { id: taskId, existing: true },
541
+ };
542
+ }
543
+ }
544
+ } catch {
545
+ // fall through to create
546
+ }
497
547
  const body = `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`;
498
548
  const response = await request(`/crm/v3/objects/tasks`, {
499
549
  method: "POST",
@@ -686,6 +736,14 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
686
736
  };
687
737
  }
688
738
 
739
+ function provenanceFrom(props: Record<string, unknown>): RecordProvenance | undefined {
740
+ const source = stringOrUndefined(props.hs_object_source);
741
+ const sourceLabel = stringOrUndefined(props.hs_object_source_label);
742
+ const sourceId = stringOrUndefined(props.hs_object_source_id);
743
+ if (!source && !sourceLabel && !sourceId) return undefined;
744
+ return { source, sourceLabel, sourceId };
745
+ }
746
+
689
747
  function stringOrUndefined(value: unknown): string | undefined {
690
748
  if (value === undefined || value === null || value === "") return undefined;
691
749
  return String(value);
package/src/diff.ts CHANGED
@@ -7,7 +7,7 @@ import type { AuditFinding, CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
7
7
  */
8
8
 
9
9
  // Fields that change on every sync without semantic meaning.
10
- const IGNORED_FIELDS = new Set(["raw", "lastSyncAt", "identities"]);
10
+ const IGNORED_FIELDS = new Set(["raw", "lastSyncAt", "identities", "provenance"]);
11
11
 
12
12
  export type FieldChange = { field: string; before: unknown; after: unknown };
13
13
 
package/src/index.ts CHANGED
@@ -95,6 +95,7 @@ export {
95
95
  orphanAccountRule,
96
96
  pastCloseDateRule,
97
97
  patchOperationId,
98
+ provenanceSummary,
98
99
  requiresHumanInput,
99
100
  staleDealRule,
100
101
  } from "./rules.ts";
@@ -128,6 +129,7 @@ export {
128
129
  type Rubric,
129
130
  type ScoredDimension,
130
131
  } from "./llm.ts";
132
+ export { resolveRecord, type ResolveCandidate, type ResolveMatch, type ResolveResult } from "./resolve.ts";
131
133
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
132
134
  export type {
133
135
  ApprovalStatus,
package/src/mcp.ts CHANGED
@@ -48,6 +48,7 @@ import { builtinAuditRules } from "./rules.ts";
48
48
  import { sampleSnapshot } from "./sampleData.ts";
49
49
  import { normalizeTranscript, parseCall } from "./calls.ts";
50
50
  import { extractInsightsLlm, resolveLlmCredential } from "./llm.ts";
51
+ import { resolveRecord } from "./resolve.ts";
51
52
  import { suggestValues } from "./suggest.ts";
52
53
  import type { CanonicalGtmSnapshot, GtmConnector, PatchPlan } from "./types.ts";
53
54
 
@@ -239,6 +240,31 @@ export async function startMcpServer() {
239
240
  },
240
241
  );
241
242
 
243
+ server.registerTool(
244
+ "fullstackgtm_resolve",
245
+ {
246
+ title: "Resolve Record (create gate)",
247
+ description:
248
+ "Before creating a CRM record, check whether it already exists. Returns a verdict " +
249
+ "(exists | ambiguous | safe_to_create) with matches and a reason, using the same " +
250
+ "identity keys as the audit/merge engines (account domain, contact email, open-deal " +
251
+ "key). Read-only. Never create on 'exists' or 'ambiguous'.",
252
+ inputSchema: {
253
+ objectType: z.enum(["account", "contact", "deal"]),
254
+ name: z.string().optional(),
255
+ domain: z.string().optional(),
256
+ email: z.string().optional(),
257
+ accountId: z.string().optional(),
258
+ provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
259
+ inputPath: z.string().optional(),
260
+ },
261
+ },
262
+ async ({ objectType, name, domain, email, accountId, provider, inputPath }) => {
263
+ const snapshot = await readSnapshot(provider, inputPath);
264
+ return content(resolveRecord(snapshot, { objectType, name, domain, email, accountId }));
265
+ },
266
+ );
267
+
242
268
  server.registerTool(
243
269
  "fullstackgtm_rules",
244
270
  {
package/src/merge.ts CHANGED
@@ -52,7 +52,7 @@ export type MergeReport = {
52
52
  };
53
53
 
54
54
  const CONFLICT_IGNORED_FIELDS = new Set([
55
- "id", "provider", "crmId", "identities", "raw", "lastSyncAt", "lastActivityAt", "ownerId", "accountId",
55
+ "id", "provider", "crmId", "identities", "raw", "lastSyncAt", "lastActivityAt", "ownerId", "accountId", "provenance",
56
56
  ]);
57
57
 
58
58
  export function normalizeDomain(domain?: string): string | undefined {
package/src/resolve.ts ADDED
@@ -0,0 +1,157 @@
1
+ import { normalizeDomain } from "./merge.ts";
2
+ import type { CanonicalGtmSnapshot } from "./types.ts";
3
+
4
+ /**
5
+ * The resolve gate — the Prevent layer of the CRM-health lifecycle.
6
+ *
7
+ * Before any writer (sync job, webhook handler, agent, this CLI's own
8
+ * `create:` path) creates a record, it should ask: does this record already
9
+ * exist? The gate answers deterministically from a snapshot using the same
10
+ * identity keys the audit and merge engines use:
11
+ *
12
+ * account → normalized domain (exact), then normalized name
13
+ * contact → normalized email (exact), then full name
14
+ * deal → open-deal key: (accountId | "unlinked") + normalized name
15
+ *
16
+ * Verdicts are gate-shaped: `exists` (link to it, don't create),
17
+ * `ambiguous` (a human must pick — do NOT blind-create), `safe_to_create`.
18
+ */
19
+
20
+ export type ResolveCandidate = {
21
+ objectType: "account" | "contact" | "deal";
22
+ name?: string;
23
+ domain?: string;
24
+ email?: string;
25
+ /** For deals: scope the duplicate key to an account. */
26
+ accountId?: string;
27
+ };
28
+
29
+ export type ResolveMatch = {
30
+ id: string;
31
+ name: string;
32
+ matchedBy: "domain" | "email" | "name" | "deal_key";
33
+ detail: string;
34
+ };
35
+
36
+ export type ResolveResult = {
37
+ objectType: ResolveCandidate["objectType"];
38
+ verdict: "exists" | "ambiguous" | "safe_to_create";
39
+ matches: ResolveMatch[];
40
+ reason: string;
41
+ };
42
+
43
+ export function resolveRecord(
44
+ snapshot: CanonicalGtmSnapshot,
45
+ candidate: ResolveCandidate,
46
+ ): ResolveResult {
47
+ if (candidate.objectType === "account") return resolveAccount(snapshot, candidate);
48
+ if (candidate.objectType === "contact") return resolveContact(snapshot, candidate);
49
+ return resolveDeal(snapshot, candidate);
50
+ }
51
+
52
+ function resolveAccount(snapshot: CanonicalGtmSnapshot, c: ResolveCandidate): ResolveResult {
53
+ const base = { objectType: "account" as const };
54
+ const domain = normalizeDomain(c.domain);
55
+ if (domain) {
56
+ const matches = snapshot.accounts
57
+ .filter((a) => normalizeDomain(a.domain) === domain)
58
+ .map((a) => match(a.id, a.name, "domain", `account domain ${a.domain} normalizes to ${domain}`));
59
+ if (matches.length === 1) {
60
+ return { ...base, verdict: "exists", matches, reason: `An account with domain ${domain} already exists: "${matches[0].name}" (${matches[0].id}). Link to it instead of creating.` };
61
+ }
62
+ if (matches.length > 1) {
63
+ return { ...base, verdict: "ambiguous", matches, reason: `${matches.length} accounts already share domain ${domain} — that's a duplicate group; merge it before adding more. Do not create.` };
64
+ }
65
+ }
66
+ if (c.name) {
67
+ const key = normalizeName(c.name);
68
+ const matches = snapshot.accounts
69
+ .filter((a) => normalizeName(a.name) === key)
70
+ .map((a) => match(a.id, a.name, "name", `account name matches "${c.name}" (domain: ${a.domain ?? "none"})`));
71
+ if (matches.length > 0) {
72
+ // Name alone is suggestive, not identity — two real companies can share
73
+ // a name (the merge engine treats this the same way).
74
+ return {
75
+ ...base,
76
+ verdict: "ambiguous",
77
+ matches,
78
+ reason: `${matches.length} account(s) named "${c.name}" exist but ${domain ? `none share domain ${domain}` : "no domain was supplied to confirm identity"}. Confirm before creating — supply a domain to disambiguate.`,
79
+ };
80
+ }
81
+ }
82
+ if (!domain && !c.name) {
83
+ return { ...base, verdict: "ambiguous", matches: [], reason: "Supply --domain and/or --name to resolve an account." };
84
+ }
85
+ return { ...base, verdict: "safe_to_create", matches: [], reason: `No account matches ${domain ? `domain ${domain}` : `name "${c.name}"`}. Safe to create.` };
86
+ }
87
+
88
+ function resolveContact(snapshot: CanonicalGtmSnapshot, c: ResolveCandidate): ResolveResult {
89
+ const base = { objectType: "contact" as const };
90
+ const email = c.email?.trim().toLowerCase();
91
+ if (email) {
92
+ const matches = snapshot.contacts
93
+ .filter((row) => row.email?.trim().toLowerCase() === email)
94
+ .map((row) => match(row.id, contactName(row), "email", `contact email matches ${email}`));
95
+ if (matches.length === 1) {
96
+ return { ...base, verdict: "exists", matches, reason: `A contact with email ${email} already exists: "${matches[0].name}" (${matches[0].id}). Update it instead of creating.` };
97
+ }
98
+ if (matches.length > 1) {
99
+ return { ...base, verdict: "ambiguous", matches, reason: `${matches.length} contacts already share ${email} — a duplicate group; merge before adding more. Do not create.` };
100
+ }
101
+ return { ...base, verdict: "safe_to_create", matches: [], reason: `No contact matches ${email}. Safe to create.` };
102
+ }
103
+ if (c.name) {
104
+ const key = normalizeName(c.name);
105
+ const matches = snapshot.contacts
106
+ .filter((row) => normalizeName(contactName(row)) === key)
107
+ .map((row) => match(row.id, contactName(row), "name", `contact name matches "${c.name}" (email: ${row.email ?? "none"})`));
108
+ if (matches.length > 0) {
109
+ return { ...base, verdict: "ambiguous", matches, reason: `${matches.length} contact(s) named "${c.name}" exist; names are not identity. Supply --email to resolve definitively.` };
110
+ }
111
+ return { ...base, verdict: "safe_to_create", matches: [], reason: `No contact named "${c.name}". Safe to create — but prefer resolving by email.` };
112
+ }
113
+ return { ...base, verdict: "ambiguous", matches: [], reason: "Supply --email (preferred) or --name to resolve a contact." };
114
+ }
115
+
116
+ function resolveDeal(snapshot: CanonicalGtmSnapshot, c: ResolveCandidate): ResolveResult {
117
+ const base = { objectType: "deal" as const };
118
+ if (!c.name) {
119
+ return { ...base, verdict: "ambiguous", matches: [], reason: "Supply --name (and ideally --account-id) to resolve a deal." };
120
+ }
121
+ const key = `${c.accountId ?? "unlinked"}:${normalizeName(c.name)}`;
122
+ const open = snapshot.deals.filter((d) => d.isClosed !== true && d.isWon !== true);
123
+ const matches = open
124
+ .filter((d) => `${d.accountId ?? "unlinked"}:${normalizeName(d.name)}` === key)
125
+ .map((d) => match(d.id, d.name, "deal_key", `open deal with the same name on ${c.accountId ? `account ${c.accountId}` : "no account"}`));
126
+ if (matches.length > 0) {
127
+ return {
128
+ ...base,
129
+ verdict: "exists",
130
+ matches,
131
+ reason: `${matches.length} open deal(s) already match "${c.name}" on ${c.accountId ? `account ${c.accountId}` : "unlinked"} — creating another would double-count pipeline. Update the existing deal.`,
132
+ };
133
+ }
134
+ const closedSameName = snapshot.deals.filter(
135
+ (d) => (d.isClosed === true || d.isWon === true) && normalizeName(d.name) === normalizeName(c.name!),
136
+ );
137
+ return {
138
+ ...base,
139
+ verdict: "safe_to_create",
140
+ matches: [],
141
+ reason: closedSameName.length > 0
142
+ ? `No open deal matches; ${closedSameName.length} closed deal(s) share the name (a re-open/renewal may be intended). Safe to create.`
143
+ : `No open deal matches "${c.name}". Safe to create.`,
144
+ };
145
+ }
146
+
147
+ function contactName(row: { firstName?: string; lastName?: string }) {
148
+ return [row.firstName, row.lastName].filter(Boolean).join(" ") || "(unnamed)";
149
+ }
150
+
151
+ function normalizeName(value: string) {
152
+ return value.trim().toLowerCase().replace(/\s+/g, " ");
153
+ }
154
+
155
+ function match(id: string, name: string, matchedBy: ResolveMatch["matchedBy"], detail: string): ResolveMatch {
156
+ return { id, name, matchedBy, detail };
157
+ }
package/src/rules.ts CHANGED
@@ -21,6 +21,27 @@ export function requiresHumanInput(value: unknown): boolean {
21
21
  return typeof value === "string" && value.startsWith(REQUIRES_HUMAN_PREFIX);
22
22
  }
23
23
 
24
+ /**
25
+ * Attribution for duplicate groups: when the provider exposes record-source
26
+ * provenance (RecordProvenance), name the writer(s) that created the group —
27
+ * the fix for recurring dupes is upstream in the writer, not in the records.
28
+ */
29
+ export function provenanceSummary(records: Array<{ provenance?: { source?: string; sourceLabel?: string; sourceId?: string } }>): string {
30
+ const counts = new Map<string, number>();
31
+ for (const record of records) {
32
+ const p = record.provenance;
33
+ if (!p) continue;
34
+ const label = p.sourceLabel ?? p.source ?? "unknown source";
35
+ const key = p.sourceId ? `${label} (${p.sourceId})` : label;
36
+ counts.set(key, (counts.get(key) ?? 0) + 1);
37
+ }
38
+ if (counts.size === 0) return "";
39
+ const parts = [...counts.entries()]
40
+ .sort((a, b) => b[1] - a[1])
41
+ .map(([key, count]) => (count > 1 ? `${key} ×${count}` : key));
42
+ return ` Created by: ${parts.join(", ")}.`;
43
+ }
44
+
24
45
  export function auditFindingId(ruleId: string, objectId: string) {
25
46
  return `finding_${stableHash(`${ruleId}:${objectId}`)}`;
26
47
  }
@@ -343,7 +364,7 @@ export const duplicateAccountDomainRule: GtmAuditRule = {
343
364
  ruleId: "duplicate-account-domain",
344
365
  title: "Accounts share the same domain",
345
366
  severity: "warning",
346
- summary: `${accounts.length} accounts share ${domain}: ${accounts.map((account) => account.name).join(", ")}.`,
367
+ summary: `${accounts.length} accounts share ${domain}: ${accounts.map((account) => account.name).join(", ")}.${provenanceSummary(accounts)}`,
347
368
  recommendation: "Review the group and merge duplicates so activity and deals roll up once.",
348
369
  });
349
370
  operations.push({
@@ -383,7 +404,7 @@ export const duplicateContactEmailRule: GtmAuditRule = {
383
404
  ruleId: "duplicate-contact-email",
384
405
  title: "Contacts share the same email",
385
406
  severity: "warning",
386
- summary: `${contacts.length} contacts share ${email}.`,
407
+ summary: `${contacts.length} contacts share ${email}.${provenanceSummary(contacts)}`,
387
408
  recommendation: "Merge the duplicates so engagement history and routing stay coherent.",
388
409
  });
389
410
  operations.push({
@@ -433,7 +454,7 @@ export const duplicateOpenDealRule: GtmAuditRule = {
433
454
  severity: "warning",
434
455
  summary: `${deals.length} open deals named "${anchor.name}"${
435
456
  anchor.accountId ? " on the same account" : ""
436
- }: ${deals.map((deal) => deal.id).join(", ")}.`,
457
+ }: ${deals.map((deal) => deal.id).join(", ")}.${provenanceSummary(deals)}`,
437
458
  recommendation:
438
459
  "Keep one deal, archive the copies, and fix the integration that is re-creating them.",
439
460
  });
package/src/types.ts CHANGED
@@ -141,11 +141,26 @@ export type CanonicalUser = {
141
141
  active?: boolean;
142
142
  };
143
143
 
144
+ /**
145
+ * Who created a record, per the provider's read-only record-source fields
146
+ * (HubSpot: hs_object_source / _label / _id). Populated on read; used to
147
+ * attribute duplicate findings to the writer that produced them.
148
+ */
149
+ export type RecordProvenance = {
150
+ /** Provider source code, e.g. INTEGRATION, API, CRM_UI, IMPORT, FORM. */
151
+ source?: string;
152
+ /** Human label, e.g. an integration's name. */
153
+ sourceLabel?: string;
154
+ /** Provider-side id of the source (e.g. app id, import id). */
155
+ sourceId?: string;
156
+ };
157
+
144
158
  export type CanonicalAccount = {
145
159
  id: string;
146
160
  provider?: CrmProvider;
147
161
  crmId?: string;
148
162
  identities?: ProviderIdentity[];
163
+ provenance?: RecordProvenance;
149
164
  name: string;
150
165
  domain?: string;
151
166
  industry?: string;
@@ -163,6 +178,7 @@ export type CanonicalContact = {
163
178
  provider?: CrmProvider;
164
179
  crmId?: string;
165
180
  identities?: ProviderIdentity[];
181
+ provenance?: RecordProvenance;
166
182
  accountId?: string;
167
183
  firstName?: string;
168
184
  lastName?: string;
@@ -181,6 +197,7 @@ export type CanonicalDeal = {
181
197
  provider?: CrmProvider;
182
198
  crmId?: string;
183
199
  identities?: ProviderIdentity[];
200
+ provenance?: RecordProvenance;
184
201
  accountId?: string;
185
202
  ownerId?: string;
186
203
  name: string;