# Contract rules: identity, ownership, values, removals and freshness

The contract lets two engineers build the same behaviour and lets the business owner judge the result. Settle these rules before choosing a method; a connector cannot fix an undefined key or owner. Rule IDs marked "lint" are checked by `scripts/lint_integration.py` with the exact conditions at the end of this file.

## Identity

- Match on a stable source ID stored in a destination field that enforces uniqueness:
  - HubSpot: a property created with `hasUniqueValue` true. At most ten unique value properties per object; the setting is chosen at creation, so plan it before the backfill.
  - Salesforce: a field marked External ID and Unique. They are separate settings; External ID alone does not stop duplicates. REST upsert by external ID returns 300 and changes nothing when several records match, `updateOnly=true` forbids creation, and an External ID without Unique also needs View All Data to run the upsert (upsert reference, checked 2026-11-06).
- Email only for person records, never for accounts, companies, workspaces, deals or opportunities. Emails change, move between people and are shared by finance teams. Never match on name or domain: names collide and agencies or holding companies share domains.
- Two candidate matches: no write; send both record IDs to the exception queue. Never pick the first match.
- No match: create only if this flow owns creation. Otherwise send an exception with the source ID so an owner can resolve it.
- Keep person, workspace, commercial account and subscription as separate entities. An account summary aggregates subscriptions with a written rule.
- Include the source account and its live or test environment in keys when there are several (two billing accounts, a sandbox and live mode), so a test record never lands on a live customer.

Crosswalk columns:

- `source_system`, `source_id`, `destination_system`, `destination_id`, `entity`
- `status`: matched, ambiguous, unmatched, merged, deleted
- `first_seen`, `last_verified`

Record the destination ID returned by an accepted create in the crosswalk, and stop another pending create for the same entity from racing ahead of the first result.

## Key normalisation traps

- Never lowercase opaque IDs. Case can be part of the ID.
- Store numeric-looking IDs as text so leading zeros survive. Spreadsheets strip them (`0001009` becomes `1009`).
- Salesforce 15-character IDs are case-sensitive; 18-character IDs are case-safe. Reports return the 15-character form, and `CASESAFEID(Id)` converts it. Convert everything to 18 characters before joining, and never join 15-character IDs case-insensitively.
- Segment Unify treats every identifier, including `userId`, `anonymousId` and `groupId`, as case-insensitive (identity resolution page, checked 2026-11-02). Two source IDs that differ only by case resolve to one profile there.
- Test the destination's comparison rule with two IDs that differ only by case before the backfill.
- `scripts/reconcile_readback.py` reports these as KEY_MANGLED, CASE_COLLISION and MIXED_SF_ID.

## Merges and deletes

- On a merge, move the crosswalk to the survivor before the next write, or the flow recreates the merged-away record.
- Keep tombstones for deleted and erased records so a stale cache, retry or replay cannot recreate them.
- Pipedrive webhooks carry `merged_to_id` and `merged_from_id` in the meta block; handle them as merge events, not as updates.

## Field ownership

