# Worked example: Acme Analytics (fictional)

Acme Analytics is a fictional B2B SaaS company on HubSpot. It sells to business units of two groups (Example Co on example.org, Example Retail Group on example.net) and to smaller accounts met at events. Billing Sync, an integration, creates companies and contacts through the HubSpot API. Exports were taken 2026-11-01 08:00 UTC; the CRM showed 60 contacts, 20 companies and 24 deals.

## Files

- [../assets/example-acme-stack.md](../assets/example-acme-stack.md): the `stack.md` read first (integrations, keys, account boundary, approver roles).
- [../assets/example-acme-rules.json](../assets/example-acme-rules.json): required fields, allowed lead status values, the sales pipeline, internal domain example.com.
- [../assets/example-acme-contacts.csv](../assets/example-acme-contacts.csv), [../assets/example-acme-companies.csv](../assets/example-acme-companies.csv), [../assets/example-acme-deals.csv](../assets/example-acme-deals.csv), [../assets/example-acme-users.csv](../assets/example-acme-users.csv): HubSpot-shaped exports with English headers; the users file is keyed on owner IDs.
- [../assets/example-acme-merge-plan.csv](../assets/example-acme-merge-plan.csv): the reviewed merge plan.
- [../assets/example-acme-contacts-2026-12.csv](../assets/example-acme-contacts-2026-12.csv) and [../assets/example-acme-companies-2026-12.csv](../assets/example-acme-companies-2026-12.csv): the December exports for the monthly rerun.

## Command

Run the fixture commands from a working folder outside the skill, with `SKILL_DIR` set to this skill's folder, as in step 3 of SKILL.md. Outputs land in the working folder, never in the skill.

```bash
python3 "$SKILL_DIR/scripts/profile_crm_export.py" --crm hubspot \
  --contacts "$SKILL_DIR/assets/example-acme-contacts.csv" --companies "$SKILL_DIR/assets/example-acme-companies.csv" \
  --deals "$SKILL_DIR/assets/example-acme-deals.csv" --users "$SKILL_DIR/assets/example-acme-users.csv" \
  --rules "$SKILL_DIR/assets/example-acme-rules.json" \
  --unique-key "contacts=Product User ID" --unique-key "companies=Billing Account ID" \
  --expected-count contacts=60 --expected-count companies=20 --expected-count deals=24 \
  --allow-reserved-domains --as-of 2026-11-01 --out acme-hygiene-out
```

`--allow-reserved-domains` is needed only because the fixture uses example.org and example.net addresses; never pass it on real exports.

## Expected output (excerpt)

The run ends with `Totals: 13 FAIL, 33 WARN, 4 INFO, 51 PASS, 4 NOT CHECKED, 3 N/A. Exit 1.` It also writes calibration-sample.csv (10 pairs, one per link: 2 certain, 4 probable, 4 possible) and a one-line trend.md, since no previous profile was given. A few lines, shortened:

```text
contacts: example-acme-contacts.csv, 60 rows, utf-8, comma. Coverage FULL (60 of 60).
WARN X06 blank-column companies: ... NOT RETRIEVED: Industry (industry). Dependent checks are NOT CHECKED.
FAIL E06 bounced-still-marketable contacts: 1 of 43 (2.3%) marketing contacts. ... Records: 1011
FAIL D01 same-unique-key companies: 2 of 20 (10.0%) companies excluding test records. ... Records: 9001, 9002
WARN D05 company companies: 7 of 20 (35.0%) ... Rule fired in 1 certain group, 1 probable group, 3 possible pairs (3 with a key conflict).
FAIL O04 integration-owner deals: ... Open deals owned by an integration: 1 of 16 (6.2%). Records: 7011
FAIL L01 customer-without-evidence contacts: 2 of 9 (22.2%) customer-stage contacts. ... Records: 1002, 1014
FAIL L05 open-past-close deals: 2 of 16 (12.5%) open deals. ... Records: 7003, 7004
```

The full expected status per family:

