# Rules: objects, associations, identity and field governance

OBJ-01 to OBJ-06 and FLD-01 to FLD-08. Checked on 2026-11-06. Verify vendor menu paths and limits at run time.

Severity means the same in every rule:

- FAIL: wrong data, sends or exposure without permission, broken routing, revenue or consent, or an irreversible risk. Downgrade only with a dated `decisions.md` entry cited beside the finding.
- WARN: a plausible defect that needs a decision or more evidence.
- PASS: the rule ran and found nothing, or found something correct. State the reason.
- NOT CHECKED: evidence missing or the denominator is zero. State the reason and the evidence that would close it. NOT CHECKED is never a pass.
- N/A: the rule does not fit this CRM or business model. State why.

Impact classes weight the score: A = 3 (can contact people without permission, expose restricted data, corrupt revenue, ownership or consent, or make an irreversible change likely), B = 2 (misroutes work, breaks automation silently, makes a key report wrong), C = 1 (maintainability). "Script" names the inventory keys `scripts/audit_crm.py` reads ([inventory-format.md](inventory-format.md)).

## OBJ-01 Each object and pipeline has one meaning (B)

- FAIL: an object or pipeline holds another kind of record than its name and the entity map say (a deal per signup, tickets used as tasks, companies standing in for product workspaces, an Attio list used for a repeated entity whose history matters), and a report or automation counts them as the named entity.
- WARN: no written entity map; a custom object with no named person acting on its records; a custom object with zero records created more than 90 days ago; a custom object with no unique key.
- PASS: a written entity map exists and every object and pipeline matches it.
- Evidence: HubSpot `discover_hubspot_schema`, record counts, pipeline names. Salesforce `sf sobject list`, describe, SOQL `COUNT()` per object, record types. Attio `list-objects`, `list-lists`. Other CRMs: module list with counts.
- Script: `objects[]` (custom, owner, record_count, created_at, unique_keys, meaning_mismatch), `pipelines[].meaning_mismatch`, `decisions.entity_map_documented`.
- Agent adds: ask what each custom object is for before rating; compare with the account boundary in `decisions.md`.
- Confirm: open three recent records of a suspect object and read what created them.

## OBJ-02 Every integration that creates or updates records matches on a stable unique key (A, automation gate)

- FAIL: an integration creates or updates companies, accounts or custom object records matched on name, domain or email alone; or matches on a property that is not unique; or writes an object that has no unique key.
- WARN: a unique key exists but a create path does not search it first; no match key recorded; contacts matched on email only with no person ID kept.
- PASS: each writing integration matches on a unique key (HubSpot unique-value property or record ID; Salesforce External ID marked Unique, with `updateOnly` where creation is not intended; Attio unique attribute) and unmatched records go to a review queue.
- Evidence: HubSpot property definitions and record source detail on recent companies. Salesforce describe `externalId` and `unique`, upsert settings, records created by the integration user. Attio `is_unique` and the upsert matching attribute.
- Script: `integrations[]` (creates_records, objects, match_key or match_keys, searches_before_create, keeps_person_id) against `objects[].unique_keys`.
- Agent adds: HubSpot does not deduplicate companies created through the API, sync apps included, by domain. Attio upsert rejects a value that matches several records but does not require a unique attribute. Salesforce upsert creates a record when nothing matches unless `updateOnly` is set.
- Confirm: read one record the integration created, check the key is filled, and count duplicates by key (must be zero).

## OBJ-03 Auto-creation and auto-association settings fit the identity design (A)

- FAIL: HubSpot "Create and associate companies with contacts" is on while companies are keyed on a source ID or Salesforce is connected; a Salesforce flow or lead conversion path creates accounts without duplicate matching where an account key exists.
- WARN: Attio mailbox sync creates a company for every non-public email domain (the default) while companies come from another system; the HubSpot setting is on with no exclusions for shared domains.
- PASS: each setting is consistent with how companies and accounts are keyed, and recorded in `decisions.md`.
- Evidence: HubSpot company automation settings (verify the menu path at run time). Salesforce `DuplicateRule`, lead conversion settings, flows creating Account. Attio email and calendar sync settings.
- Script: `settings.auto_create_associate_companies`, `auto_association_shared_domain_exclusions`, `companies_keyed_on_source_id`, `salesforce_connected`, `account_creation_without_duplicate_matching`, `mailbox_sync_creates_companies`, `companies_maintained_externally`.
- Agent adds: on a shared domain the HubSpot setting attaches contacts to a company you cannot choose.
- Confirm: count companies created by auto-association in the last 90 days with an empty source-ID property.

## OBJ-04 Required associations exist (B)

