# The inventory file the scripts read

The agent fills one JSON file per CRM instance, read-only, starting from [../assets/crm-inventory-template.json](../assets/crm-inventory-template.json). [../assets/example-acme-inventory.json](../assets/example-acme-inventory.json) is a complete fictional example. JSON allows no comments, so this page documents the keys and the rules that read them.

## General rules

- A section that could not be read is marked in `coverage`, never left empty and never filled with guesses.
- Use `null` for unknown values and `false` only when the setting was seen to be off. The script treats `null` as not recorded and `false` as a finding.
- Counts are count objects, never bare percentages: `{"numerator": 38, "denominator": 640, "population": "open_deals", "query": "...", "read_at": "2026-11-01T09:40:00Z"}`. A zero denominator makes the rule NOT CHECKED.
- People appear as role labels (`user-07`, `agency-01`, `integration-billing`), never names or emails. Record IDs are fine.
- Writer labels are `type:name` in lowercase with hyphens: `workflow:inbound-rotation`, `integration:enrichco`, `form-setting:pricing-guide`, `setting:deal-won`, `user`. Use the same label in every section and in history exports, or writers will not line up. A form field a person typed is `form:<form>`; a lifecycle value a form sets on its own is `form-setting:<form>`.
  - The script labels an automation `<layer>:<name>` and an integration `integration:<name>`. To use another label, set `writer_label` on the automation or integration.
  - History often names a workflow or app by ID (`workflow:585051946`). Give the automation its `id`, or the integration its `id` or `app_id`, and the script matches the two; otherwise map the ID with `writer_history.py --writer-map`.

## Validation

`audit_crm.py` checks the inventory before any rule runs and stops with exit code 2, naming the key path, when:

- an enum key still holds a template hint such as `"personal | dedicated"`;
- a key that must be a list or an object is not one (`match_keys` is an object; `unique_keys`, `objects`, `creates_records`, `fields_written`, `writers` and `lifecycle.writers[].sets` are lists).

Enum values are compared ignoring case, spaces, hyphens and underscores, and rewritten to the forms below. Any other value prints a warning in the report; ACC-02 treats an unknown identity_type as not recorded and INT-02 an unknown blank_semantics as undefined. Allowed values:

- crm: hubspot, salesforce, attio, pipedrive, zoho, dynamics, other. business_model: b2b, b2c, self-serve. scope: full, gates.
- coverage status: complete, partial, unavailable. Stage type: open, won, lost.
- account_type: employee, contractor, agency, shared, generic, integration, former_employee.
- Integration type: data_sync, enrichment, event_app, no_code, reverse_etl, middleware, private_app, other. identity_type: personal, dedicated, private_app, service_key, oauth_app, integration_user. direction: into_crm, out_of_crm, two_way. blank_semantics: preserve, clear, undefined. stage_mapping: ids, labels.
- Lifecycle writer kind: form-setting, setting, workflow, integration, billing, enrichment, import.
- manual_results status: PASS, WARN, FAIL, NOT CHECKED, N/A. Evidence kind: writer sample, record trace, reconciliation, access view, settings capture.

## meta and coverage

- `meta`: crm, instance_label (a placeholder for shared reports), environment, edition, business_model, read_at, routes, reading_identity (role, can_write, denied_tools), scope.
  - business_model b2c or self-serve makes OBJ-04 N/A.
- `coverage`: one entry per section, `{"status": "complete", "reason": "...", "pages_read": n, "pages_remaining": n}` with status complete, partial or unavailable.
  - Unavailable makes every rule that reads the section NOT CHECKED, with the reason.
  - Partial adds a note to each finding from that section.
  - Sections: objects, settings, counts, fields, critical_fields, pipelines, lifecycle, forms, automations, users, integrations, api_capacity, reporting, access, history.
- `populations`: the counts behind each named population. The definitions live in `audit-config.json`.

## objects and settings