- Integrity and coverage: X01, X02, X04, X05, X07 PASS; X03 N/A; X06 companies.industry NOT RETRIEVED; X08 PASS for all three objects; X10 WARN 1 (contact 1032's job title starts with =).
- Fields: F01 contacts.country WARN 4 of 43 marketing contacts; F01 companies.domain WARN 1 of 20; F01 companies.industry NOT CHECKED; F01 deals close date and amount PASS; F02 WARN 3 (phone 0000000000); F03 WARN 1 (lead status "Hot"); F04 WARN (US, USA, United States); F05 WARN 1 (postal code 2134); F06 INFO 1 (all-caps name); F07 WARN 1 (contact 1026, internal test record).
- Email and decay: E01 FAIL 1; E02 WARN 1; E03 PASS; E04 INFO 1; E05 PASS; E06 FAIL 1; E07 WARN 1; E08 INFO (address-dead 1, sender-side 1); R01 PASS (1 of 43 dormant); R02 WARN 1; R03 INFO.
- Duplicates: contacts 1 certain group (1001, 1002), 2 probable groups (1003 and 1004; 1005 and 1006), 1 possible pair (1038 and 1039, key conflict), excess 3 of 59 (5.1%). Companies 1 certain group (9001, 9002), 1 probable group (9006, 9007), possible pairs 9001 and 9003, 9002 and 9003, 9004 and 9005 (shared domain, key conflict), excess 2 of 20 (10.0%). Deals 1 probable group (7003, 7004), excess 1 of 24 (4.2%). D01 FAIL; D03, D04, D05, D06 WARN; D02, D08, D09 PASS; D01 deals NOT CHECKED (no deal key declared).
- Ownership: O01 FAIL for deal 7006, WARN for contact 1015 and company 9010; O02 FAIL for deal 7005, WARN for company 9009 and contact 1016; O03 WARN 1 (owner "Pat Quinn" not in the users file); O04 FAIL for deal 7011 (1 of 16 open deals, 6.25%), WARN for contacts 1002 and 1043 and company 9002.
- Lifecycle and stages: L01 FAIL for companies (9008, 1 of 3) and contacts (1002 and 1014, 2 of 9); L02 WARN 1 (9011); L03 FAIL 1 (7002); L04 FAIL 1 (7008); L05 FAIL (2 of 16, 12.5%); L06 WARN 2; L07 FAIL 1 (7009, stage "Negotiation"); L08 WARN 1; L09 WARN 1; L11 WARN 1.
- Associations: A01, A02, A03, A04 and A07 WARN 1 each; A05 and A06 PASS.
- The marketing population is 43 of 60 contacts: 17 are non-marketing (event scans without email, the test record, a role mailbox, an unsubscribed and a policy-bounced contact).

## What the agent does with each finding

- Billing Sync created 9002 with the same Billing Account ID as 9001 (D01). First a change list item to stop the writer: search before create on Billing Account ID, owner the integration owner. Then merge 9002 into 9001 (G4). Acme's account is in the primary ID preservation beta, so 9001 keeps its ID; the billing system still replaces any stored 9002 with the ID read back. Without the beta, the merge returns a new ID and both old IDs change.
- 9003 shares example.org with 9001 and 9002, and 9005 shares example.net with 9004, each with a different billing account. Under the account boundary in `decisions.md` (one company per billing account) they stay separate: G8 investigate, then keep separate once the decision is cited.
- 1001 and 1002 are a certain duplicate. Survivor 1001: it holds deal 7001 and an active human owner. The merge also resolves 1002's false Customer flag (L01), because duplicates split the evidence.
- 1003 and 1004 are a plus-tag variant (D03); 1004 is unsubscribed. The privacy owner decides the unsubscribe applies to the person; the plan records consent_reviewed = yes and change list item CL-04 unsubscribes the survivor's primary address and sets it non-marketing after the merge.
- 1005 and the event scan 1006 share a name and phone (D04). Survivor 1005, which has the company association.
- 1038 and 1039 have similar names (0.967) at one company but different Product User IDs: keep separate (G7). This is the false positive the key-conflict cap exists for.
- Open deal 7005 is owned by Jordan Blake, deactivated in September, and 7006 has no owner: reassignment rows for the routing owner (sales manager), not rotation.
- Deal 7011 is owned by Billing Sync: the integration sets owners on what it touches, hiding routing failures. Prevention item for the integration owner.
- 7002 is won with no amount and 7008 is closed in the future: finance confirms the values; the report owner is told the bookings figure will move before the change.
- 1011 bounced (Unknown user, address-dead) and is still a marketing contact: set non-marketing and suppress (takes effect at the next update date). 1012's Policy bounce is sender-side: handed to deliverability, not counted as decay.
- Industry is blank on every company: not retrieved, not missing. Re-export with the property in the view before any industry rule runs.
- 9008 is a Customer with only a lost deal (L01): ask the account owner; if no contract exists, clear_then_set the lifecycle (HubSpot only moves lifecycle forward through imports and workflows).

## Merge plan check

```bash
python3 "$SKILL_DIR/scripts/check_merge_plan.py" "$SKILL_DIR/assets/example-acme-merge-plan.csv" --crm hubspot --profile acme-hygiene-out/profile.json
```

This is the plan the CRM owner signed on 2026-11-03. It prints a six-line sequence (G1 to G6, one HubSpot merge each), the HubSpot mechanics note once, and `Totals: 0 BLOCK, 0 WARN, 0 NOTE. Exit 0.` G7 (keep separate) and G8 (investigate) produce no merge steps.

The agent's draft had approver, approved_at and consent_reviewed blank. With `--draft` it printed 13 PENDING lines (12 missing approvals and G2's consent review) and `Totals: 0 BLOCK, 0 WARN, 0 NOTE, 13 PENDING. Exit 0.`: the point where the agent stops and hands the plan over. The same draft without `--draft` prints 13 BLOCK.