- FAIL: open deals without a company above 10% of open deals (B2B); sales-eligible contacts without a company above 25%.
- WARN: above 2% and 10%; open deals without any contact above 10% where contact roles drive lifecycle or attribution.
- PASS: below the thresholds, population named.
- Evidence: HubSpot association count properties with `query_crm_data` or search totals (confirm internal names with `get_properties`). Salesforce `SELECT COUNT() FROM Opportunity WHERE IsClosed = false AND AccountId = null`. Attio SQL on Enterprise or filtered views.
- Script: `counts.open_deals_without_company`, `counts.sales_contacts_without_company`, `counts.open_deals_without_contact` (when `settings.contact_roles_drive_lifecycle`). `meta.business_model` of b2c or self-serve makes the rule N/A.
- Confirm: spot-check five orphaned deals for how they were created.

## OBJ-05 Duplicate creation is under control (B)

- FAIL: companies or accounts sharing a primary domain above 5% of companies with a domain (public email domains, listed shared domains and documented subsidiaries removed); or an integration created duplicates in the last 30 days at a rising rate.
- WARN: above 2%; vendor duplicate suggestions above 1% of contacts (a signal only).
- Evidence: HubSpot duplicate management counts (Professional or Enterprise) or an ID-and-domain export grouped by domain. Salesforce Duplicate Record Sets. Attio domain grouping via SQL or a view.
- Script: `counts.companies_sharing_domain`, `counts.vendor_duplicate_contacts`, `integrations[].duplicate_trend`.
- Agent adds: hand the candidate list to the crm-data-hygiene-audit skill if installed, otherwise the vendor duplicate tool. Never merge from this audit.

## OBJ-06 Merges cannot silently break integration mappings (B)

- FAIL: an integration or warehouse keys on CRM record IDs, merges happened in the last 90 days, and sync errors or orphaned mappings followed.
- WARN: an integration keys on CRM record IDs with no remap step after merges.
- PASS: integrations key on a source unique key, or a documented remap runs after every merge.
- Merge behaviour to record first: HubSpot merges create a new record ID unless the primary ID preservation beta is on; Attio merges always create a new record ID and the originals can no longer be read or written; Salesforce keeps the master and deletes the merged records.
- Script: `integrations[].keys_on_record_id`, `remaps_after_merge`, `errors_after_merges`, `record_not_found_errors_30d`, `settings.merges_90d`, `settings.merge_preserves_record_id`.
- Confirm: find one merged record and check every downstream mapping points at the surviving ID.

## FLD-01 Critical fields are registered (B, never FAIL)

- WARN: a critical field lacks meaning, authoritative source, permitted writers, blank meaning or consumers.
- PASS: every critical field is complete in [the register](../assets/field-ownership-register.csv) or `data-dictionary.md`.
- Script: `critical_fields[]` keys `meaning`, `authoritative_source`, `permitted_writers`, `blank_means`, `consumers`.
- Agent adds: draft missing rows from the evidence and mark them proposed. Never invent an owner.

## FLD-02 One automated writer per critical field, or a written precedence rule (A, automation gate)

- FAIL: two or more automated writers (integrations, workflows, flows, scheduled imports, enrichment, settings) write a routing, ownership, lifecycle, stage, revenue or identity field with no written precedence rule; or `writer_history.py` shows flip-flops on 3 or more records, or flip-flops between automated writers on 1% or more of records changed, in the window.
- WARN: flip-flops on 1 or 2 records where an automated writer reverses a person's change, or between automated writers on under 1% of records changed; observed writers outside the permitted list.
- PASS: every field the rule covers was rated from history, its observed writers are a subset of the permitted writers and no flip-flops appear. A field with no history, history tracking off, or no change inside the window is NOT CHECKED, naming the history to export; the rule cannot pass while any field is NOT CHECKED, because definitions cannot show which writer changed a value. Definition findings (two automated writers, writers outside the permitted list) still raise FAIL or WARN without history.
- A flip-flop is value A, then B from a different writer, then A again, inside the window. It is "between automated writers" when no person made any of the three changes. Unmapped Salesforce user IDs count as automated until `--writer-map` names them, so map every ID before rating. A workflow or app named by ID in history (`workflow:585051946`) is matched to the automation or integration with that `id` in the inventory.
- Two automated writers on a critical field without a written precedence rule is FAIL even when the values agree today.
- HubSpot property edit restrictions and Salesforce read-only page layouts are not precedence: they do not stop API writers. HubSpot states that any user can set a restricted property through the API or when creating a record.
- Evidence: HubSpot property history (source and label), the Data sources tab (Data Hub Professional or Enterprise), workflow edit and rotate actions, integration field mappings. Salesforce field history with `CreatedById` mapped to labels, flows, Apex and workflow rules writing the field. Attio historic values with `created_by_actor`.
- Script: writers merged from automation definitions, integration `fields_written`, `lifecycle.writers`, form settings, the `writers` column of a generic field export, `critical_fields[].observed_writers` and history; `critical_fields[].precedence_rule`, `permitted_writers` and `history_tracked`.
- Confirm: attach the `writer_history.py` summary and two record timelines.
- Who owns a two-writer finding: this rule rates it for every critical field across the whole CRM. revops-workflow-design decides who may write each value of a workflow field (lifecycle, lead status, owner, deal stage) and proves it with T05 and T06. martech-integration-engineering does the repair, one flow at a time, until crm-sync-and-field-ownership ships. crm-data-hygiene-audit rates no writers: it names the creation path and hands the finding here.
- The same evidence can rate differently, by design. T05 fails any A to B to A inside 24 hours against an agreed spec, a rep's reversal included; FLD-02 looks across 30 days and leaves 1 or 2 reversals of a person's change at WARN. Keep each status in its own report and cite the other by rule ID.

