# CRM writeback under a field ownership contract

Depth for procedure step 8 and the decision rules `scripts/check_writeback.py` applies. The planned clay-to-crm-writeback and crm-sync-and-field-ownership skills go further; until they are installed, use this file.

## The contract

One row per object and property the workflow reads or writes ([../assets/field-ownership-contract.csv](../assets/field-ownership-contract.csv)):

- `object`: contacts, companies, deals (HubSpot) or Lead, Contact, Account (Salesforce).
- `property`: the internal name (HubSpot) or API name (Salesforce), never the label.
- `owner`: the system or team whose value wins (crm, sales, enrichment, scoring, shared).
- `allowed_writers`: semicolon-separated writer names, the same names the writeback file's `writer` column uses (clay_writeback, routing_workflow, scoring_job).
- `write_rule`: `never` (no writeback ever changes it), `fill_if_blank` (write only when the current value is blank), `overwrite` (the writer owns it), `propose_only` (always goes to the candidate property).
- `on_create`: `allowed` or `forbidden` when the workflow creates the record.
- `blank_input`: `ignore` (a blank never clears) or `clear_with_approval` (a clear is allowed only with a named approval).
- `candidate_property`: where a conflicting value goes for review (for example `enr_jobtitle_candidate`).
- `freshness_days`, `note`.

## Field classes and defaults

- Never written by enrichment: owner, lifecycle stage, lead status, deal stage and amount, consent and subscription status, do-not-contact, customer status, notes, manual overrides.
- Enrichment-owned: source-prefixed properties that only the enrichment writer touches (`enr_company_size`, `enr_email_status`, `enr_source`, `enr_observed_at`). These may be overwritten.
- Shared (job title, phone, industry): fill when blank. A different non-blank value goes to the candidate property and a review queue. Rep edits win.
- Disputed fields: use three properties, for example `acme_tier_calculated` (the job writes it), `acme_tier_override` (a person writes it) and `acme_tier_effective` (the override when set, otherwise the calculated value). Reports read the effective one.

## Update and create are separate gates

- Update requires a known CRM ID and an approved change. A lookup that fails or times out never redirects to create.
- Create requires zero matches from an authoritative lookup, identity evidence (email for contacts, normalized primary domain for companies) and create permission under the business rules.
- Serialize creates per entity key. Lookup-then-create in two parallel runs makes two records.
- Where the CRM offers upsert on a unique key, use it for the create path: HubSpot batch upsert by `email` or a unique value property, Salesforce upsert on an External ID field also marked Unique, or Clay's Salesforce Upsert object. The platform then decides create or update in one call, which serialization only approximates. Before approval, still resolve each row against a fresh export to `update` or `create`, run `check_writeback.py`, and send only the properties the contract lets that writer set, because an upsert overwrites every property it sends.
- Store every returned ID. A create that times out is an unknown outcome: reconcile by the identity key before any retry.
- HubSpot search can take a few moments to show new or updated records, so an empty search right after a create is not proof of absence.
- Unique keys: HubSpot allows up to ten unique value properties per object, not supported in forms (checked 2026-11-01). In Salesforce, External ID and Unique are separate field settings, and several records can share an External ID value unless Unique is on (checked 2026-11-06). Check both on the field before relying on upsert: when several records match, REST upsert returns 300 and changes nothing, and an External ID without Unique also needs View All Data.

## Blank and clear semantics per writer (checked 2026-11-01; verify live)

- Clay HubSpot Update object: requires the object type and record ID; Ignore blank values is on by default; turning it off sends blanks and clears HubSpot values. No upsert action is listed.
- Clay Salesforce Update record: Ignore blank values applies to text fields only; with it off, a blank text cell clears the Salesforce value. A blank number column is dropped before it reaches Salesforce whatever the setting, so a blank never clears a number. A formula-mode mapping with a null fallback (for example `{{Employees}} ?? null`) does clear it, every time enrichment returns nothing, so use that fallback only on a field the writer owns and may clear. Clay's Salesforce actions also include Upsert object, which needs an external ID on the object.
- Apollo create a contact with `run_dedupe` true: updates the matched contact in place. Sent fields overwrite, omitted fields stay, and an empty value clears. The email can be overwritten when the match came from a CRM ID, `person_id` or name plus company, and `email_status` is not re-verified. Bulk create returns matches unchanged in `existing_contacts`.
- Salesforce Bulk API CSV: empty values are ignored on update and `#N/A` sets a field to null (checked 2026-11-06).
- HubSpot import: rows without a Record ID create new records, and a secondary email supplied with a Record ID replaces the primary email.
- `check_writeback.py --blank-mode send` models any writer that sends empty cells as clears. For Clay Salesforce it over-reports: blank number cells are dropped, not sent.

## Duplicate traps (checked 2026-11-01; verify live)

- HubSpot deduplicates contacts by email and companies by primary domain, but companies created through the API, including installed third-party sync apps, are not deduplicated by domain.
- HubSpot "Create and associate companies with contacts" (Settings, Data Management, Objects, Companies, Automation; checked 2026-11-02) creates a company for a new contact's email domain when none exists, one per unique domain including subdomains (`example.com` and `info.example.com` make two companies). When several companies share a domain, contacts with it are associated with only one of them. For free email domains it uses the contact's Website URL, which can attach a personal-email contact to a company. If an integration also creates companies through the API, the two paths create duplicates. Pick one company-creating path, record it in the contract, and detect with a duplicate scan by domain and creating source.
- HubSpot contact upsert works by email or a custom unique identifier property; partial upserts by email are not supported; batch operations take 100 records and batch updates need record IDs.
- Apollo create a contact does not deduplicate by default.
- Clay's Salesforce Create record has a Duplicate rule override that bypasses the org's duplicate rules. Keep it off. Clay Salesforce report imports cap at 2,000 records, and without uniqueness fields they can duplicate rows.
- Salesforce lead conversion does not run cross-object duplicate rules the way record creation does (checked 2026-11-06).

