---
id: CAP-CRM-ENT-005
title: Maintain reliable contact points
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-D3'
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#M1-D8'
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A9'
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A12'
purpose: Staff maintain the contact points used to reach people and organizations.
outcomes:
  - >-
    Each contact has at most three e-mails with one primary that no other active
    contact holds.
  - >-
    Each contact and organization has at most one address and one phone number
    per tenant type, with one preferred.
  - >-
    A seasonal address is a recurring season and decides the mailing address
    while in season.
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.address-types.read
    where_checked: api
    scope: tenant
  - key: crm.phone-number-types.read
    where_checked: api
    scope: tenant
rules:
  - id: BR-CAP-CRM-ENT-005-01
    proved_by: [ATS-CRM-ENT-001-20, ATS-CRM-ENT-005-01, ATS-CRM-ENT-005-12, TST-CRM-ENT-774, TST-CRM-ENT-787]
    statement: >-
      Contacts store up to three ordered valid e-mail addresses, with the first
      as primary, canonical lowercase values without whitespace and blank
      entries removed.
    kind: validation
    enforced_at:
      - api
      - service
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#refuses
      a fourth e-mail address
    origin: none
    examples:
      positive: >-
        Accepted example: "Ada@Example.com " is stored as "ada@example.com" at
        position 1.
      negative: 'Rejected example: a fourth address is rejected.'
  - id: BR-CAP-CRM-ENT-005-02
    proved_by: [ATS-CRM-ENT-004-21, ATS-CRM-ENT-005-02, ATS-CRM-ENT-005-10, TST-CRM-ENT-774, TST-CRM-ENT-787]
    statement: >-
      Primary e-mail addresses are unique among active and duplicate contacts
      within a tenant; inactive or deceased contacts release them, reactivation
      conflicts with another holder, secondary addresses are not compared and
      deletion releases the address immediately.
    kind: invariant
    enforced_at:
      - service
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/db/contact-email-addresses.table.integration.spec.js#refuses
      to make an inactive contact active while another contact holds its primary
      address
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#M1-D1'
    examples:
      positive: >-
        Accepted example: another contact may take the primary address of a
        deceased contact.
      negative: >-
        Rejected example: a second active contact with "ADA@example.com" as
        primary is rejected 409.
  - id: BR-CAP-CRM-ENT-005-03
    proved_by: [ATS-CRM-ENT-002-14, ATS-CRM-ENT-005-03, ATS-CRM-ENT-005-13, TST-CRM-ENT-786, TST-CRM-ENT-787]
    statement: >-
      Each contact or organization phone number uses a tenant phone type, with
      one number per type and at most one preferred number; if none is selected,
      the earliest number in type order becomes preferred.
    kind: validation
    enforced_at:
      - api
      - service
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#refuses
      two numbers of the same type
    origin: none
  - id: BR-CAP-CRM-ENT-005-04
    proved_by: [ATS-CRM-ENT-005-04, TST-CRM-ENT-787]
    statement: >-
      Valid US phone numbers are normalized to national format, other numbers
      retain their input form and blank entries are discarded.
    kind: calculation
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#formats
      a valid US number, drops a blank one and marks the default preferred
      number
    origin: none
    examples:
      positive: 'Accepted example: "202-555-0143" is stored as "(202) 555-0143".'
      negative: 'Rejected example: "+44 20 7946 0958" is stored as typed.'
  - id: BR-CAP-CRM-ENT-005-05
    proved_by: [ATS-CRM-ENT-005-05, ATS-CRM-ENT-005-17, TST-CRM-ENT-786, TST-CRM-ENT-787]
    statement: >-
      Each address uses a tenant address type, with one per type and at most one
      preferred address; the earliest nonseasonal address in type order becomes
      preferred when needed and replaces a removed preferred address.
    kind: validation
    enforced_at:
      - api
      - service
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#marks
      the first address of the tenant’s display order as preferred, never one
      with a seasonal period
    origin: none
  - id: BR-CAP-CRM-ENT-005-06
    proved_by: [ATS-CRM-ENT-005-06, TST-CRM-ENT-786, TST-CRM-ENT-787]
    statement: >-
      Addresses default to US, normalize recognized country and state names to
      codes, reject unknown countries and restrict latitude to -90 through 90
      and longitude to -180 through 180.
    kind: validation
    enforced_at:
      - service
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.field-rules.integration.spec.js#refuses
      an unknown country, naming the address’s country
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#M1-D3'
  - id: BR-CAP-CRM-ENT-005-07
    proved_by: [ATS-CRM-ENT-005-07, ATS-CRM-ENT-005-16, ATS-CRM-ENT-018-11, TST-CRM-ENT-786, TST-CRM-ENT-787]
    statement: >-
      A contact cannot acquire or change to an organization-only address or
      phone type, but an existing element can retain a type later made
      organization-only.
    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#leaves
      a contact saveable after the type of an address it holds became
      organizations-only
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#M1-D8'
  - id: BR-CAP-CRM-ENT-005-08
    proved_by: [ATS-CRM-ENT-005-08, TST-CRM-ENT-774, TST-CRM-ENT-786, TST-CRM-ENT-788]
    statement: >-
      Seasonal addresses use inclusive recurring month-day boundaries that may
      cross December, reject full calendar dates and overlapping seasons, and
      treat February 29 as February 28 in non-leap years.
    kind: validation
    enforced_at:
      - api
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/unit/services/contacts/seasonal-addresses.spec.js#refuses
      overlapping seasons naming both addresses
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A12'
  - id: BR-CAP-CRM-ENT-005-09
    proved_by: [ATS-CRM-ENT-005-09, TST-CRM-ENT-786, TST-CRM-ENT-788]
    statement: >-
      A seasonal address cannot be the regular preferred address and supplies
      the mailing address only while in season on the workspace calendar and
      explicitly marked for seasonal use; otherwise the preferred address
      supplies it.
    kind: calculation
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers the
      seasonal address in season when the contact marks it, with the workspace
      date
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A12'
  - id: BR-CAP-CRM-ENT-005-10
    proved_by: [ATS-CRM-ENT-004-16, ATS-CRM-ENT-005-11, ATS-CRM-ENT-005-14, ATS-CRM-ENT-005-15, ATS-CRM-ENT-006-13]
    statement: >-
      Correcting an address or phone number retains its element identity;
      replacing the element requires removing it and adding another.
    kind: invariant
    enforced_at:
      - service
    must_prove: false
    provenance: documented
    source: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A9'
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A9'
scenarios:
  - id: SC-CAP-CRM-ENT-005-01
    given: 'Ruth Kim, deceased, retained ruth@example.org as primary.'
    when: Her son is created with that address as primary.
    then: The create succeeds.
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.field-rules.integration.spec.js#lets
      another contact take the primary address of a deceased contact
  - id: SC-CAP-CRM-ENT-005-02
    given: >-
      A contact with a home address and a winter address from 12-01 to 03-31
      marked for the season.
    when: The mailing address is read in January and in June.
    then: 'The winter address in January, the home address in June.'
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers the
      preferred address out of season
