---
id: CAP-CRM-ENT-001
title: Establish a person’s CRM record
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#M1-D1'
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#M1-D2'
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#M1-D3'
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#M1-D4'
  - >-
    services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#PLAT-M10
    D3
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A8'
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A10'
purpose: >-
  Staff establish an identifiable record for a person and keep the information
  needed for later CRM work.
outcomes:
  - >-
    A new contact exists with its computed names, number and id, and every value
    entered was saved exactly once.
  - A rejected create writes nothing and names every problem.
  - A retried create never makes a second person.
actors:
  - ACT-CRM-001
  - ACT-CRM-003
  - ACT-CRM-004
surfaces:
  - console
  - api
preconditions:
  - The caller holds crm.contacts.create in the tenant of the path.
postconditions:
  - >-
    After completion, the contact can be retrieved with the values it was
    created with, and its number is never reused.
permissions:
  - key: crm.contacts.create
    where_checked: api
    scope: tenant
  - key: crm.contacts.create-despite-duplicate-block
    where_checked: api
    scope: tenant
  - key: crm.contact-types.read
    where_checked: api
    scope: tenant
rules:
  - id: BR-CAP-CRM-ENT-001-01
    proved_by: [ATS-CRM-ENT-001-01, ATS-CRM-ENT-001-20, ATS-CRM-ENT-001-21, ATS-CRM-ENT-005-12, ATS-CRM-ENT-007-06, ATS-CRM-ENT-008-08, TST-CRM-ENT-786]
    statement: >-
      A contact requires a nonblank first or last name; other name components do
      not satisfy this requirement.
    kind: validation
    enforced_at:
      - api
      - service
      - database
      - console
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts-names.e2e.spec.js#answers
      400 for a contact with neither a first nor a last name, naming the rule
    origin: none
    examples:
      positive: >-
        Accepted example: last name "Hopper" alone is saved and sorted as
        "Hopper".
      negative: >-
        Rejected example: first name of spaces and no last name is rejected 400
        naming first_name.
  - id: BR-CAP-CRM-ENT-001-02
    proved_by: [ATS-CRM-ENT-001-02, ATS-CRM-ENT-001-18, ATS-CRM-ENT-001-19, ATS-CRM-ENT-003-13, ATS-CRM-ENT-004-15, TST-CRM-ENT-786]
    statement: >-
      Each contact write derives its display name from first name, middle
      initial, quoted nickname, last name, suffix and credentials, adds the
      prefix for the full name, and builds the sort name from last name followed
      by the given names and nickname without a trailing comma.
    kind: calculation
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts-names.e2e.spec.js#builds
      the full name from every part in order, trimmed and singly spaced
    origin: none
    examples:
      positive: >-
        Accepted example: dr./Grace/Brewster/Amazing/Hopper/Jr/PhD gives "Grace
        B. "Amazing" Hopper Jr, PhD".
      negative: >-
        Rejected example: a last name only gives the sort name "Solo", never
        "Solo,".
  - id: BR-CAP-CRM-ENT-001-03
    proved_by: [ATS-CRM-ENT-001-03, TST-CRM-ENT-786]
    statement: >-
      Contact creation rejects supplied computed names and never extracts name
      components from a full name.
    kind: validation
    enforced_at:
      - api
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts-names.e2e.spec.js#answers
      400 for a full name sent instead of name parts, and never splits it into
      parts
    origin: none
  - id: BR-CAP-CRM-ENT-001-04
    proved_by: [ATS-CRM-ENT-001-04, TST-CRM-ENT-774, TST-CRM-ENT-787]
    statement: >-
      Saving moves an honorific salutation into the prefix, standardizes
      recognized prefixes and removes a nickname that repeats the first name;
      Review Changes identifies those adjustments before the console saves.
    kind: calculation
    enforced_at:
      - service
      - console
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#moves
      an honorific salutation to the prefix, start-cased and dotted (RB-2367)
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#M1-D4'
    examples:
      positive: >-
        Accepted example: salutation "mr" is stored as prefix "Mr." with no
        salutation.
      negative: 'Rejected example: salutation "Dear colleague" is preserved as typed.'
  - id: BR-CAP-CRM-ENT-001-05
    proved_by: [ATS-CRM-ENT-001-05, TST-CRM-ENT-774, TST-CRM-ENT-787]
    statement: >-
      The service limits prefix, first, middle and last names, suffix and
      nickname to 200 characters, notes to 10,000 and e-mail addresses to 254,
      while credentials, salutation and maiden name have no equivalent service
      limit.
    kind: validation
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.field-rules.integration.spec.js#names
      every overlong name part of one request at once
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#M1-D3'
    examples:
      positive: 'Accepted example: a first name of exactly 200 characters is accepted.'
      negative: 'Rejected example: notes of 10,001 characters are rejected naming notes.'
  - id: BR-CAP-CRM-ENT-001-06
    proved_by: [ATS-CRM-ENT-001-06, TST-CRM-ENT-786, TST-CRM-ENT-787]
    statement: >-
      A valid client-supplied contact id is retained if unused in the tenant;
      otherwise creation generates a time-ordered UUID, and another tenant may
      use the same id independently.
    kind: validation
    enforced_at:
      - api
      - service
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#keeps the id
      the body named, which is no UUIDv7
    origin: none
    examples:
      positive: 'Accepted example: a create naming no id returns a UUIDv7 id.'
      negative: >-
        Rejected example: a create naming an id the tenant already uses is
        rejected 400.
  - id: BR-CAP-CRM-ENT-001-07
    proved_by: [ATS-CRM-ENT-001-07, TST-CRM-ENT-787]
    statement: >-
      Contact numbers come from the tenant’s contact counter, cannot be selected
      by a client or reused, may have gaps after refusal, and recover collisions
      by taking one more than the tenant’s highest contact number.
    kind: calculation
    enforced_at:
      - service
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#keeps
      the number consumed when the transaction rolls back: a gap, never given
      back
    origin: none
    examples:
      positive: 'Accepted example: two creates in a row answer consecutive numbers.'
      negative: >-
        Rejected example: a body naming number 5 returns the tenant’s next
        number.
  - id: BR-CAP-CRM-ENT-001-08
    proved_by: [ATS-CRM-ENT-001-08, ATS-CRM-ENT-001-17, TST-CRM-ENT-787]
    statement: >-
      Repeating a contact create with the same UUIDv7 id and request returns the
      original contact, while reuse of that id for a different request produces
      a conflict.
    kind: policy
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/contacts/contact-create.integration.spec.js#answers
      the retry with the contact the first attempt made
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A8'
  - id: BR-CAP-CRM-ENT-001-09
    proved_by: [ATS-CRM-ENT-001-09, ATS-CRM-ENT-001-22, ATS-CRM-ENT-018-09, TST-CRM-ENT-786]
    statement: >-
      Contacts may have no type, but a supplied type must belong to the tenant
      regardless of its active flag; an unknown type causes a conflict without
      saving the contact.
    kind: validation
    enforced_at:
      - api
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers 409
      for a type the tenant does not have, and saves no contact
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#M1-D2'
    examples:
      positive: >-
        Accepted example: a contact with no type is saved and listed as "No
        type".
      negative: 'Rejected example: a type id the tenant lacks is rejected 409.'
  - id: BR-CAP-CRM-ENT-001-10
    proved_by: [ATS-CRM-ENT-001-10, ATS-CRM-ENT-001-16, ATS-CRM-ENT-004-21, TST-CRM-ENT-787]
    statement: >-
      Contact status defaults to active and accepts only active, inactive,
      deceased or duplicate; archive is a separate lifecycle action.
    kind: validation
    enforced_at:
      - api
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/db/contacts.table.integration.spec.js#refuses
      a status outside the closed list by contacts_status_check
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#M1-D1'
    examples:
      positive: 'Accepted example: status "deceased" is saved.'
      negative: 'Rejected example: status "archived" is rejected 400.'
  - id: BR-CAP-CRM-ENT-001-11
    proved_by: [ATS-CRM-ENT-001-11, TST-CRM-ENT-786, TST-CRM-ENT-787]
    statement: >-
      A spouse link must identify another contact in the tenant who is not
      already a third contact’s spouse, is written reciprocally, and is removed
      from both sides when either link is cleared or either contact is deleted.
    kind: invariant
    enforced_at:
      - service
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.spouse.integration.spec.js#links
      the other spouse back when a contact is created naming it
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A10'
    examples:
      positive: >-
        Accepted example: a names B as spouse; B retrieves A as spouse with its
        version advanced once.
      negative: 'Rejected example: a spouse of another tenant is rejected 409.'
  - id: BR-CAP-CRM-ENT-001-12
    proved_by: [ATS-CRM-ENT-001-12, ATS-CRM-ENT-001-14, TST-CRM-ENT-774]
    statement: >-
      A blocked contact create requires duplicate-block override authority and a
      reason, and a confirmation flag alone cannot authorize it.
    kind: policy
    enforced_at:
      - api
      - service
    must_prove: true
    provenance: test
    source: >-
      react-console:acceptance/crm/playwright/journeys/contacts/CRM-ACC-0042.spec.ts#CRM-ACC-0042
    origin: >-
      D-32 (cited in
      services-crm:aws/crm.service.ts:duplicateBlockOverrideAction)
  - id: BR-CAP-CRM-ENT-001-13
    proved_by: [ATS-CRM-ENT-001-13, TST-CRM-ENT-786]
    statement: >-
      A contact photo is made permanent from a tenant upload, an existing tenant
      file or a public web image before creation writes the contact; failure
      rejects creation and identifies the photo field.
    kind: validation
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers 400
      for a photo whose upload is not there, naming the field that failed
    origin: >-
      services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#PLAT-M10
      D3