- One owner per field. Ownership means the business definition, the authoritative writer, permitted secondary writers, validation and the correction route. It is not the CRM's record owner.
- Direction per field. A system can own some fields and receive others; "the CRM is the source of truth" is too broad when billing supplies paid status and the product supplies usage.
- Conflict rule by version or effective time. Last write wins only when arrival order equals authority, which delayed exports, retries and manual edits break.
- Writers send an allowlist of owned fields, never the whole source object, and skip writes that change nothing. A new field in a source response must not gain write authority by appearing.
- Separate observations from judgements: `last_active_at` (observed) and `account_risk_reason` (a person's assessment) in one editable field means one erases the other.

Manual override pattern, when people must correct a calculated value:

- `calculated_account_tier`: written by the integration.
- `account_tier_override`: written by people, with a reason and an expiry or review date.
- `effective_account_tier`: the override while it is active, otherwise the calculated value. Automations read this one.

Two-writer findings arrive here from other audits: martech-stack-audit's FLW01 across tools, crm-audit's FLD-02 inside a CRM. This skill owns the repair for one flow: the field map, its FO rules, the conflict rule and the change list. Who may set lifecycle stage, lead status, deal stage or record owner is revops-workflow-design's decision, taken first. When crm-audit is installed, its `writer_history.py` reads the same history exports and flags flip-flops.

Find every writer before designing:

- Sample: history for the flow's fields on at least one record per lifecycle path the flow touches (created, changed, cancelled or churned, merged, manually corrected), plus the ten most recently modified records per destination object. Writers that fire only on churn, renewal, merge or a quarterly import rarely show on a handful of recent records.
- Bulk pull where the tool allows it: HubSpot batch read with `propertiesWithHistory` returns each value with `sourceType`, `sourceId`, `sourceLabel` and `updatedByUserId`; Salesforce field history objects (for example AccountHistory) for tracked fields. Tally change sources over the last 90 days. Batch operations take up to 100 records, and the batch read reference states no separate limit for history reads (checked 2026-11-02); confirm the current limit there before paging.
- List each connector, iPaaS scenario, reverse ETL sync, workflow action, import and script found. A writer in history but not in the inventory is a FAIL until someone owns it.
- No history for the field (Salesforce keeps it only for fields with history tracking turned on, from the day it was turned on; several lifecycle and warehouse tools keep none): mark the writer check NOT CHECKED with that reason and use the fallbacks: last-modified user and time on the sample, the integration users' API or audit logs, and the lists of workflows, syncs, imports and scenarios that set the field. Never mark it PASS without history.
- Record the sample and the window in the review. Do not confuse the record's creation source with the source of a later property change.

## Blank semantics

- Absent field: no change.
- Explicit clear: only from the field's owner, and only where the contract allows it.
- Null caused by a failed upstream join or lookup: skip the write and flag it.
- Zero and false are values, not blanks.

Vendor behaviour differs; verify before mapping:

- Salesforce Bulk API 2.0 CSV updates ignore empty cells and set null on `#N/A` (checked 2026-11-06).
- RudderStack maps a null to `#N/A` for Salesforce, so a null clears the field.
- Hightouch sends nulls by default unless the mapping skips them.
- HubSpot: an empty string sent through the API clears the property (CRM properties guide, checked 2026-11-02). Leave a property out of the payload to keep its value; never turn a null from a failed lookup into an empty string.

## Values

- Enums: list allowed values; quarantine unknown values in the exception queue; never map them to a convenient default.
- Money: ISO 4217 currency on the same record, and the amount basis (gross, net, tax, fees, refunds). Keep currencies separate until a written conversion rule applies, and store the conversion date with any converted value.
- Time: a date and an instant are different types. State the time zone. Store occurrence time and receipt time separately.
- HubSpot date properties take `YYYY-MM-DD`, or epoch milliseconds set to midnight UTC (CRM properties guide, checked 2026-11-02). Derive the calendar date in the time zone the contract names, then send the date string; converting a period-end timestamp through another time zone moves the date by a day.

## Removals

- Give unsubscribe, audience exit, deletion and merge each a destination behaviour. Removing someone from an audience is not deleting their CRM record.
- An add-only sync cannot meet a current-membership contract (RM01). People who stop qualifying stay in the list forever.
- Minimum-population guard: any run that would remove more than 10% of the destination population or more than 1,000 records stops for approval. A broken join that returns zero rows otherwise mirrors as "delete everyone". Hightouch's unexpected-volume stop covers journey Trigger-mode syncs only, so it is not this guard.
- Hightouch detects removals by comparing consecutive runs; enabling deletion later does not recover earlier exits.
- Fivetran Activations Mirror deletes destination objects whose rows were removed, compared with what it sent before; its first run upserts everything.

## Freshness

- Set the target from the decision: a weekly review and a suppression check before a send need different targets.
- Measure three stages separately: source-through time, processing time and destination acceptance.
- A late event must not restore an obsolete state. Refetch the authoritative object or compare versions before writing; keep the old event for history.

## Rules checked by the linter

Identity:

- ID01: `match_key_kind` name or domain is FAIL. Email on an account-level object (`destination_object` matching company, account, organization, organisation, workspace, deal or opportunity, case-insensitive) is FAIL. Email on a person object is WARN.
- ID02 FAIL: `creates_records` yes and `destination_unique` not yes.
- ID03 WARN: `match_key_kind` destination_id with `creates_records` yes. Only the writer that owns creation can know the destination ID.
- ID04 WARN: `creates_records` no and `destination_unique` not yes. Two destination records can match; state how the flow detects two matches and stops.

Removals:

- RM01 FAIL: `membership_contract` current with `removal_policy` none or not_applicable.
- RM02 WARN: `removal_policy` delete; needs the minimum-population guard and approval.

Field ownership (field map; rows with writer read_only are excluded from FO01, FO02, FO04 and FO09):

- FO01: one destination system, object and field written (writer this_flow) by two or more flows is FAIL. When every one of those rows has conflict rule version or effective_time it is WARN: confirm every writer compares the same version field, because two flows that each compare their own revision still overwrite each other.
- FO02: `other_writers` listed with `conflict_rule` none or blank is FAIL. Listed with version or effective_time it is WARN: a version rule only binds writers that compare it, so remove the other writer's write access or show it compares the same version.
- FO03: `direction` two_way with `conflict_rule` none is FAIL; two_way with owner_wins on a decision-driving field is WARN (a global winner).
- FO04 FAIL: `blank_policy` clear on a decision-driving field.
- FO05 FAIL: `type` enum with blank `allowed_values`.
- FO06: `type` currency with blank `unit_or_currency` is FAIL on decision-driving fields, WARN otherwise.
- FO07 WARN: `pii` yes. Confirm the consent basis, minimise, keep it out of logs and alerts.
- FO08: a field-map `flow_id` missing from the inventory is FAIL; an inventory flow with method native, ipaas, reverse_etl or custom and no field-map rows is WARN.
- FO09 WARN: a one_way row with writer this_flow whose owner does not match the flow's `source_system` (case-insensitive containment either way): the flow writes a field its source does not own.

Rules applied by hand: method passes MP01 to MP07 ([method-selection.md](method-selection.md)), the writer inventory, crosswalk handling of merges and deletes, blank semantics per vendor and the removal guard.