## Change list excerpt

From [../assets/change-list-template.csv](../assets/change-list-template.csv), with Acme's values:

- CL-01 companies: Billing Sync search-before-create on Billing Account ID; operation review_only (a source fix, applied by the integration owner); check D01; reversible yes.
- CL-02 deals 7005 hubspot_owner_id: 103 to 102; reassign_owner; O02. The names sit in evidence: Jordan Blake inactive since September, the routing owner chose Sam Ortiz.
- CL-05 contacts 1002 lifecyclestage: customer to lead; clear_then_set; L01 (superseded if merge G1 runs first).
- CL-07 contacts 1011 hs_marketable_status: true to false; set_non_marketing; E06; reversible no for the sends already prevented. downstream_effects says "apply with Clean up contacts, not import": HubSpot imports cannot set contacts as non-marketing. Effective at the next update date.
- Confirm internal property names with `get_properties` before building an import; labels in an export are not internal names. Operations, batch size and import identifiers: [cleanup-plan.md](cleanup-plan.md).

## Prevention items

- Billing Sync creates companies through the API, which HubSpot does not deduplicate by domain: unique Billing Account ID property, search before create, unmatched records to a review queue. Monitor: new D01 and D05 groups whose newest member has Record source Integration.
- Event badge imports split people from their form-created records: import with email or a product user ID, or route scans to a review list. Monitor: new D04 pairs with Record source Import whose Record source detail 1 names an event import.
- Offboarding left records with a deactivated owner: reassign before deactivating. Monitor: O02 monthly.

## December rerun (the monthly loop)

In November the team merged the pilot group G1 and applied CL-07, but CL-01, the Billing Sync source fix, had not shipped: step 1 of the merge batch protocol was skipped. A November list import also rewrote one contact's country. Acme's account is in HubSpot's primary ID preservation beta, so the merged contact keeps ID 1001. Deals did not change, so the December deals export is the same file (its sha256 matches). Run:

```bash
python3 "$SKILL_DIR/scripts/profile_crm_export.py" --crm hubspot \
  --contacts "$SKILL_DIR/assets/example-acme-contacts-2026-12.csv" --companies "$SKILL_DIR/assets/example-acme-companies-2026-12.csv" \
  --deals "$SKILL_DIR/assets/example-acme-deals.csv" --users "$SKILL_DIR/assets/example-acme-users.csv" \
  --rules "$SKILL_DIR/assets/example-acme-rules.json" \
  --unique-key "contacts=Product User ID" --unique-key "companies=Billing Account ID" \
  --expected-count contacts=60 --expected-count companies=20 --expected-count deals=24 \
  --allow-reserved-domains --as-of 2026-12-01 --previous acme-hygiene-out/profile.json --out acme-hygiene-2026-12
```

It ends with `Totals: 12 FAIL, 33 WARN, 4 INFO, 52 PASS, 4 NOT CHECKED, 3 N/A. Exit 1.` trend.md, shortened:

```text
- F04 contacts: WARN 2 of 43 to WARN 3 of 43.
  - New on records that existed last time (regressions or newly broken): 1 (1029).
- E06 contacts: FAIL 1 of 43 to PASS 0 of 42.
  - Resolved since last profile: 1 (1011).
- D01 contacts: FAIL 2 of 59 to FAIL 2 of 59.
  - Resolved since last profile: 1 (1002).
  - On records created since the previous profile: 1 (1061).
- L01 contacts: FAIL 2 of 9 to FAIL 2 of 9.
  - Resolved since last profile: 1 (1002).
  - On records created since the previous profile: 1 (1061).
- L05 deals: FAIL 2 of 16 to FAIL 5 of 16.
  - New on records that existed last time (regressions or newly broken): 3 (7009, 7011, 7020).
## Records missing since the previous profile (merged or deleted)
- contacts: 1 (1002)
## Duplicate groups with a member created since the previous as-of, by creation source
- contacts, Integration: 1 group (CT-01).
```

