---
id: CAP-CRM-ENT-009
title: Maintain a person’s organizational relationships
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-M2-affiliations-roles-compliance.md#D-09'
  - 'services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.md#M2-D5'
  - 'services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.md#M2-D8'
  - >-
    services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.md#M2-D8
    placement
  - 'services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.md#M2-D9'
  - >-
    services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.plan.md#A15
  - >-
    services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.plan.md#A18
  - >-
    services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.plan.md#A20
  - >-
    services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.plan.md#A21
purpose: >-
  Staff keep a person’s organizational memberships, employment and primary
  organization understandable over time.
outcomes:
  - >-
    A person’s affiliations are history, not overwrites: ending a affiliation
    preserves it, changing employer never leaves two primaries.
  - The organization's people list and the person’s affiliations agree.
  - A save that leaves out the affiliations never erases them.
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.contacts.read
    where_checked: api
    scope: tenant
  - key: crm.affiliation-types.read
    where_checked: api
    scope: tenant
  - key: crm.organizations.read
    where_checked: api
    scope: tenant
rules:
  - id: BR-CAP-CRM-ENT-009-01
    proved_by: [ATS-CRM-ENT-009-01, TST-CRM-ENT-786]
    statement: >-
      Affiliations require an organization and affiliation type belonging to the
      tenant; missing, foreign or unknown references reject the complete save.
    kind: validation
    enforced_at:
      - api
      - service
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contact-affiliations.e2e.spec.js#answers
      400 for an organization the tenant does not have
    origin: none
  - id: BR-CAP-CRM-ENT-009-02
    proved_by: [ATS-CRM-ENT-004-18, ATS-CRM-ENT-006-14, ATS-CRM-ENT-009-02, ATS-CRM-ENT-009-17, ATS-CRM-ENT-009-19, ATS-CRM-ENT-009-20, ATS-CRM-ENT-011-12, ATS-CRM-ENT-012-20, TST-CRM-ENT-776]
    statement: >-
      Affiliations and their role periods are saved transactionally through the
      contact; organization-side people lists expose reads and write additions
      through the person’s record.
    kind: invariant
    enforced_at:
      - api
      - service
    must_prove: true
    provenance: test
    source: >-
      react-console:acceptance/crm/playwright/journeys/affiliations/CRM-ACC-0202.spec.ts#CRM-ACC-0202
    origin: none
  - id: BR-CAP-CRM-ENT-009-03
    proved_by: [ATS-CRM-ENT-009-03, ATS-CRM-ENT-009-24, TST-CRM-ENT-776, TST-CRM-ENT-788]
    statement: >-
      Omitting affiliations preserves them even on replacement, while an
      explicit list replaces the collection and an idless row matching
      organization, type and start date reuses the existing affiliation.
    kind: policy
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      react-console:acceptance/crm/playwright/journeys/affiliations/CRM-ACC-0177.spec.ts#CRM-ACC-0177
    origin: 'services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.md#D-09'
  - id: BR-CAP-CRM-ENT-009-04
    proved_by: [ATS-CRM-ENT-003-15, ATS-CRM-ENT-009-04, ATS-CRM-ENT-009-18, TST-CRM-ENT-786, TST-CRM-ENT-788]
    statement: >-
      Selecting a different primary affiliation clears the previous primary in
      the same save, while selecting two new primaries is rejected.
    kind: invariant
    enforced_at:
      - service
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/unit/services/persistence/contact-affiliations.spec.js#refuses
      two affiliations newly marked primary at once
    origin: none
  - id: BR-CAP-CRM-ENT-009-05
    proved_by: [ATS-CRM-ENT-009-05, TST-CRM-ENT-776, TST-CRM-ENT-786, TST-CRM-ENT-788]
    statement: >-
      A primary affiliation must have started by the workspace’s current day and
      have no end date.
    kind: validation
    enforced_at:
      - service
      - database
      - console
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/unit/services/persistence/contact-affiliations.spec.js#refuses
      a primary affiliation that starts after today
    origin: none
  - id: BR-CAP-CRM-ENT-009-06
    proved_by: [ATS-CRM-ENT-009-06, ATS-CRM-ENT-009-22, ATS-CRM-ENT-011-13, TST-CRM-ENT-776, TST-CRM-ENT-787, TST-CRM-ENT-788]
    statement: >-
      Ending a primary affiliation removes its primary designation without
      selecting a replacement.
    kind: lifecycle
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contact-affiliations.integration.spec.js#makes
      nothing primary in its place
    origin: none
  - id: BR-CAP-CRM-ENT-009-07
    proved_by: [ATS-CRM-ENT-009-07, ATS-CRM-ENT-009-21, ATS-CRM-ENT-009-24, ATS-CRM-ENT-010-10, TST-CRM-ENT-776, TST-CRM-ENT-787]
    statement: >-
      Affiliation periods include their end day, allow a one-day stay and reject
      an end before the start.
    kind: validation
    enforced_at:
      - service
      - database
      - console
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#refuses
      an affiliation that ends before it starts
    origin: none
  - id: BR-CAP-CRM-ENT-009-08
    proved_by: [ATS-CRM-ENT-009-08, TST-CRM-ENT-776, TST-CRM-ENT-786, TST-CRM-ENT-788]
    statement: >-
      One contact’s affiliations of the same type to the same organization
      cannot overlap, including touching boundary days and undated periods;
      separate periods and different types are permitted.
    kind: invariant
    enforced_at:
      - service
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/unit/services/persistence/contact-affiliations.spec.js#finds
      periods that share their last and first day overlapping
    origin: none
  - id: BR-CAP-CRM-ENT-009-09
    proved_by: [ATS-CRM-ENT-009-09, TST-CRM-ENT-776, TST-CRM-ENT-787]
    statement: >-
      An affiliation cannot change its organization; the editor supports ending
      it and adding a new affiliation instead.
    kind: invariant
    enforced_at:
      - service
      - console
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#refuses
      to move the affiliation to another organization under the same id
      (docs/affiliations/README.md §0b.2)
    origin: >-
      services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.plan.md#A15
  - id: BR-CAP-CRM-ENT-009-10
    proved_by: [ATS-CRM-ENT-009-10, TST-CRM-ENT-788]
    statement: >-
      A contact may have 100 current or upcoming affiliations, with no limit on
      ended history.
    kind: validation
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/unit/services/persistence/contact-affiliations.spec.js#counts
      only current and upcoming affiliations against the limit: ended history is
      unlimited (M2-D5)
    origin: 'services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.md#M2-D5'
  - id: BR-CAP-CRM-ENT-009-11
    proved_by: [ATS-CRM-ENT-009-11, ATS-CRM-ENT-009-23, ATS-CRM-ENT-017-13, TST-CRM-ENT-786]
    statement: >-
      The Organization command selects an effective open tie or creates one
      using the default type and contact title, applies a nonfuture effective
      date defaulting to workspace today, ends the previous primary the
      preceding day unless retained, removes a zero-day prior stay and clears
      primary designation when cleared.
    kind: calculation
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/unit/services/persistence/contact-affiliations.spec.js#ends
      the previous primary the day before the effective date by default, and its
      open roles with it
    origin: none
  - id: BR-CAP-CRM-ENT-009-12
    proved_by: [ATS-CRM-ENT-009-12]
    statement: >-
      Adding a primary affiliation over a different current primary asks whether
      the person left; Yes ends the old affiliation before the new start but not
      before its own start, and Make Primary on an existing affiliation does not
      ask.
    kind: policy
    enforced_at:
      - console
    must_prove: true
    provenance: observed
    source: 'react-console:lib/affiliations/section.ts:affiliationBeingLeft'
    origin: >-
      services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.md#M2-D8
      placement
  - id: BR-CAP-CRM-ENT-009-13
    proved_by: [ATS-CRM-ENT-009-13, TST-CRM-ENT-774, TST-CRM-ENT-788]
    statement: >-
      The contact and primary affiliation share the same job title in either
      direction, a titleless primary inherits the contact title and historical
      affiliations retain their ending titles.
    kind: calculation
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/unit/services/persistence/contact-affiliations.spec.js#gives
      the contact the primary affiliation's job title when the save changed only
      the affiliation's
    origin: none
  - id: BR-CAP-CRM-ENT-009-14
    proved_by: [ATS-CRM-ENT-009-14, TST-CRM-ENT-776, TST-CRM-ENT-786, TST-CRM-ENT-787]
    statement: >-
      Client affiliation ids must be unused lowercase UUIDv7 values appearing
      once in the save, and existing role ids must belong to the affiliation
      under which they are submitted.
    kind: validation
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contact-information-sources.e2e.spec.js#refuses
      400 a second contact naming the same affiliation id, saying another
      affiliation has it
    origin: none
  - id: BR-CAP-CRM-ENT-009-15
    proved_by: [ATS-CRM-ENT-009-15, ATS-CRM-ENT-009-25, ATS-CRM-ENT-013-14, TST-CRM-ENT-776, TST-CRM-ENT-786, TST-CRM-ENT-788]
    statement: >-
      Organization people lists default to current affiliations, support past,
      upcoming, all and as-of filters plus type and role, return pages and total
      head count with separate rows for separate ties, return an empty page for
      an unknown organization and require contact read permission.
    kind: permission
    enforced_at:
      - api
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contact-affiliations.e2e.spec.js#answers
      403 permission_missing to a caller who may read organizations but not
      contacts
    origin: >-
      services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.plan.md#A21
  - id: BR-CAP-CRM-ENT-009-16
    proved_by: []
    statement: >-
      Add Person on an organization preselects the workspace default affiliation
      type and initially selects Primary when the person has no current primary
      affiliation.
    kind: policy
    enforced_at:
      - console
    must_prove: false
    provenance: observed
    source: >-
      react-console:components/console/organization/affiliation-dialogs.tsx:ContactPrimaryInfo
    origin: 'services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.md#M2-D9'
  - id: BR-CAP-CRM-ENT-009-17
    proved_by: []
    statement: Blank affiliation titles and notes are saved as absent values.
    kind: calculation
    enforced_at:
      - api
      - service
    must_prove: false
    provenance: test
    source: >-
      react-console:acceptance/crm/playwright/journeys/affiliations/CRM-ACC-0172.spec.ts#CRM-ACC-0172
    origin: >-
      services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.plan.md#A18
