---
id: CAP-CRM-ENT-008
title: Keep customer-defined information with records
product_area: crm
module: entities
revision: 1
status: approved
product_intent: confirmed
implementation_evidence: observed
source_sha:
  services-crm: 7ea48a414c42eade10d60753b8bcc323b1dd726f
  react-console: da63075876b2c5f4a6af07d457bb24ea8bf4c0c7
intent_sources:
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#D-04'
  - >-
    services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#2.
    Scope
  - >-
    services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.plan.md#A12
purpose: >-
  Staff store the tenant’s additional information alongside contact and
  organization records.
outcomes:
  - >-
    Every custom value is stored in the type its field defines, or the save is
    rejected at that key.
  - A write changes only the keys it names.
actors:
  - ACT-CRM-001
  - ACT-CRM-003
  - ACT-CRM-004
surfaces:
  - console
  - api
preconditions: []
postconditions: []
permissions:
  - key: crm.contacts.update
    where_checked: api
    scope: tenant
  - key: crm.organizations.update
    where_checked: api
    scope: tenant
  - key: crm.contacts.read
    where_checked: api
    scope: tenant
  - key: crm.organizations.read
    where_checked: api
    scope: tenant
rules:
  - id: BR-CAP-CRM-ENT-008-01
    proved_by: [ATS-CRM-ENT-008-01, TST-CRM-ENT-786]
    statement: >-
      Custom field writes merge only named keys, preserve omitted or empty maps,
      set supplied values and clear null or blank values, but reject nulling or
      removing the entire map.
    kind: policy
    enforced_at:
      - api
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts-custom-fields.e2e.spec.js#sets
      the key it sends, clears the key sent null and the selection sent empty,
      and leaves the others
    origin: none
  - id: BR-CAP-CRM-ENT-008-02
    proved_by: [ATS-CRM-ENT-008-02, TST-CRM-ENT-786]
    statement: >-
      Writable custom field keys must identify active definitions for the tenant
      and record kind; unknown, foreign, mismatched or archived definitions are
      rejected at the key’s path.
    kind: validation
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts-custom-fields.e2e.spec.js#refuses
      a write to an archived field
    origin: none
  - id: BR-CAP-CRM-ENT-008-03
    proved_by: [ATS-CRM-ENT-002-15, ATS-CRM-ENT-004-17, ATS-CRM-ENT-008-03, ATS-CRM-ENT-008-06, ATS-CRM-ENT-008-09, ATS-CRM-ENT-008-10, TST-CRM-ENT-786]
    statement: >-
      Custom values are converted to their declared types or rejected, including
      exact integer and decimal conversion, explicit true/false interpretation,
      text lengths, live list options, offset-qualified date-times and
      tenant-owned references.
    kind: validation
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts-custom-fields.e2e.spec.js#refuses
      yes for a yes/no field: no truthiness
    origin: none
  - id: BR-CAP-CRM-ENT-008-04
    proved_by: [ATS-CRM-ENT-001-21, ATS-CRM-ENT-008-04, ATS-CRM-ENT-008-08, TST-CRM-ENT-775, TST-CRM-ENT-786]
    statement: >-
      API-required custom fields need values on creation and cannot be cleared,
      while an older record lacking a newly required value can still be saved.
    kind: validation
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts-custom-fields.e2e.spec.js#saves
      a replace of a contact created before the field was required, leaving the
      field out
    origin: none
  - id: BR-CAP-CRM-ENT-008-05
    proved_by: [ATS-CRM-ENT-008-07, TST-CRM-ENT-786]
    statement: >-
      Reads return readable custom values in one map using their JSON forms and
      null for empty values; top-level custom field properties are neither
      returned nor accepted on writes.
    kind: policy
    enforced_at:
      - api
      - service
    must_prove: false
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts-custom-fields.e2e.spec.js#puts
      no custom field at the top level of the record
    origin: none
  - id: BR-CAP-CRM-ENT-008-06
    proved_by: [ATS-CRM-ENT-008-05, TST-CRM-ENT-786]
    statement: >-
      Contact roles, affiliation types and affiliations do not support custom
      values and reject custom_fields as an unknown property.
    kind: validation
    enforced_at:
      - api
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contact-roles.e2e.spec.js#refuses
      a create that carries custom_fields, as an unknown property
    origin: >-
      services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.plan.md#A12
  - id: BR-CAP-CRM-ENT-008-07
    proved_by: []
    statement: >-
      Rich-text custom values use a sanitized basic editor and safe rendering in
      forms, Explorer and record views.
    kind: policy
    enforced_at:
      - console
    must_prove: false
    provenance: documented
    source: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#D-04'
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#D-04'
scenarios:
  - id: SC-CAP-CRM-ENT-008-01
    given: 'A tenant with member_since__c (date) and dues__c (amount, 2 places).'
    when: >-
      A staff member sets both from the Custom Fields tab and the API retrieves
      them.
    then: Dues retrieves "12.50" and member_since the date entered.
    provenance: test
    source: >-
      react-console:acceptance/crm/playwright/journeys/contacts/CRM-ACC-0027.spec.ts#CRM-ACC-0027