edge_cases:
  - id: SC-CAP-CRM-ENT-005-03
    given: Two creates racing for the same primary e-mail.
    when: Both commit.
    then: Exactly one wins and the other is rejected.
    provenance: test
    source: >-
      react-console:acceptance/crm/playwright/journeys/contacts/CRM-ACC-0017.spec.ts#CRM-ACC-0017
constraints: []
depends_on:
  - CAP-CRM-ENT-018
related:
  - CAP-CRM-ENT-001
  - CAP-CRM-ENT-004
  - CAP-CRM-ENT-006
  - CAP-CRM-ENT-018
journeys:
  - JNY-CRM-ENT-001
open_questions: []
feature: FEAT-CRM-ENT-03
concepts:
  - CON-CRM-ENT-001
  - CON-CRM-ENT-002
evidence:
  - >-
    `services-crm` at `7ea48a414c42eade10d60753b8bcc323b1dd726f`: examined
    `services/persistence/contacts.ts` (e-mail, phone, address problems),
    `services/contacts/seasonal-addresses.ts`, `services/persistence/organizations.ts`,
    `db/contact-email-addresses.table.ts`, `db/contact-phone-numbers.table.ts`,
    `db/contact-addresses.table.ts`, `db/organization-addresses.table.ts`,
    `db/organization-phone-numbers.table.ts`, `db/address-country-and-state.ts`,
    `db/phone-number-format.ts`; tests `e2e/contacts.e2e.spec.js`,
    `integration/services/persistence/contacts.integration.spec.js`,
    `contacts.field-rules.integration.spec.js`, `organizations.integration.spec.js`,
    `integration/db/contact-email-addresses.table.integration.spec.js`,
    `unit/services/contacts/seasonal-addresses.spec.js`.
  - >-
    `react-console` at `da63075876b2c5f4a6af07d457bb24ea8bf4c0c7`: examined inventory
    RUL-CRM-CON-015 to 019, 042, RUL-CRM-ORG-007 to 010; journeys CRM-ACC-0015 to 0022, 0067, 0069,
    0142, 0145.
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/integration/services/persistence/contacts.integration.spec.js#refuses
      a fourth e-mail address;
      services-crm:modules/entities/tests/integration/db/contact-email-addresses.table.integration.spec.js#refuses
      to make an inactive contact active while another contact holds its primary
      address;
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#refuses
      two numbers of the same type;
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#formats
      a valid US number, drops a blank one and marks the default preferred
      number;
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#marks
      the first address of the tenant’s display order as preferred, never one
      with a seasonal period;
      services-crm:modules/entities/tests/integration/services/persistence/contacts.field-rules.integration.spec.js#refuses
      an unknown country, naming the address’s country;
      services-crm:modules/entities/tests/integration/services/persistence/contacts.field-rules.integration.spec.js#leaves
      a contact saveable after the type of an address it holds became
      organizations-only;
      services-crm:modules/entities/tests/unit/services/contacts/seasonal-addresses.spec.js#refuses
      overlapping seasons naming both addresses;
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers the
      seasonal address in season when the contact marks it, with the workspace
      date;
      services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A9
    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.plan.md
      - modules/entities/tests/e2e/contacts.e2e.spec.js
      - >-
        modules/entities/tests/integration/db/contact-email-addresses.table.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/unit/services/contacts/seasonal-addresses.spec.js
  - repo: react-console
    role: console
    paths:
      - 'app/(console)/console/[tenantId]/crm/contacts/'
      - acceptance/crm/playwright/journeys/contacts/CRM-ACC-0017.spec.ts