scenarios:
  - id: SC-CAP-CRM-ENT-009-01
    given: Lena is primary at Harbor Society.
    when: >-
      The staff member adds Bayview Guild as a new primary and returns "Yes, she
      left".
    then: >-
      Bayview is her Organization, Harbor lists her under Past and her roles
      there ended.
    provenance: test
    source: >-
      react-console:acceptance/crm/playwright/journeys/affiliations/CRM-ACC-0201.spec.ts#CRM-ACC-0201
  - id: SC-CAP-CRM-ENT-009-02
    given: A member primary at Acme and current at Globex.
    when: Make Primary on Globex.
    then: >-
      Globex is the Organization and Acme stays current, not primary, in one
      revision.
    provenance: test
    source: >-
      react-console:acceptance/crm/playwright/journeys/affiliations/CRM-ACC-0175.spec.ts#CRM-ACC-0175
edge_cases:
  - id: SC-CAP-CRM-ENT-009-03
    given: Two staff members change the same person’s primary at once.
    when: Both save.
    then: >-
      One primary remains, the winner's, and the other is told the record
      changed.
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contact-affiliations.integration.spec.js#leaves
      at most one primary affiliation, the winner’s
constraints: []
depends_on:
  - CAP-CRM-ENT-001
  - CAP-CRM-ENT-002
  - CAP-CRM-ENT-017