edge_cases:
  - id: SC-CAP-CRM-ENT-008-02
    given: A custom field archived while it holds values.
    when: Records are read and written.
    then: >-
      Its values vanish from retrieves, writes to it are rejected, and restoring
      the field brings them back.
    provenance: test
    source: >-
      react-console:acceptance/crm/playwright/journeys/contacts/CRM-ACC-0063.spec.ts#CRM-ACC-0063
constraints: []
depends_on: []
related:
  - CAP-CRM-ENT-001
  - CAP-CRM-ENT-002
  - CAP-CRM-ENT-004
journeys:
  - JNY-CRM-ENT-001
open_questions: []
feature: FEAT-CRM-ENT-01
concepts:
  - CON-CRM-ENT-001
  - CON-CRM-ENT-002
  - CON-CRM-ENT-033
evidence:
  - >-
    `services-crm` at `7ea48a414c42eade10d60753b8bcc323b1dd726f`: examined
    `services/persistence/contacts.ts`, `organizations.ts` (custom field values),
    `db/contact-custom-field-values.table.ts`, `db/organization-custom-field-values.table.ts`; tests
    `e2e/contacts-custom-fields.e2e.spec.js`, `e2e/organizations-custom-fields.e2e.spec.js`,
    `e2e/contact-roles.e2e.spec.js`,
    `integration/db/contact-custom-field-values.table.integration.spec.js`,
    `organization-custom-field-values.table.integration.spec.js`.
  - >-
    `react-console` at `da63075876b2c5f4a6af07d457bb24ea8bf4c0c7`: examined inventory
    RUL-CRM-CON-022, RUL-CRM-ORG-030, 031; journeys CRM-ACC-0027, 0028, 0063, 0138, 0139.
approved_by: Andrew
approved_at: 2026-10-22
controls: [TC-AZ-001, TC-AZ-002, TC-AZ-003, TC-AZ-004, TC-AZ-006, TC-DAT-001, TC-DAT-002, TC-DAT-003, TC-LOG-004, TC-LOG-005, TC-PI-001, TC-PI-003, TC-PRV-001, TC-PRV-002, TC-TEN-001, TC-TEN-002, TC-TEN-003]
narrative:
  status: verified
  review: NARRATIVE-REVIEW-2026-10-23
narrative_sources:
  - section: Why It Works This Way
    provenance: inferred
    source: >-
      services-crm:modules/entities/tests/e2e/contacts-custom-fields.e2e.spec.js#sets
      the key it sends, clears the key sent null and the selection sent empty,
      and leaves the others;
      services-crm:modules/entities/tests/e2e/contacts-custom-fields.e2e.spec.js#refuses
      a write to an archived field;
      services-crm:modules/entities/tests/e2e/contacts-custom-fields.e2e.spec.js#refuses
      yes for a yes/no field: no truthiness;
      services-crm:modules/entities/tests/e2e/contacts-custom-fields.e2e.spec.js#saves
      a replace of a contact created before the field was required, leaving the
      field out;
      services-crm:modules/entities/tests/e2e/contacts-custom-fields.e2e.spec.js#puts
      no custom field at the top level of the record;
      services-crm:modules/entities/tests/e2e/contact-roles.e2e.spec.js#refuses
      a create that carries custom_fields, as an unknown property;
      services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#D-04
    note: >-
      Operational rationale inferred from the cited behavior; no personal motive
      or historical decision is asserted.