scenarios:
  - id: SC-CAP-CRM-ENT-001-01
    given: A tenant with a member contact type and two custom fields.
    when: >-
      A staff member creates a member with names, three e-mails, two phones, two
      addresses and opt-outs.
    then: >-
      The 360 and an API read agree on every value, with the computed names and
      the server number.
    provenance: test
    source: >-
      react-console:acceptance/crm/playwright/journeys/contacts/CRM-ACC-0001.spec.ts#CRM-ACC-0001
  - id: SC-CAP-CRM-ENT-001-02
    given: A request whose create timed out on the network.
    when: The client retries with the same id.
    then: >-
      It receives the contact the first attempt made and no second contact
      exists.
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/contacts/contact-create.integration.spec.js#makes
      no second contact
  - id: SC-CAP-CRM-ENT-001-03
    given: A contact type deactivated while a staff member is choosing it.
    when: The staff member saves.
    then: >-
      The contact is created with the now-inactive type and the chooser hides it
      on the next visit.
    provenance: test
    source: >-
      react-console:acceptance/crm/playwright/journeys/contacts/CRM-ACC-0065.spec.ts#CRM-ACC-0065
edge_cases:
  - id: SC-CAP-CRM-ENT-001-04
    given: Two racing creates that meet a contact number another row holds.
    when: Both commit.
    then: >-
      Each takes a distinct number after the highest and no number is retained
      twice.
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#leaves
      no number held twice