- `objects` (OBJ-01, OBJ-02, OBJ-03): name, label, custom, record_count, unique_keys, purpose, owner, created_at, meaning_mismatch (agent text that makes OBJ-01 FAIL).
- `settings`, grouped by the rules that read them:
  - OBJ-03: 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.
  - OBJ-04 and OBJ-06: contact_roles_drive_lifecycle, merges_90d, merge_preserves_record_id.
  - FLD-03 and FLD-05: forms_set_marketing_contact_default, custom_field_limits and custom_field_counts (per object).
  - ACC-05 and ACC-06: attio_plan, restricted_controls_only_layouts, two_factor_required, sso_enforced, team_visibility_used, owners_without_team.
  - INT-05 and INT-06: smart_transfer_active, native_salesforce_integration_active, inclusion_segment_used_as_security, sync_filters_assumed_exclusion, salesforce_dx_mcp_configured.
  - AUTO-08 and AUTO-10: account_cleanup_auto_remove_workflows, account_cleanup_owner, sequences_suppress_active_conversations.
  - REP-04: currencies, multi_currency, amounts_summed_without_conversion, company_currency_changed_after_deals, currency_conversion_documented, exchange_rate_policy_documented.

## counts

- OBJ-04: open_deals_without_company, sales_contacts_without_company, open_deals_without_contact.
- OBJ-05: companies_sharing_domain, vendor_duplicate_contacts.
- LIFE-06: customers_without_evidence, opportunity_without_open_deal, recent_contacts_empty_lifecycle.
- REP-05: open_deals_past_close_date, forecast_deals_missing_amount, lost_deals_with_reason (this one counts deals that have a reason).
- REP-06: recent_contacts_unknown_source.

## fields and critical_fields

- `fields`: usually empty in the inventory, because field definitions come from `normalize_exports.py`. Add entries only to correct or extend the fragment:
  - `created_at` from the properties API when the export has no created date (FLD-05).
  - `used_in_range_filters`, `used_in_calculations` and `used_as_dimension` flags (FLD-07).
  - `writers` (a list of writer labels) comes from the generic CSV for Pipedrive, Zoho CRM and Dynamics 365 and counts as writer evidence for FLD-02 and FLD-03.
  - Salesforce descriptions (FLD-06) do not go here: `""` never overrides, so a blank description cannot be recorded in the overlay. Save the `FieldDefinition` description query in [reading-salesforce.md](reading-salesforce.md) as one CSV per object and pass each with `normalize_exports.py salesforce-describe --descriptions [object=]file.csv`; fields it lists get their description, blank ones as `""`.
- `critical_fields` (FLD-01, FLD-02, FLD-03, FLD-08 and the writer inventory). The list and classes come from `audit-config.json`; the inventory adds:
  - Register keys: meaning, authoritative_source, permitted_writers, precedence_rule, blank_means, consumers, owner.
  - observed_writers: writers seen in history or settings that no other section shows.
  - history_tracked: false when the CRM keeps no history for the field (Salesforce field history tracking off). FLD-02 and FLD-03 then stay NOT CHECKED for it.
  - fill: a count object for the rule's population.
  - opt_out_reversals (consent fields): `[{"writer": "...", "records": n}]`.

## pipelines, lifecycle and forms

- `pipelines` (PIPE rules): id, label, object, open_deals, created_180d, records_180d (Salesforce sales processes and record types), multiple_won_reason, meaning_mismatch, and `stages[]`:
  - id (the internal ID), label, order, type (open, won, lost), probability (0 to 1), forecast_category (Salesforce, or HubSpot with Automate forecast categories on).
  - HubSpot: type is won when probability is 1.0 and lost when 0.0; every other stage is open. The script reads HubSpot types from probability and notes any recorded type that disagrees.
  - entry_evidence, entries_180d, required_fields, missing_required (field to count object), stuck_open (count object).
- `lifecycle` (LIFE rules): field (default `lifecyclestage`), options (value, label, custom, entry_evidence, owner), writers (writer, kind, sets, customer_evidence_documented), precedence_rule, precedence_tested, correction_path_documented, default_new_records, lead_status_used_for_follow_up, leads_object_active.
- `forms` (LIFE-03, LIFE-04): id, name, public, sets_lifecycle, sets_marketing_contact.

## automations

Overlays merged by `id` onto the fragment from `normalize_exports.py`, for what the definition cannot show:

- AUTO-01 and AUTO-02: owner, errors_30d. AUTO-08: enrolled_total, turned_off_at.
- AUTO-03: mutually_exclusive_with, enrollment_readable.
- AUTO-04: archived_list_refs. AUTO-05: has_exit_or_suppression, reenrollment (with trigger_fields once the trigger is read).
- AUTO-06: decision_ref (the id of a `decisions.items` entry that has a date; any other reference is FAIL), tested.
- AUTO-07: population_includes_customers, owner_fallback, available_only, rotation_users_without_seat. An `owner_writes` entry with `overwrite` null (the rotate action named no overwrite setting) adds a note, never a pass for protecting owners.
- AUTO-09: trigger_order. AUTO-10: essential, credit_fallback, send_limit_fallback.
- LIFE-05: manual_only, sets_reason_field.
- writer_label: the label history uses for this automation, when it differs from `<layer>:<name>`.

Whether an automation was read decides whether the rules that need it can pass:

- `writes_known` covers `writes` (field, op, value) and `owner_writes`; `actions_known` covers `destructive_actions`, `consent_actions` and `action_types`; `refs_known` covers `filter_refs`.
- An automation counts as read for a flag when the flag is true, or when the flag is null and its list is present (an empty list means "read, none"). A flag set to false, or a null flag with no list, means unread.
- An unread enabled automation makes LIFE-05, AUTO-03, AUTO-04, AUTO-05, AUTO-06, AUTO-07 and AUTO-10 NOT CHECKED when nothing else was read, and otherwise a NOT CHECKED part that turns their PASS into NOT CHECKED, naming it. In FLD-02, FLD-03, LIFE-03 and AUTO-09 it is a part that blocks the ready verdict.
- Hand-entered automations (Attio, Pipedrive, Zoho CRM, Dynamics 365, or a HubSpot workflow read from screenshots): give each id, name, object, layer (workflow, flow, process_builder, workflow_rule, apex, flow_other) and enabled, record what it writes, deletes, changes in consent and filters on, then set `writes_known`, `actions_known` and `refs_known` to true. The template leaves them null. An `enabled` left null counts as possibly on: the automation is checked like an enabled one, and AUTO-08 skips it.
- HubSpot: the fragment marks a flow without an actions list (a summary) as unread. Re-read it with `workflows get <flowId>` rather than editing the flags.
- Salesforce flows: add writes, actions and filter references from flow metadata or Org Check, and set the three flags to true. Every active flow needs this, not only those that write critical fields, or the rules above cannot pass.
- HubSpot workflows with `unknown_action_types` in the fragment: open each workflow, add the field writes (`writes`), deletes (`destructive_actions`) and consent actions (`consent_actions`) those actions perform, then set `unknown_action_types` to `[]` so the note clears.

## users

- `users` (ACC-01, ACC-03, ACC-04, AUTO-07): id, active, privileged, account_type (a departed person whose account is still active is former_employee with active true), documented_need, paid_seat, last_login, can_bulk_delete, can_merge_all, can_export_all, open_deals_owned, open_tickets_owned, routing_records_owned, team.

## integrations

One entry per app, private app, service key, connector or sync job:

- Identity and ownership (ACC-02, INT-01, INT-05): name, id or app_id (the ID history uses), writer_label, type, identity, identity_type, identity_privilege, documented_need, scopes_exceed_writes, owner, in_stack_md, believed_connected, legacy_private_app, migration_plan, credential_tied_to_departed_person, last_used_at.
  - identity_type must be recorded for every integration; one that is missing leaves ACC-02 NOT CHECKED.
  - credential_tied_to_departed_person: true when the integration authenticates with a departed person's own login or OAuth grant, or with a token or private app a departed person created or could read that has not been rotated since they left. Write the rotation fact into `identity` ("left 2025-11; token rotated 2025-11-30") so the value can be checked.
- What it writes and how it matches (OBJ-02, OBJ-05, INT-01): direction (into_crm, out_of_crm, two_way), objects, match_key or match_keys (object to key), creates_records, searches_before_create, keeps_person_id, fields_written (`field` or `object.field`), duplicate_trend.
- Conflicts (INT-02, INT-06, INT-07): two_way_fields_with_split_owners, default_app, blank_semantics, blanks_clear_owned_values, first_sync_baseline_decided, writes_owner, overwrite_mode (overwrite, fill_empty), provenance_field, clears_on_missing_rows, row_count_guard, feeds_automation, freshness_field.
- IDs and stages (OBJ-06, PIPE-06): keys_on_record_id, remaps_after_merge, errors_after_merges, record_not_found_errors_30d, stage_refs, stage_mapping.
- Health (INT-03): suspended_30d, record_errors (count object), oldest_open_error_days.