related:
  - CAP-CRM-ENT-003
  - CAP-CRM-ENT-006
  - CAP-CRM-ENT-010
  - CAP-CRM-ENT-011
  - CAP-CRM-ENT-017
journeys:
  - JNY-CRM-ENT-002
  - JNY-CRM-ENT-003
  - JNY-CRM-ENT-004
  - JNY-CRM-ENT-005
open_questions: []
feature: FEAT-CRM-ENT-04
concepts:
  - CON-CRM-ENT-001
  - CON-CRM-ENT-002
  - CON-CRM-ENT-003
evidence:
  - >-
    `services-crm` at `7ea48a414c42eade10d60753b8bcc323b1dd726f`: examined
    `services/persistence/contact-affiliations.ts`, `services/persistence/contacts.ts`,
    `aws/api/organizations.routes.ts` (people list), `organization-api-mapper.ts`,
    `db/contact-organization-affiliations.table.ts`, `docs/affiliations/README.md` §3 to §6,
    `docs/entities-api.md` ("A contact's organization affiliations", "An organization's
    affiliations"); tests `e2e/contact-affiliations.e2e.spec.js`,
    `unit/services/persistence/contact-affiliations.spec.js`,
    `integration/services/persistence/contact-affiliations.integration.spec.js`,
    `contact-affiliations.queries.integration.spec.js`, `contacts.integration.spec.js`,
    `unit/api/organization-affiliation-page-mapper.spec.js`.
  - >-
    `react-console` at `da63075876b2c5f4a6af07d457bb24ea8bf4c0c7`: examined
    `components/forms/contacts/affiliation-components.tsx`,
    `components/affiliations/affiliation-editor.tsx`, `organization-affiliations-section.tsx`,
    `components/record-views/cards/contact/affiliations-card.tsx`,
    `components/console/organization/affiliation-dialogs.tsx` (outside the registry scope,
    FND-CRM-ENT-001); inventory RUL-CRM-AFF-001 to 008, 010 to 018, 022 to 025, 031, 032, 052, 053,
    RUL-CRM-ORG-070; journeys CRM-ACC-0170 to 0181, 0187, 0193, 0201, 0202, 0205 to 0208, 0213.
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-PI-005, 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/contact-affiliations.e2e.spec.js#answers
      400 for an organization the tenant does not have;
      react-console:acceptance/crm/playwright/journeys/affiliations/CRM-ACC-0202.spec.ts#CRM-ACC-0202;
      react-console:acceptance/crm/playwright/journeys/affiliations/CRM-ACC-0177.spec.ts#CRM-ACC-0177;
      services-crm:modules/entities/tests/unit/services/persistence/contact-affiliations.spec.js#refuses
      two affiliations newly marked primary at once;
      services-crm:modules/entities/tests/unit/services/persistence/contact-affiliations.spec.js#refuses
      a primary affiliation that starts after today;
      services-crm:modules/entities/tests/integration/services/persistence/contact-affiliations.integration.spec.js#makes
      nothing primary in its place;
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#refuses
      an affiliation that ends before it starts;
      services-crm:modules/entities/tests/unit/services/persistence/contact-affiliations.spec.js#finds
      periods that share their last and first day overlapping;
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#refuses
      to move the affiliation to another organization under the same id
      (docs/affiliations/README.md §0b.2);
      services-crm:modules/entities/tests/unit/services/persistence/contact-affiliations.spec.js#counts
      only current and upcoming affiliations against the limit: ended history is
      unlimited (M2-D5);
      services-crm:modules/entities/tests/unit/services/persistence/contact-affiliations.spec.js#ends
      the previous primary the day before the effective date by default, and its
      open roles with it;
      react-console:lib/affiliations/section.ts:affiliationBeingLeft;
      services-crm:modules/entities/tests/unit/services/persistence/contact-affiliations.spec.js#gives
      the contact the primary affiliation's job title when the save changed only
      the affiliation's;
      services-crm:modules/entities/tests/e2e/contact-information-sources.e2e.spec.js#refuses
      400 a second contact naming the same affiliation id, saying another
      affiliation has it;
      services-crm:modules/entities/tests/e2e/contact-affiliations.e2e.spec.js#answers
      403 permission_missing to a caller who may read organizations but not
      contacts;
      react-console:components/console/organization/affiliation-dialogs.tsx:ContactPrimaryInfo;
      react-console:acceptance/crm/playwright/journeys/affiliations/CRM-ACC-0172.spec.ts#CRM-ACC-0172
    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/contact-affiliations.e2e.spec.js
      - modules/entities/tests/e2e/contact-information-sources.e2e.spec.js
      - >-
        modules/entities/tests/integration/services/persistence/contact-affiliations.integration.spec.js
      - >-
        modules/entities/tests/integration/services/persistence/contacts.integration.spec.js
      - >-
        modules/entities/tests/unit/services/persistence/contact-affiliations.spec.js
  - repo: react-console
    role: console
    paths:
      - 'app/(console)/console/[tenantId]/crm/contacts/'
      - acceptance/crm/playwright/journeys/affiliations/CRM-ACC-0172.spec.ts
      - acceptance/crm/playwright/journeys/affiliations/CRM-ACC-0175.spec.ts
      - acceptance/crm/playwright/journeys/affiliations/CRM-ACC-0177.spec.ts
      - acceptance/crm/playwright/journeys/affiliations/CRM-ACC-0201.spec.ts
      - acceptance/crm/playwright/journeys/affiliations/CRM-ACC-0202.spec.ts
      - components/console/organization/affiliation-dialogs.tsx
      - lib/affiliations/section.ts
exercised_by: [FAS-CRM-ENT-01-04, FAS-CRM-ENT-02-02, FAS-CRM-ENT-02-03, FAS-CRM-ENT-02-04, FAS-CRM-ENT-03-05, FAS-CRM-ENT-04-01, FAS-CRM-ENT-04-02, FAS-CRM-ENT-04-03, FAS-CRM-ENT-04-04, FAS-CRM-ENT-04-05, FAS-CRM-ENT-05-02, FAS-CRM-ENT-06-03]
suites: [TST-CRM-ENT-774, TST-CRM-ENT-776, TST-CRM-ENT-786, TST-CRM-ENT-787, TST-CRM-ENT-788]
---

# Maintain a person’s organizational relationships

## Purpose

A coordinator needs to represent a person's several organizational connections without replacing history every time employment or participation changes. This capability stores each dated affiliation on the contact, identifies a primary tie for prominent context, and exposes organization-side views that distinguish people from their individual relationship rows.

## How It Works

Staff add an organization and affiliation type with optional dates, title, notes, and roles. The contact owns the write transaction even when staff begin from an organization page. An omitted affiliation collection leaves existing ties intact; an explicit collection describes the intended result, with matching idless rows able to reuse an existing tie.

Date periods include their last day. Same-type ties to the same organization cannot overlap, and an affiliation cannot be reassigned to another organization. A primary tie has started and remains open-ended. Selecting a different primary clears the prior designation in the same save; ending the primary does not select an alternative automatically. The primary title and contact job title follow each other while historical titles remain on ended ties.

The Organization command can select or create a tie using the workspace default. The editor's new-primary workflow asks whether the person left the previous organization; Make Primary on an existing tie has a different interaction. The active-and-upcoming limit does not cap ended history. Organization people views can select current, past, upcoming, all, or an as-of date, paginate affiliation rows, and provide total head count under contact-read authority.

## Example

Lena leaves Harbor Engineering and joins Bayview Manufacturing. The coordinator adds Bayview as her new primary affiliation and confirms that she left Harbor. The old tie ends before the new start and retains its historical title and role context. Lena's separate chapter connection remains. Viewing the chapter's Current people includes her, while viewing Harbor's Past relationships explains her earlier employment.

The coordinator dates Lena's new Bayview affiliation October 23. If departure is confirmed, Harbor ends October 22, so the two employer periods do not share an inclusive boundary day. If Lena still consults for Harbor, retaining it keeps it current while Bayview becomes primary. The coordinator reads both relationships afterwards and checks the title now shown on the contact. A chapter connection continues independently in either case. Changing the organization on the old Harbor affiliation is refused; preserving its identity requires ending that relationship and adding the Bayview relationship rather than rewriting what the old row historically represents.

## Why It Works This Way

Our reading is that contact ownership makes relationship changes one deliberate aggregate operation rather than conflicting contact-side and organization-side authorities. Omission preservation prevents a client updating a phone from silently erasing employment history. Same-type overlap refusal distinguishes a return from duplicate simultaneous representations; allowing different types preserves genuinely different relationships.

A primary designation is a present label, not the whole membership model. Open-ended primary constraints and explicit selection prevent an ended employer from remaining prominent without inventing a successor. Asking about departure keeps new employment from silently ending a still-valid concurrent tie. Stable organization identity on the affiliation preserves what the historical relationship actually means. Separate affiliation rows and head counts let organization readers see multiple ties without confusing them with multiple people.

## Relationships

Contact and Organization supply the two identities, and Affiliation Type supplies the relationship vocabulary. Maintain responsibilities on organizational relationships owns dated role behavior. Shared contact points rely on eligible affiliations, and date reconciliation handles their boundaries. Locate people and organizations uses current ties for discovery; compliance consumes effective responsibilities through committed change reactions.