constraints: []
depends_on:
  - CAP-CRM-ENT-018
related:
  - CAP-CRM-ENT-003
  - CAP-CRM-ENT-004
  - CAP-CRM-ENT-005
  - CAP-CRM-ENT-018
journeys:
  - JNY-CRM-ENT-001
open_questions: []
feature: FEAT-CRM-ENT-01
concepts:
  - CON-CRM-ENT-001
evidence:
  - >-
    `services-crm` at `7ea48a414c42eade10d60753b8bcc323b1dd726f`: examined
    `modules/entities/aws/api/contacts.routes.ts`, `contact-api-mapper.ts`, `contacts.schemas.ts`,
    `services/persistence/contacts.ts` (names, limits, spouse, create),
    `services/contacts/contact-create.ts`, `db/contacts.table.ts`, `db/field-limits.ts`,
    `db/managed-assets.table.ts` (the photo's file record); tests `e2e/contacts.e2e.spec.js`,
    `e2e/contacts-names.e2e.spec.js`,
    `integration/services/persistence/contacts.integration.spec.js`,
    `contacts.field-rules.integration.spec.js`, `contacts.spouse.integration.spec.js`,
    `integration/services/contacts/contact-create.integration.spec.js`; `docs/milestones/CRM-M1-*`.
  - >-
    `react-console` at `da63075876b2c5f4a6af07d457bb24ea8bf4c0c7`: examined
    `acceptance/crm/inventory/business-rules.json` (RUL-CRM-CON-001 to 012, 028, 035, 037, 045,
    047), journeys CRM-ACC-0001, 0002, 0005, 0012, 0030, 0032, 0033, 0053, 0065, 0069, 0070 and
    their Playwright specs.
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-002, 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-names.e2e.spec.js#answers
      400 for a contact with neither a first nor a last name, naming the rule;
      services-crm:modules/entities/tests/e2e/contacts-names.e2e.spec.js#builds
      the full name from every part in order, trimmed and singly spaced;
      services-crm:modules/entities/tests/e2e/contacts-names.e2e.spec.js#answers
      400 for a full name sent instead of name parts, and never splits it into
      parts;
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#moves
      an honorific salutation to the prefix, start-cased and dotted (RB-2367);
      services-crm:modules/entities/tests/integration/services/persistence/contacts.field-rules.integration.spec.js#names
      every overlong name part of one request at once;
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#keeps the id
      the body named, which is no UUIDv7;
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#keeps
      the number consumed when the transaction rolls back: a gap, never given
      back;
      services-crm:modules/entities/tests/integration/services/contacts/contact-create.integration.spec.js#answers
      the retry with the contact the first attempt made;
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers 409
      for a type the tenant does not have, and saves no contact;
      services-crm:modules/entities/tests/integration/db/contacts.table.integration.spec.js#refuses
      a status outside the closed list by contacts_status_check;
      services-crm:modules/entities/tests/integration/services/persistence/contacts.spouse.integration.spec.js#links
      the other spouse back when a contact is created naming it;
      react-console:acceptance/crm/playwright/journeys/contacts/CRM-ACC-0042.spec.ts#CRM-ACC-0042;
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers 400
      for a photo whose upload is not there, naming the field that failed
    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/
      - modules/entities/tests/e2e/contacts-names.e2e.spec.js
      - modules/entities/tests/e2e/contacts.e2e.spec.js
      - modules/entities/tests/integration/db/contacts.table.integration.spec.js
      - >-
        modules/entities/tests/integration/services/contacts/contact-create.integration.spec.js
      - >-
        modules/entities/tests/integration/services/persistence/contacts.field-rules.integration.spec.js
      - >-
        modules/entities/tests/integration/services/persistence/contacts.integration.spec.js
      - >-
        modules/entities/tests/integration/services/persistence/contacts.spouse.integration.spec.js
  - repo: react-console
    role: console
    paths:
      - 'app/(console)/console/[tenantId]/crm/contacts/'
      - acceptance/crm/playwright/journeys/contacts/CRM-ACC-0001.spec.ts
      - acceptance/crm/playwright/journeys/contacts/CRM-ACC-0042.spec.ts
      - acceptance/crm/playwright/journeys/contacts/CRM-ACC-0065.spec.ts
exercised_by: [FAS-CRM-ENT-01-01, FAS-CRM-ENT-01-02, FAS-CRM-ENT-01-03, FAS-CRM-ENT-01-05, FAS-CRM-ENT-06-02]
suites: [TST-CRM-ENT-774, TST-CRM-ENT-786, TST-CRM-ENT-787]
---

# Establish a person’s CRM record

## Purpose

A coordinator needs a recognizable person record before recording membership work, communications, or organizational responsibilities against that person. This capability establishes that identity from entered information, keeps the person's owned details together, and gives later staff work a record to find rather than a loose name or e-mail address.

## How It Works

Staff enter name components, optionally choose a contact type, and supply the other information appropriate to the person. Rhythm constructs display and sort names from those components and normalizes recognized honorifics and repeated nicknames. A first or last name anchors identification; entering only a computed full name does not substitute for the components.

The service validates the workspace's references and information, assigns a contact number, and writes the aggregate. Creation can retain an unused client identity. The retry-safe time-ordered identity path compares repeated creation requests with the original request so a network retry can recover the created person rather than creating a second one. Numbers are allocated separately and gaps can remain after a refused transaction.

Status describes the person independently of archive. A supplied spouse reference is reciprocal, so creation can also change the other person's record and version. Photos must be made permanent before the person write succeeds. Duplicate-block handling remains a boundary with Data Quality, including its separately authorized override.

## Example

A membership coordinator enters Ruth Kim with a member contact type, work phone, two e-mail addresses, and a home address. Rhythm returns the derived name and assigned contact number with the saved collections. The coordinator opens the contact page to inspect them. If the create response is lost, an integration using the supported repeatable identity and identical request can recover the same record.

Ruth's coordinator also checks the refusal paths before relying on the identity. A request containing only credentials has not supplied the minimum person name, and an unknown Member type does not silently create an untyped person. She corrects the named problem and retries. A successful read then shows the name Rhythm derives, not a full-name string the caller independently calculates. If Ruth's primary e-mail conflicts, the duplicate override path does not release that uniqueness constraint. The coordinator must resolve the actual conflicting input rather than assume every refusal is a warning that can be confirmed.

## Why It Works This Way

Our reading is that structured names preserve enough information to display and sort a person consistently; accepting a free-form computed name would make those views disagree. Minimum identification and field limits reject unusable input before it becomes a shared reference. Separate server numbers supply familiar identifiers without making them safe retry keys or gap-free counts.

The creation digest protects against interpreting a different request as an innocent retry. Workspace-scoped type and spouse checks keep the identity meaningful within the customer's records, and reciprocal spouse updates prevent one-sided claims. Photo preparation avoids storing a record whose selected image cannot be saved. Duplicate override authority prevents a mere confirmation from bypassing a stronger creation refusal; it does not replace primary-e-mail uniqueness.

## Relationships

Maintain reliable contact points and Capture a person’s communication choices own the details saved with the person. Keep customer-defined information with records uses Platform metadata definitions for additional values. Locate people and organizations supplies subsequent discovery. Affiliations and Roles owns organization ties. Platform supplies files and security, while Data Quality owns duplicate policy and merge outcomes.