## capacity, reporting, decisions and agent results

- `api_capacity` (INT-04): salesforce_daily `{used, max, read_at}`, hubspot_private_app_daily `{used, limit, read_at}` (the account's daily limit, shared by every app).
- `reporting` (REP rules):
  - metrics: name, headline, money, counting_unit, timestamp, timezone, currency_policy, exclusions.
  - headline_numbers; reconciliations: metric, dashboard_value, record_list_value, explained_cause, read_at. A reconciliation with either value missing, or a record list value of zero, reads "reconciliation not run" (WARN), as does a headline number with no reconciliation.
  - reports: name, headline, sums_amount_after_join, report_type_drops_parents, unique_count_on_names, grain_stated.
  - test_exclusion_rule_documented, funnel_on_custom_field_without_timestamps, reentry_rule_documented, deal_source_snapshot.
- `decisions`: entity_map_documented, auto_creation_recorded, marketing_contact_default_reviewed, restricted_fields (a list; `null` means not recorded), automation_order_documented (fields whose layer order is written down), items (id, date, topic, rules). Copy every `decisions.md` entry a finding relies on into `items` with its date (YYYY-MM-DD): a `decision_ref` or a manual downgrade counts only when it names one of these ids.
- `access_checks` (ACC-05, fields): field, role, readable, approved, confirmed (false means possible but not confirmed, which counts as class B), via.
- `record_access` (ACC-05, records): object, holds_restricted_records, default_access (the org-wide default or the widest View access, as shown, such as "Public Read/Write" or "All deals"), readable_outside_approved, via (org-wide default, sharing rule, permission set, Attio object access), confirmed, decision_ref (the dated decision behind a deliberate Public Read/Write default).
- `archive_candidates` (AUTO-04): fields the change list proposes to archive.
- `gate_owners`: `{"FLD-03": {"owner": "privacy owner", "date": "2026-11-15"}}`, copied from the change list so WARN gates can reach a ready verdict.
- `manual_results`: rule, status, finding, evidence, decision, class. Fills rules the script cannot judge, or overrides it. A result that lowers a script FAIL or WARN is rejected with a warning, and the script result stands, unless `decision` is the id of a dated `decisions.items` entry. An accepted downgrade replaces the findings but keeps any NOT CHECKED part; a result for a NOT CHECKED rule replaces the whole result.
- `evidence`: id, rule, kind, ref (a path in the private working folder), summary. The Markdown report prints each item under its rule and marks a FAIL or gate WARN without one as unconfirmed.

## Fragments and how they merge

- `fields.json` from `normalize_exports.py`: `{"fields": [...]}` with object, name, label, type, field_type, type_family (text, enum, number, date, bool, reference, other), description (`null` when the export has none; for Salesforce it comes from `--descriptions`), options (label, value, raw), custom, unique, calculated, fill_rate (0 to 1), usages, created_at, last_updated, update_source.
- `automations.json` from `normalize_exports.py`: `{"automations": [...]}` with id, name, layer, object, enabled, description, created_at, updated_at, writes (field, op set, clear or set-dynamic, value; object when a Create record action writes a new record; associated true when an Edit record action writes an associated record), owner_writes (field, overwrite true, false or null when the action names no overwrite setting, available_only), consent_actions, destructive_actions, credit_actions, sequence_enrolments, creates_records, reenrollment (enabled, trigger_fields, unread_event_triggers), filter_refs (property, values), other_object_refs (filters on events or associated objects, counted and not resolved), action_types, unknown_action_types, writes_known, actions_known, refs_known (false for a HubSpot summary without actions and for every Salesforce flow until its metadata is added).
- `history.json` from `writer_history.py`: `{"fields": [...]}` with object, field, records_changed, changes_by_writer, multi_writer_records, flip_flop_records, automated_flip_flop_records, human_reversal_records, flip_flop_rate, automated_flip_flop_rate, last_writer_share, sample_record_ids, window_days. A file without the automated and human split is rated as if every flip-flop were between automated writers. Pass `--history` once per file.
- `audit_crm.py` merges in memory: fragment entries first, then inventory entries with the same key (fields by object and name, automations by id) override them key by key. `null` and `""` never override; a list, `[]` included, does. The inventory file is never rewritten.