repositories:
  - repo: services-crm
    role: owner
    paths:
      - modules/entities/
      - docs/milestones/CRM-M1-contacts-and-organizations.md
      - modules/entities/tests/e2e/contact-roles.e2e.spec.js
      - modules/entities/tests/e2e/contacts-custom-fields.e2e.spec.js
  - repo: react-console
    role: console
    paths:
      - 'app/(console)/console/[tenantId]/crm/contacts/'
      - acceptance/crm/playwright/journeys/contacts/CRM-ACC-0027.spec.ts
      - acceptance/crm/playwright/journeys/contacts/CRM-ACC-0063.spec.ts
exercised_by: [FAS-CRM-ENT-01-01, FAS-CRM-ENT-01-02, FAS-CRM-ENT-01-03, FAS-CRM-ENT-01-04, FAS-CRM-ENT-01-05]
suites: [TST-CRM-ENT-775, TST-CRM-ENT-786]
---

# Keep customer-defined information with records

## Purpose

Associations need to keep information that is specific to their work, such as professional specialization or an internal reference, beside the shared contact or organization identity. This capability persists those values using Platform's declared definitions, so CRM can support customer information without treating every customer-defined business term as a built-in record property.

## How It Works

A save names custom values in a map. Named keys are merged into existing values; omitted keys and an empty map preserve them. A null or blank value clears an individual key, while removing the entire map is not a supported clearing command. Reads return the readable values together in that map rather than as competing top-level properties.

Each writable key must match an active workspace definition for the record kind. Rhythm converts supported input to its declared type or refuses the field, including reference ownership, list options, numeric precision, and date-time form. Required values constrain creation and clearing without making an older record unsaveable merely because a definition later becomes required. Rich-text values use sanitized editing and rendering. Affiliations, roles, and affiliation types do not gain custom values through this capability.

## Example

An association defines a specialization field for contacts in Platform metadata. A coordinator saves Ruth's specialization and later changes only that named key while correcting her phone. Other stored custom values remain. Clearing the specialization explicitly differs from submitting an empty custom-value map, which leaves it alone; an unknown field name refuses the save rather than creating an undeclared field.

The association names its specialization field Professional Discipline and gives it declared options. Ruth's coordinator selects Structural Engineering, saves, then reads the custom-value map to check that the stored representation agrees with the option selected. Sending a misspelled field key refuses the aggregate instead of adding a second discipline property. If a later integration corrects a different named key, Professional Discipline remains. If administrators make another field required after Ruth was created, her unrelated phone correction can still save while that older value is absent; attempting to clear an already present required value is a different validation case. Definition maintenance belongs to Platform.

## Why It Works This Way

Our reading is that definitions supply the meaning of values while CRM owns their placement on actual records. Merging named keys avoids turning a partial integration payload into an accidental purge of unrelated customer information. Rejecting undeclared or wrong-kind keys prevents misspellings and foreign definitions from silently becoming business data.

Declared conversion keeps list choices, references, and numeric values usable across presentation and queries. Preserving older records that lack a newly required value separates forward validation from a retroactive repair requirement. Restricting custom values to supported parent records prevents a flexible data mechanism from expanding the affiliation and role model without an explicit product contract.

## Relationships

Platform metadata owns custom field definitions and the shared editing and rendering vocabulary. CRM contacts and organizations own the corresponding record values. Save corrections without overwriting another change provides aggregate rollback when conversion fails. Locate people and organizations returns readable values, and Platform query and record-view systems consume the declared model through their contracts.