exercised_by: [FAS-CRM-ENT-03-01, FAS-CRM-ENT-03-02, FAS-CRM-ENT-03-03, FAS-CRM-ENT-03-04, FAS-CRM-ENT-03-05]
suites: [TST-CRM-ENT-774, TST-CRM-ENT-786, TST-CRM-ENT-787, TST-CRM-ENT-788]
---

# Maintain reliable contact points

## Purpose

Staff need contact points whose purpose and preferred use are clear, rather than several indistinguishable strings. This capability organizes e-mail addresses, phone numbers, and addresses on people and organizations, validates their supported forms, and selects a mailing address without confusing a seasonal residence with the regular preferred address.

## How It Works

A contact saves ordered e-mail addresses, with the first serving as primary. Rhythm canonicalizes their values and enforces the primary-address constraint for the applicable contact statuses. Secondary slots are not substitutes for that uniqueness boundary, and a status change can expose a conflict even without editing the address itself.

Addresses and phones use workspace classifications, with a single entry for each type and a single preferred choice. Type order supplies the default preference when needed. Supported US phone and geographic input is normalized; other phone forms retain their entered representation. A contact cannot newly acquire an organization-only type, although a previously held entry can remain after administrators narrow its applicability.

Seasonal addresses describe recurring month-day windows, including windows spanning the new year. They cannot overlap and remain separate from regular preference. Mailing selection uses the workspace day and explicit seasonal-use choice. A correction retains the address or phone element's identity; a real replacement removes and adds an element instead.

## Example

A retired member has a preferred home address and a winter address from December through March. Staff mark seasonal use. In January the mailing-address read selects the winter location; outside that season it selects the regular home address. A coordinator corrects the winter street line using the same address identity, so the correction is not recorded as removal of one residence and addition of another.

For Daniel Ruiz, the association records the winter window as December 1 through March 31 each year, not as a stay in one named year. A March 31 mailing selection still uses the seasonal destination when seasonal use is marked; April 1 uses the regular preferred home. Adding a second seasonal destination that begins March 31 is refused because the windows share that day. The coordinator corrects the second boundary before saving. She also reads Daniel's ordered e-mails and preferred phone independently: neither the winter address nor the mailing-use choice changes which stored e-mail is primary or which phone is preferred.

## Why It Works This Way

Our reading is that one entry per type and one preferred choice keep downstream selection unambiguous. Catalogue order supplies a repeatable fallback instead of whichever row a database happens to return. Canonical primary e-mail values protect the uniqueness check from spelling and case differences, while status-sensitive release permits an address to move without deleting historical people.

Seasonal periods recur annually because the operational question is where to mail today, not whether an address belongs to one particular year. Nonoverlap avoids competing seasonal answers, and explicit use avoids assuming every stored seasonal address is currently wanted. Preserving held types after applicability changes keeps a catalogue edit from making unrelated record corrections impossible. Stable element identities preserve the distinction between correcting information and replacing its source.

## Relationships

Classification Types and CRM Settings supplies address and phone type order and applicability. Save corrections without overwriting another change owns aggregate mutation and element identity behavior. Share organization contact points with affiliated people adds source interpretation to addresses and phones. Communication choices are stored alongside these values but their downstream application belongs to the sender's contract.