## Two enrichers on one property

- HubSpot automatic enrichment fills only empty properties and does not overwrite values set by a user or another system; manual enrichment can overwrite; continuous enrichment stops once a user or another system edits the property (checked 2026-11-01).
- ZoomInfo CRM integrations give each mapped field an update option, overwrite or complete if missing. Set shared fields to complete if missing; confirm the exact label in your org.
- Two enrichers writing one property is a contract defect. Choose one writer; the other writes its own candidate property or nothing.

## Loop prevention

- Enrich on business events only: a required field is missing, freshness expired, a new signal arrived. Never on "record updated", because your own write updates the record.
- Write only when the proposed value differs from the current value (`no_change` otherwise).
- In Clay, avoid scheduled sources with Update existing rows re-running every row; gate paid columns with "Only run if".

## Read-back, change log and rollback

- After an approved write, read the records back and compare with the change list.
- Keep a before-values log per run: record ID, property, before value, after value, run ID, writer, time.
- HubSpot property history shows value, time and source, but restore is offered only when the source was a CRM UI edit, an import or a workflow (checked 2026-11-01). Integration and API writes need your own before-values log to roll back.
- Roll back by run ID, only the changes that run made, after checking nothing newer was written by a person.

## Rate limits that shape batch design (checked 2026-11-01; verify live)

- HubSpot privately distributed apps: 100 requests per 10 seconds on Free and Starter, 190 on Professional and Enterprise; daily 250,000, 625,000 or 1,000,000 per account by tier; the daily limit resets at midnight in the account time zone. CRM search: 5 requests per second, 200 results per page, 10,000 results per query.
- Salesforce (checked 2026-11-06): the daily API allocation is measured over a sliding 24 hours, with no reset at midnight. Production orgs and sandboxes allow 25 concurrent long-running requests (20 seconds or more), Developer Edition and trial orgs 5.
- Apollo: per team, per endpoint, in minute, hour and day windows.

## check_writeback.py

```bash
python3 scripts/check_writeback.py --contract CONTRACT.csv --current CRM_EXPORT.csv \
  --proposed WRITEBACK.csv --object contacts --key record_id --identity email \
  --blank-mode send --out change-list.csv
```

- Inputs: the contract, a fresh export keyed by record ID kept as text, and the proposed file with `action` (update or create), the key, `writer`, and one column per property.
- The export holds the update targets plus a read-only lookup on each create row's email or domain, with the same columns: HubSpot `search_crm_objects` by email and by additional email addresses (HubSpot treats both as unique identifiers), or a Salesforce `sobject-reads` query. The duplicate check sees only the export, so a matching record left out of it passes silently. It compares a writeback column with the export column of the same name unless `--identity` maps it to more. On HubSpot, add `hs_additional_emails` to the export and pass `--identity "email=email|hs_additional_emails"`, quoted because `|` is a shell pipe, so a create whose email is another contact's additional email is caught. Export cells may hold a semicolon or comma list. For companies pass `--object companies --identity domain`.
- The export carries every property column in the proposed file, named by internal name (HubSpot) or API name (Salesforce). Rename label headers from a UI export first. A missing column for a property the update rows write, or for an identity column, stops the script (exit 2), because blank cannot be told from missing.
- Findings print no cell values: only row, action, record ID, property and reason. Values appear only in the `--out` file, which goes to the approver, not into chat. The `--out` file holds contact values: keep it beside the user's own export and out of version control, share it only with the approver, and delete it once the change is applied or rejected. `--show-values` adds values to findings, for fictional or test data only.
- Cells: empty means no value; `__CLEAR__` (change with `--clear-token`) means an explicit clear. With `--blank-mode send`, an empty cell in a column the writer maps is a clear.
- Decisions per cell: `fill`, `overwrite`, `create`, `candidate` (goes to the candidate property), `no_change`, `needs_approval` (a clear allowed with approval), `blocked`.
- Row checks: update target missing from the export, damaged IDs (scientific notation, trailing `.0`), create rows with an ID or without identity, duplicate creates (emails compared ignoring case and spaces, domains also ignoring the scheme, `www.`, port and path), the same record twice with conflicting values, an identity value on update that differs from the record (a likely wrong match), mixed 15 and 18 character Salesforce IDs.
- Mapping check: a column the writer fills but may not write is reported once per column. With `--blank-mode send` it is a FAIL with the count of values its blank cells would clear.
- Pass for approval: zero blocked cells and zero FAIL in the file that goes to the approver. A create row against an export with no records is a WARN, because no duplicate check ran. Exit 1 means at least one FAIL. Exit 2 means bad input, for example a writeback with no rows, a file that is not a UTF-8 CSV (save Excel files as CSV UTF-8) or an `--out` folder that does not exist yet.

## Execution hand-off

- This skill stops at the change list. Applying it follows agent-write-safety: connection inventory, read-only first pass, change list, approval of that exact version, smallest batch, read-back, change log, return to read-only.
- HubSpot: the Agent CLI skills (`HubSpot/agent-cli-skills`) run upserts with `--dry-run`, a digest and a confirm step. Read back after.
- Salesforce: the hosted `sobject-mutations` server, acting as the signed-in user with their object permissions, field-level security and sharing. Validation rules apply. Salesforce doesn't document whether assignment rules and flows fire on these writes, so test it in a sandbox and record the result in the change list.