## FLD-03 Consent and marketing status have only permitted writers (A, automation gate)

- FAIL: an automated writer other than the consent source or a documented process writes opt-in, subscription, marketing contact status or opt-out fields (enrichment, an event app, a CSV import, reverse ETL, a workflow without a policy); or a non-consent writer cleared an opt-out in the window.
- WARN: a consent field has no register entry; manual edits outside the permitted list; forms set new contacts as marketing contacts by default with no reviewed decision.
- PASS: every consent field was rated from history and shows only permitted writers there and in the definitions. A consent field with no history is NOT CHECKED, even when no writer was observed, and the rule cannot pass while one is. This holds for every consent field, including one with no register entry or with manual edits outside the permitted list: its WARN keeps the automation verdict at Not assessable until the history arrives, whoever owns the WARN.
- Evidence: HubSpot subscription and consent property history, workflows that set marketing contact status or manage subscriptions, the form setting. Salesforce `HasOptedOutOfEmail` and consent object history. Attio consent attributes and their historic values.
- Script: critical fields of class `consent`, `opt_out_reversals`, `settings.forms_set_marketing_contact_default`, `decisions.marketing_contact_default_reviewed`.
- Agent adds: the legal basis is the privacy owner's decision; this rule checks writers against it.

## FLD-04 No near-duplicate or numbered fields split one meaning (B)

- FAIL: two or more fields in one cluster are both written and both used (usages above 0).
- WARN: other clusters; numbered sets such as `workspace_1_status` and `workspace_2_status`; a custom field duplicating a standard one.
- Clustering, per object and type family (text and enumeration count as one family): lowercase the name, strip one suffix (`__c`, `__pc`, `__r`), strip one vendor prefix (`hs_`, `hubspot_`, `sf_`, `salesforce_`, `stripe_`, `clay_`, `zi_`, `zoominfo_`, `apollo_`, `clearbit_`, `attio_`), drop digits and the words current, new, old, copy, latest, primary, then cluster on equal keys or token Jaccard 0.8 or more. Clusters of standard fields only are ignored. Lists are configurable in `audit-config.json`.
- Agent adds: decide the surviving field with its owner. HubSpot internal names cannot change, so a rename is a migration.

## FLD-05 Unused fields are known and capacity has headroom (C; B when the capacity FAIL fires)

- FAIL: custom field count at or above 95% of the object's limit.
- WARN: custom fields with 0% fill and zero usages that are older than 90 days (archive candidates); custom field count at or above 80% of the limit.
- Age comes from `created_at`, else from the export's last updated time. Fields an integration maps are kept even when empty.
- Agent adds: HubSpot archived properties restore for 90 days, then are deleted. A deleted Salesforce custom field and its data restore for 15 days, or until the org permanently deletes them, whichever comes first. Read limits from the HubSpot Limits tab or Salesforce Object Manager, never from memory.

## FLD-06 Custom fields are documented (C, never FAIL)

- WARN: more than 25% of custom fields have no description, or any critical field has none.
- Salesforce describe output carries no descriptions. Run the `FieldDefinition` description query in [reading-salesforce.md](reading-salesforce.md) per object and pass the CSVs with `normalize_exports.py salesforce-describe --descriptions`; without them the rule is NOT CHECKED. A field missing from those files counts as not read, never as undocumented.

## FLD-07 Field types and options fit their use (B)

- FAIL: an enumeration has two options whose labels or values match after trimming and lowercasing; a field used in range filters or calculations stores numbers or dates as text (agent confirms the use with `used_in_range_filters` or `used_in_calculations`).
- WARN: names with date tokens (date, at, timestamp, deadline, day) or amount tokens (amount, count, revenue, arr, mrr, price, seats, quantity, qty, total, employees) stored as text; an enumeration with one option; free text used as a segmentation dimension (`used_as_dimension`).
- Agent adds: workflows and filters that reference a removed duplicate option stop matching silently. Check AUTO-04 before merging options.

## FLD-08 Decision-driving fields are filled for their population (B)

- FAIL: a routing input below 80% filled in its eligible population; a headline reporting dimension below 70%.
- WARN: below 95% and 90%.
- Script: `critical_fields[].fill` count objects. Routing and ownership classes use routing thresholds; other classes use reporting thresholds.
- Agent adds: HubSpot export fill rates cover all records; recount for the rule's population before rating. Missing and zero are different values; record the blank meaning from the register.