What the agent does with it:

- The merge worked (1002 is gone) and the suppression held (E06 passes), but Billing Sync created 1061 on 20 November for the same Product User ID, again as a Customer owned by itself: D01, D03, D04, O04 and L01 recur on a new record. Escalate CL-01, the search-before-create fix, ahead of any further merging; merging 1061 now only restarts the cycle.
- 1029's country was rewritten to USA by the November import: a formatting regression from that import path, so fix the import mapping, not only the value.
- The L05 rise is time passing: 7009, 7011 and 7020 crossed their close dates in November. Send them to their owners; it is not a data defect yet.
- The same rules hash on both runs means every delta is a data change, not a definition change.

## Regression fixtures

Run after any script change and compare. Each case is one or a few rows. The five runs together exercise every check family, the union guard, the band rules, converted leads and the merge plan blocks.

Salesforce-shaped run:

```bash
python3 "$SKILL_DIR/scripts/profile_crm_export.py" --crm salesforce --leads "$SKILL_DIR/assets/regression-salesforce-leads.csv" \
  --contacts "$SKILL_DIR/assets/regression-salesforce-contacts.csv" --companies "$SKILL_DIR/assets/regression-salesforce-accounts.csv" \
  --users "$SKILL_DIR/assets/regression-salesforce-users.csv" --rules "$SKILL_DIR/assets/regression-rules.json" \
  --allow-reserved-domains --as-of 2026-11-01 --out regression-sf-out
```

Expected `Totals: 4 FAIL, 5 WARN, 8 INFO, 72 PASS, 25 NOT CHECKED, 2 N/A. Exit 1.` (A07 is NOT CHECKED for leads and accounts, whose exports hold no association IDs; O01 is NOT CHECKED for contacts and companies, whose owner-required populations are empty). summary.md says 2 converted leads were set aside. Per case:

- S1 contact ID 00300000003T2PG (15 characters): counted in X03.
- S2 accounts 001XX00000Abcde and 001XX00000aBcde, 15-character IDs equal when lowercased: X03 FAIL for companies, "2 15-character, 2 case collisions".
- S3 contact ID 00300000003t2pgaa0 (18 characters, case altered): X03 FAIL for contacts, "1 15-character, 0 case collisions, 1 inconsistent suffixes" (S1 and S3).
- S4 unconverted lead with a contact's email: D07 WARN, listed in possible-pairs.csv as leads-contacts.
- S5 converted lead with Email Opt Out true whose contact has it false: L10 FAIL.
- S6 contact bounced "550 5.1.1 user unknown", not opted out: E06 FAIL, E08 address-dead.
- S7 contact bounced "550 5.7.1 rejected by policy", opted out: E08 sender-side, no E06.
- S8 two leads with different names sharing a sales@ address: D02 INFO shared role mailbox, no group.
- S9 lead owned by a queue (no O03; O05 WARN, queue-owned for a month) and a lead owned by an ID in neither users nor queues: O03 WARN.
- S10 contact owned by an inactive user: O02 WARN.
- S11 one phone on 5 contacts with different names: D08 INFO, no pairs.
- S12 phone 0000000000 on 60 leads: F02 WARN and D08 INFO, no pairs.
- S13 converted lead 00Q5e00000S13LdEAJ: same email as the S4 open lead and the contact it converted into, an address-dead bounce, created earlier, owned by the inactive user. Expected: no lead D02 group, D07 WARN for S4 only, E06 and E08 PASS for leads, O02 PASS for leads, L10 FAIL 1 of 2 converted leads (S5).
- S14 leads 00Q5e00000S14aBEAR and 00Q5e00000S14AbEAJ: valid 18-character IDs whose first 15 characters differ only in case. Expected: X03 PASS for leads (0 case collisions); they are different records.

Generic run:

```bash
python3 "$SKILL_DIR/scripts/profile_crm_export.py" --crm generic --contacts "$SKILL_DIR/assets/regression-contacts.csv" \
  --companies "$SKILL_DIR/assets/regression-companies.csv" --deals "$SKILL_DIR/assets/regression-deals.csv" \
  --rules "$SKILL_DIR/assets/regression-rules.json" --map "contacts.record_id=Contact Ref" \
  --unique-key "contacts=Customer Key" --allow-reserved-domains --as-of 2026-11-01 --out regression-generic-out
```

