references/1-survivorship-policy.md を記入する。 パート A にマッチルール、パート B に主レコードの選定、パート C にフィールド単位の勝者テーブル、パート D に不可触リストを書きます。アトリビューションと同意のフィールドはパート D に入れてください。清掃中にファーストタッチのアトリビューションを書き換えることは、マーケティングの履歴を目に見えない形で改変することです。
---
name: hubspot-crm-hygiene-governed
description: Runs bulk CRM hygiene in HubSpot through the HubSpot Agent CLI under a written policy — dedupe with field-level survivorship, property backfill with provenance stamping, and stale-deal disposition — producing a reviewable change ledger and a pre-image snapshot before any irreversible write. Use when a hygiene run has to be repeatable, reviewable, or auditable rather than a one-off cleanup.
---
# Governed CRM hygiene via the HubSpot Agent CLI
This Skill does not replace HubSpot's official Agent CLI skills. It governs them. Install the vendor bundle first:
```
npx skills add hubspot/agent-cli-skills
```
That bundle supplies the mechanics — JSONL pipes, batch reads, pagination, `--dry-run`/confirm patterns, and `hubspot history` recovery. What it does not supply is policy: which duplicate wins field by field, where a backfilled value came from, when a deal is dead versus merely quiet, and what record of the run survives afterward. This Skill is that policy layer, plus the safety rails that `--dry-run` alone cannot provide.
## When to invoke
Invoke when a HubSpot hygiene job has to satisfy at least one of these:
- It runs on a schedule, headless, with no human watching each mutation.
- Someone other than the operator will review what changed and why.
- The changed properties feed reporting, routing, scoring, or comp — so a silent bad write has downstream cost.
- The job touches more than ~200 records, where manual pair-by-pair review stops being realistic.
## When NOT to invoke
- **One-off cleanup by the person who owns the portal.** The vendor's `crm-data-quality` skill handles it with less setup. Policy files are overhead when the operator is also the reviewer and the run happens once.
- **No sandbox and no snapshot budget.** Merges cannot be undone. If you cannot write a pre-image snapshot to disk, stop.
- **The survivorship rule has not been decided.** This Skill applies a rule you supply. It will not invent one. An unfilled `references/1-survivorship-policy.md` is a hard stop, not a default.
- **Fewer than ~50 records.** Below that, the in-app duplicate manager and manual review beat the setup cost.
- **Active enrollment triggers on the target properties have not been audited.** Property writes fire HubSpot workflows. See Watch-out 4.
## Inputs
| Input | Required | Type | Notes |
|---|---|---|---|
| `object_type` | yes | string | `contacts`, `companies`, `deals`, `tickets`, or a custom object type. One per run. |
| `job` | yes | enum | `dedupe`, `backfill`, or `stale-deals`. One per run — never combined. |
| `scope_filter` | yes | string | A CLI filter expression narrowing the working set. An unfiltered portal-wide run is refused. |
| `run_dir` | yes | path | Output directory. Defaults to `./crm-hygiene-<object_type>-<job>-<YYYY-MM-DD>/`. |
| `policy_files` | yes | paths | The filled reference files below. |
| `apply` | no | bool | Defaults to `false`. When false the run stops after the ledger. |
| `max_mutations` | no | int | Per-class ceiling. Defaults to 250. Exceeding it aborts rather than truncates. |
## Reference files
Fill these before the first run. Placeholder content is scaffolding, not defaults.
- `references/1-survivorship-policy.md` — match rules for duplicate detection and the field-level winner table.
- `references/2-backfill-provenance.md` — source precedence, provenance property names, rollback procedure.
- `references/3-stale-deal-disposition.md` — staleness thresholds per stage and the disposition decision table.
## Method
The Skill runs six phases in fixed order. It refuses to skip forward.
**Phase 1 — pin and verify.** Record `hubspot --version` into `<run_dir>/ledger/run-meta.json`. Confirm the authenticated identity with `hubspot whoami`. The CLI is in public beta and HubSpot states that commands, flags, and behavior can change without notice, so the version that produced a ledger is part of the ledger. If the recorded version differs from the previous run's, the Skill prints the delta and asks for confirmation before proceeding.
Discovery runs under `hubspot auth login` OAuth, never under a service key. Reason: OAuth is scoped to the user's own permissions, so a scoping mistake in a read phase fails closed instead of quietly reading everything.
**Phase 2 — scope and snapshot.** Resolve `scope_filter` into a working set and write a pre-image to `<run_dir>/pre-image/<object_type>.jsonl`, one full record per line, with every property the policy touches plus `id`, `createdate`, `hs_lastmodifieddate`.
Why a snapshot when the CLI has `--dry-run` and HubSpot has `hubspot history`: `--dry-run` previews a write you have not made yet, and `history` recovers property values on a record that still exists. Neither helps after a merge. A merge is irreversible — HubSpot documents no unmerge path — and the losing record stops existing. The snapshot is the only artifact that can reconstruct what was there. The Skill refuses to enter Phase 5 if the snapshot file is missing or its line count does not match the working-set count.
**Phase 3 — deterministic candidate generation.** Matching runs as code, not as a model judgment: normalize (lowercase, trim, strip `+tag` from email local parts, strip `www.` and protocol from domains), then apply the match rules from the policy file in priority order. Each candidate group is written to `<run_dir>/candidates.jsonl` with the rule that matched it.
Why deterministic: a model asked to re-rank the same duplicate set twice will not return the same grouping twice, which makes the diff between two runs unreviewable and makes a reviewer's approval meaningless. Model judgment is used in exactly one place — Phase 4's ambiguous band — and its output is advisory, flagged, and never auto-applied.
**Phase 4 — resolve under policy.** For `dedupe`, the Skill computes the surviving value **per field**, not per record. This is the phase that exists because of a specific HubSpot behavior: `hubspot objects merge` keeps the primary record's values where both records have a value. A record-level choice therefore discards good data sitting on the secondary — the newer phone number, the corrected job title, the non-empty lifecycle stage.
So the Skill inverts the order. It writes the winning field values onto the primary with `objects update` **first**, then merges. After the merge the primary already holds every winning value, and the merge only has to fold associations and activity history. Groups where the policy cannot pick a winner go to `<run_dir>/ambiguous.jsonl` for human review and are excluded from apply.
For `backfill`, values resolve by the source precedence in the provenance file, and every write carries the provenance stamp. For `stale-deals`, the disposition table maps stage plus days-since-`hs_lastmodifieddate` to one of `close-lost`, `reassign`, `reopen`, or `leave`.
**Phase 5 — dry-run ledger.** Every planned mutation is issued with `--dry-run` and `--format json`, and the responses are folded into `<run_dir>/ledger/changes.jsonl` — one line per record, carrying `id`, operation, per-field before/after, the policy rule that authorized it, and the matched candidate group. A human-readable digest lands at `<run_dir>/ledger/digest.md`: counts by operation, counts by rule, the 20 largest field-value deltas, and the full ambiguous list.
If any mutation class exceeds `max_mutations`, the run aborts here and writes nothing. It does not truncate to the cap — a truncated hygiene run leaves the portal in a half-applied state that is worse than either endpoint.
**Phase 6 — gated apply.** Only with `apply: true`. Mutations replay from the ledger, so what executes is the reviewed artifact rather than a freshly recomputed plan. Requests are paced under the account's burst ceiling with exponential backoff on `429`. Failures are quarantined to `<run_dir>/failed.jsonl` with the API error attached and are never blind-retried — a merge that failed on the 250-merge cap will fail identically on retry, and a retry loop against a partially applied merge is how a cleanup becomes an incident.
Post-apply, the Skill re-reads every touched record and writes `<run_dir>/ledger/verified.jsonl`, marking any record whose post-state does not match the ledger's intent.
## Output format
`digest.md` looks like this:
```markdown
# CRM hygiene digest — contacts / dedupe — 2026-08-03
CLI version: 1.4.2 · Auth: oauth (user: ops@example.com) · Mode: DRY RUN
Working set: 12,480 · Candidate groups: 604 · Records affected: 1,247
## Operations
| Operation | Count | Authorizing rule |
|---|---|---|
| objects update (survivorship pre-write) | 1,247 | S-02 newest-non-empty |
| objects merge | 604 | M-01 exact-email |
| deferred to ambiguous.jsonl | 38 | — |
## Largest field deltas
| id | field | before | after | rule |
|---|---|---|---|---|
| 701 | jobtitle | (empty) | VP Revenue Operations | S-02 |
| 884 | phone | +1-555-0134 | +1-555-0199 | S-04 newest-verified |
## Blocked
| id | reason |
|---|---|
| 2291 | merge cap: 250 lifetime merges reached |
```
`changes.jsonl`, one line:
```json
{"run_id":"2026-08-03T09:14:02Z","object_type":"contacts","op":"objects update","id":"701","before":{"jobtitle":null},"after":{"jobtitle":"VP Revenue Operations"},"rule":"S-02","group":"g-0117","provenance":"hygiene_run_2026-08-03"}
```
## Watch-outs
1. **A merge cannot be undone, and `--dry-run` does not change that.** The preview shows the intended result; it does not create a restore point. **Guard:** Phase 2's pre-image snapshot is mandatory and Phase 5 hard-fails without it. Keep `run_dir` for at least one renewal cycle — it is the only reconstruction path for a record that no longer exists.
2. **Merges fail at the 250-merge lifetime cap.** HubSpot blocks a merge when the two records have been involved in 250 or more merges combined, and merges also fail when the result would exceed configured association limits. On a portal with years of accumulated cleanup, these failures cluster in the middle of a run. **Guard:** failures quarantine to `failed.jsonl` and stop that group. No blind retry, and the run continues with the remaining groups rather than aborting.
3. **A backfilled value is indistinguishable from a human-entered one unless you stamp it.** Six months later nobody can tell which records were touched, so nobody can roll the backfill back or exclude it from an analysis. **Guard:** every backfill write also sets the two provenance properties defined in `references/2-backfill-provenance.md` (`hygiene_source`, `hygiene_run_id`). Create them before the first run; the rollback procedure in that file keys on `hygiene_run_id`.
4. **Property writes fire workflow enrollment triggers.** A lifecycle-stage backfill across 4,000 contacts can enroll all of them in a nurture sequence and send 4,000 emails to customers. This is the failure mode with the largest blast radius on this page, and it originates outside the CLI entirely. **Guard:** before any `backfill` run, enumerate active workflows whose enrollment triggers reference the target properties, and either pause them or exclude the working set for the duration. The Skill prints the target property list at Phase 4 and requires explicit confirmation that this audit happened.
5. **Admin mode widens the blast radius past your own permissions.** A `HUBSPOT_ACCESS_TOKEN` service key is account-level, and HubSpot requires it for schema operations and most deletes. A key exported into a long-lived shell stays live for every later command in that session. **Guard:** discovery and dry-run phases run under OAuth. When admin mode is genuinely required, export the key inside a subshell scoped to that single command so it does not leak into the rest of the run.
6. **Beta drift breaks a pinned run silently.** HubSpot states flags and behavior can change without notice, and the CLI auto-upgrades by default. A flag that vanishes between two scheduled runs turns a governed run into an ungoverned one. **Guard:** set `HUBSPOT_NO_AUTO_UPGRADE=1` for scheduled runs, pin the version in `run-meta.json`, and treat a version delta as a review trigger rather than a routine upgrade.
7. **The Search API caps at 10,000 results per query.** A working set larger than that silently stops at the cap, or returns a `400` when paging past it, and the hygiene run then reports clean on records it never read. **Guard:** the Skill shards `scope_filter` by `createdate` ranges when the working-set estimate exceeds 9,000 and asserts that the union of shard counts matches the unsharded count estimate.
# Survivorship policy
Last edited: 2026-08-03 · Owner: `REPLACE_WITH_OWNER` · Portal: `REPLACE_WITH_HUB_ID`
This file decides two things: which records are duplicates, and which value wins per field. The Skill hard-stops if the winner table still contains `REPLACE_` placeholders. There is no default rule — an unfilled file means the argument has not been had yet.
## Part A — match rules
Rules run in priority order. The first rule that matches a pair assigns the group. Lower `id` prefix wins ties only for determinism, never for data quality.
| Rule | Object | Match condition | Confidence | Action |
|---|---|---|---|---|
| M-01 | contacts | Normalized `email` exact match | high | auto-group |
| M-02 | contacts | Normalized `phone` exact match AND same `associatedcompanyid` | high | auto-group |
| M-03 | contacts | `firstname` + `lastname` exact AND same `associatedcompanyid` | medium | ambiguous |
| M-04 | companies | Normalized `domain` exact match | high | auto-group |
| M-05 | companies | `name` exact match AND same `country` | medium | ambiguous |
| M-06 | deals | `dealname` exact AND same `associatedcompanyid` AND same pipeline | medium | ambiguous |
Normalization applied before every comparison: lowercase, trim whitespace, strip `+tag` from the email local part, strip protocol and leading `www.` from domains, reduce phone numbers to digits with country code.
`auto-group` proceeds to the winner table. `ambiguous` is written to `ambiguous.jsonl` and never applied without a human decision.
**Tune the confidence column, not the rule list.** Demoting M-02 to `ambiguous` is the correct first move on a portal with shared switchboard numbers. Promoting M-03 to `auto-group` is almost always wrong — common names inside one large account collide.
## Part B — primary record selection
The primary is the record that survives the merge and keeps its `id`. Downstream integrations key on that `id`, so the choice matters beyond data quality.
Selection order — first non-tied criterion wins:
1. Record with the most associated deals (protects revenue history).
2. Record with the earliest `createdate` (protects original-source attribution).
3. Record with the lowest `id`.
`REPLACE_IF_DIFFERENT` — if an external system (billing, product, warehouse) holds a HubSpot record id as a foreign key, that record must be primary regardless of the above. List those systems and their object types here.
## Part C — field-level winner table
This is the table that makes the merge safe. HubSpot's `objects merge` keeps the **primary's** value wherever both records hold one, so any field where the secondary is better must be pre-written onto the primary before the merge runs. The Skill performs that pre-write automatically for every field marked `pre-write: yes`.
| Field | Winner rule | Pre-write | Notes |
|---|---|---|---|
| `email` | primary | no | The merge preserves the secondary's email as an additional email on the primary. |
| `firstname` / `lastname` | S-01 longest-non-empty | yes | Guards against truncated form imports (`Jo` vs `Johanna`). |
| `jobtitle` | S-02 newest-non-empty | yes | Titles change; newest by `hs_lastmodifieddate` wins. |
| `phone` | S-04 newest-verified | yes | Prefer a value whose source is not a self-serve form. |
| `lifecyclestage` | S-03 furthest-forward | yes | Never regress a customer to a lead. Order: subscriber, lead, MQL, SQL, opportunity, customer, evangelist. |
| `hubspot_owner_id` | primary | no | Reassignment is a separate decision from dedupe. Do not fold it in here. |
| `hs_lead_status` | S-03 furthest-forward | yes | Same ordering argument as lifecycle stage. |
| `REPLACE_CUSTOM_FIELD` | `REPLACE_RULE` | `REPLACE` | Add one row per custom property that carries reporting or routing weight. |
Rule definitions:
- **S-01 longest-non-empty** — the longer string wins; ties go to the primary.
- **S-02 newest-non-empty** — the value on the record with the later `hs_lastmodifieddate` wins, ignoring empties.
- **S-03 furthest-forward** — the value further along the declared ordering wins, regardless of recency.
- **S-04 newest-verified** — among values whose source property is in the trusted-source list, the newest wins; if none are trusted, fall back to S-02.
Trusted sources for S-04: `REPLACE_WITH_TRUSTED_SOURCES` (for example: sales-entered, enrichment vendor, verified-by-support).
## Part D — never touch
Fields excluded from every survivorship decision. The Skill refuses to write these even when a rule would select them.
```
hs_object_id
createdate
hs_analytics_source
hs_analytics_first_touch_converting_campaign
REPLACE_WITH_COMPLIANCE_FIELDS
```
Attribution and consent fields belong here. Rewriting first-touch attribution during a cleanup silently rewrites marketing history, and the change is invisible in a dashboard until a quarter-over-quarter comparison stops reconciling.
# Backfill provenance
Last edited: 2026-08-03 · Owner: `REPLACE_WITH_OWNER`
A backfilled value that carries no provenance is a value nobody can audit, roll back, or exclude from an analysis. Six months after the run, a blank-turned-populated `industry` field looks exactly like one a rep typed. This file defines where values come from, what gets stamped alongside them, and how to reverse the run.
## Part A — provenance properties
Create these two custom properties on every object type this Skill backfills, before the first run. The Skill hard-stops if they do not exist.
| Property | Type | Purpose |
|---|---|---|
| `hygiene_source` | single-line text | Which source supplied the value (`enrichment:REPLACE_VENDOR`, `derived:domain`, `inherited:company`, `default:policy`). |
| `hygiene_run_id` | single-line text | The run that wrote it. Format `hygiene_<job>_<YYYY-MM-DD>`. Rollback keys on this. |
Both are set on the same `objects update` call as the backfilled value, never in a second pass. A second pass leaves a window where a crash produces stamped-but-unwritten or written-but-unstamped records, and reconciling that window by hand costs more than the backfill saved.
Exclude both properties from every enrollment trigger, list definition, and report filter. They are metadata about the record, not facts about the customer.
## Part B — source precedence
Per target property, the first source that returns a non-empty value wins. Sources below the line marked `STOP` are never used to overwrite an existing value.
| Target property | 1st source | 2nd source | 3rd source | Overwrite non-empty? |
|---|---|---|---|---|
| `industry` | enrichment vendor | inherited from associated company | — | no |
| `numberofemployees` | enrichment vendor | — | — | yes, when the existing value is older than 365 days |
| `country` | enrichment vendor | derived from phone country code | derived from email TLD | no |
| `lifecyclestage` | derived from deal stage | — | — | no, forward-only per the S-03 ordering |
| `hubspot_owner_id` | territory rule | — | — | no |
| `REPLACE_TARGET` | `REPLACE_SOURCE` | — | — | `REPLACE` |
**Default to `no` in the overwrite column.** A backfill exists to fill blanks. The moment it overwrites populated fields it stops being hygiene and becomes a data migration, which needs a different review and a different approval.
The one row above that overwrites (`numberofemployees`) does so on an explicit staleness test, not on a blanket rule. Copy that shape for any other field you promote.
## Part C — derived-value rules
Derived values are computed, not fetched. Each one needs a stated rule so a reviewer can check the arithmetic.
- **`country` from phone** — map the country calling code to ISO 3166 alpha-2. Ambiguous codes (`+1` covers the US, Canada, and several Caribbean nations) resolve to `unknown`, never to a guess.
- **`country` from email TLD** — country-code TLDs only. Never derive from `.com`, `.io`, `.ai`, or any gTLD.
- **`lifecyclestage` from deal stage** — an associated deal in a closed-won stage implies `customer`. No other deal stage implies a forward move.
Anything you cannot state as a rule this short does not belong in a backfill. It belongs in a reviewed enrichment workflow with a human in the loop.
## Part D — rollback
Rollback is why the stamp exists. To reverse run `hygiene_backfill_2026-08-03`:
1. Read every record where `hygiene_run_id` equals the run id. This is the authoritative affected set — do not reconstruct it from the ledger, which records intent rather than result.
2. For each, look up the pre-image line in `<run_dir>/pre-image/<object_type>.jsonl` by `id`.
3. Write back only the properties the ledger lists for that record. Restoring the whole pre-image record would revert unrelated edits made by humans since the run.
4. Clear `hygiene_source` and `hygiene_run_id`.
5. Re-read the affected set and confirm the property values match the pre-image.
Step 3 is the one people get wrong. A blanket restore looks safer and is not — it silently discards every legitimate edit made in the intervening days.
## Part E — retention
Keep `run_dir` for `REPLACE_WITH_RETENTION_PERIOD` (suggested: 13 months, so a year-over-year comparison can still explain a discontinuity). The pre-image contains customer personal data, so store it where your CRM data-handling policy already applies and delete it on the same schedule as other CRM exports.
# Stale-deal disposition
Last edited: 2026-08-03 · Owner: `REPLACE_WITH_OWNER` · Pipeline: `REPLACE_WITH_PIPELINE_NAME`
Closing stale deals is the highest-friction job in this Skill, because the records belong to reps and the write changes a number leadership watches. This file exists so the rule is argued once with the sales leader who owns the pipeline, written down, and then applied identically every run.
Fill one copy of this file per pipeline. Thresholds that fit a 14-day transactional cycle will gut an enterprise pipeline.
## Part A — what counts as stale
Staleness is measured as days since `hs_lastmodifieddate`, not days since `createdate`. A 400-day-old deal worked last week is healthy; a 30-day-old deal untouched since creation is not.
`hs_lastmodifieddate` moves on any property write, including writes from automation. If a workflow stamps a property on every deal nightly, every deal reads as fresh and this job finds nothing. Confirm that before trusting the first run's output — a stale-deal report that returns zero rows on a pipeline everyone knows is clogged is measuring the automation, not the pipeline.
Where a nightly workflow does touch every deal, switch the measure to the most recent engagement (`notes_last_updated` or last logged activity) and record the substitution here.
## Part B — thresholds per stage
Threshold in days since last modification. Set each with the rep-facing owner, not alone.
| Stage | Stale after | Rationale |
|---|---|---|
| Appointment scheduled | 21 | A meeting either happened or did not inside three weeks. |
| Qualified to buy | 45 | `REPLACE_WITH_YOUR_MEDIAN_STAGE_DURATION` × 2 is the usual starting point. |
| Presentation scheduled | 30 | |
| Decision maker bought-in | 60 | Late-stage deals go quiet for real procurement reasons. Be slow here. |
| Contract sent | 90 | Legal and procurement cycles are long; closing these early destroys real pipeline. |
| `REPLACE_STAGE` | `REPLACE` | |
Set the threshold at roughly twice the stage's median duration, measured from your own closed-won history. Halving that number does not surface more real dead deals — it surfaces more live ones and teaches reps to distrust the job.
## Part C — disposition decision table
Staleness alone never closes a deal. The disposition combines staleness with three other signals.
| Stale | Owner active? | Amount ≥ threshold | Recent engagement | Disposition |
|---|---|---|---|---|
| yes | yes | no | none in stale window | `close-lost` |
| yes | yes | yes | none in stale window | `flag-for-review` |
| yes | no (deactivated) | any | any | `reassign` |
| yes | yes | any | engagement newer than last modification | `leave` |
| yes | yes | any | close date in the past, stage open | `flag-for-review` |
| no | any | any | any | `leave` |
Amount threshold: `REPLACE_WITH_AMOUNT` (suggested: the value above which a deal gets an executive review — commonly the same number that triggers deal-desk involvement).
Two rules carry most of the safety. Large deals never auto-close; they route to a human. And a deal whose owner has been deactivated is a data problem, not a dead deal — reassign it before judging it, or you will close pipeline that nobody has looked at because nobody could.
## Part D — close-lost reason taxonomy
Every auto-closed deal gets a reason, and the reason must distinguish itself from a rep-entered one. A pipeline where hygiene-closed and rep-closed deals share a reason code produces a win/loss analysis nobody can trust.
| Code | Meaning |
|---|---|
| `hygiene-stale-no-activity` | Auto-closed by this job. The only code the job writes. |
| `REPLACE_EXISTING_CODES` | Your existing rep-entered reasons. List them so the job never writes one. |
Set `closed_lost_reason` to `hygiene-stale-no-activity` and stamp `hygiene_run_id` from `references/2-backfill-provenance.md` on every deal the job closes. Exclude that code from win/loss reporting by default — these deals were not lost to a competitor, they were lost to neglect, and mixing the two inflates whatever category they land in.
## Part E — notification
Auto-closing a rep's deal without telling them is how this job gets banned. Before apply, post the per-owner list to the owner and hold for `REPLACE_WITH_HOLD_PERIOD` (suggested: 3 business days). Reps reopen what is actually live, and the reopen rate is the calibration signal for Part B.
A reopen rate above 15% means the thresholds are too aggressive. Raise them before the next run rather than arguing each case.