Expected `Totals: 3 FAIL, 8 WARN, 1 INFO, 58 PASS, 32 NOT CHECKED, 4 N/A. Exit 1.` (O01 and L01 are NOT CHECKED for contacts: no record is in their populations) and per case:

- G1 three contacts with blank email: no group.
- G2 record IDs 1.23457E+11 and 1.23458E+11: X02 FAIL.
- G3 a mojibake first name: X04 WARN.
- G4 title "=1+1": X10 WARN.
- G5 "Employee range" blank on every row: X06 NOT RETRIEVED and F01 NOT CHECKED, never F01 FAIL.
- G6 two open renewal deals for one company, same amount, close dates 365 days apart: no D06.
- G7 closed-won amount 0 with deal type "Partner referral" (exempt): L03 PASS.
- G8 12 contacts sharing the "unique" key TEMP: D01 FAIL plus D09 WARN; the agent demotes the key in the rules file.
- G9 lee.wong and leewong at a dot-insensitive domain: D03 and D04 probable.
- G10 a company on a domain declared personal: A05 WARN; its contact E05 INFO.
- G11 the chain: C-1023 holds key CK-1, C-1024 has no key and the same email, C-1025 holds CK-2 with the same name at the same domain and company. Expected: one certain group of two (C-1023 and C-1024, D02 FAIL) and two possible pairs with a key conflict: C-1023 with C-1025 (direct conflict) and C-1024 with C-1025 (refused by the union guard). Never one group of three.
- G12 Summit Consulting LLC on summitconsulting.example and Summit Consulting on summit-advisors.example, both in the United States: one possible pair, "D05 same name, different domains", D05 WARN, no group.

Broken merge plan:

```bash
python3 "$SKILL_DIR/scripts/check_merge_plan.py" "$SKILL_DIR/assets/regression-merge-plan-broken.csv" --crm salesforce
```

Expected `Totals: 6 BLOCK, 0 WARN, 1 NOTE. Exit 1.`: a record in two groups, a group with two survivors, a group with different unique keys, an opted-out record without consent review, a merge row without approver, a merge group in the possible band, R8 (one BLOCK each), and a four-record Salesforce group that needs two operations (NOTE).

Converted lead in a merge plan (after the Salesforce-shaped run, which wrote its profile.json):

```bash
python3 "$SKILL_DIR/scripts/check_merge_plan.py" "$SKILL_DIR/assets/regression-merge-plan-leads.csv" --crm salesforce \
  --profile regression-sf-out/profile.json
```

Expected `Totals: 1 BLOCK, 1 WARN, 0 NOTE. Exit 1.`: the BLOCK names the converted lead 00Q5e00000S13LdEAJ and says to convert the open lead into the existing contact (D07); the WARN says the profile did not group the two leads.

Export without associations (the Acme files with every "Associated ..." column removed, as a HubSpot view export without "All properties and associations" would arrive):

```bash
python3 "$SKILL_DIR/scripts/profile_crm_export.py" --crm hubspot \
  --contacts "$SKILL_DIR/assets/regression-noassoc-contacts.csv" --companies "$SKILL_DIR/assets/regression-noassoc-companies.csv" \
  --deals "$SKILL_DIR/assets/regression-noassoc-deals.csv" --users "$SKILL_DIR/assets/example-acme-users.csv" \
  --rules "$SKILL_DIR/assets/example-acme-rules.json" \
  --unique-key "contacts=Product User ID" --unique-key "companies=Billing Account ID" \
  --expected-count contacts=60 --expected-count companies=20 --expected-count deals=24 \
  --allow-reserved-domains --as-of 2026-11-01 --out regression-noassoc-out
```

Expected `Totals: 11 FAIL, 25 WARN, 4 INFO, 48 PASS, 17 NOT CHECKED, 3 N/A. Exit 1.` A01, A04 and A06 for contacts, A02, A03 and A06 for deals, A07 for every object, D06, L01 for contacts and companies, and L02 are NOT CHECKED, each naming the missing association columns. No L01 FAIL appears: on the full export, L01 FAILs for 1002, 1014 and 9008, and a 100% FAIL here would draft clear_then_set rows for real customers. O01 for companies notes that companies with open deals could not be determined.

E03 (typo domains) never appears in fixtures, because a misspelled provider domain is a real domain someone may own, and fixtures use reserved example domains only; the script's `--self-test` covers it with addresses built at run time.
