---
id: WF-CRM-ENT-002
title: Save a contact and its affiliations
trigger: >-
  A contact editor or record action submits changes, or an integration replaces
  or patches the contact.
product_area: crm
module: entities
revision: 1
status: draft
design_intent: proposed
implementation_evidence: observed
inference: []
source_sha:
  services-crm: a8d1364f1dd44b723bb5f9acba12f983e9ec8d69
  react-console: baf6dae382d6f41dce976c7f848667ccc37e076f
  services-platform: ad3f6a6582843008ed0f5a17b131878d15829af1
implements:
  - CAP-CRM-ENT-004
  - CAP-CRM-ENT-005
  - CAP-CRM-ENT-006
  - CAP-CRM-ENT-007
  - CAP-CRM-ENT-008
  - CAP-CRM-ENT-009
  - CAP-CRM-ENT-010
  - CAP-CRM-ENT-011
moves:
  - STATE-CRM-ENT-001
  - STATE-CRM-ENT-005
  - STATE-CRM-ENT-006
crosses:
  - SVC-CRM-ENT-entities
idempotency: >-
  Version comparison precedes no-op detection. A current-version save with no
  net change preserves stamps, history and events; the same content at a stale
  version still conflicts. Read the contact after an ambiguous result before
  retrying.
concurrency: >-
  The contact row lock serializes edits and pending role settlement. Role
  assignment locks relevant organizations before shared-detail work. This
  arrangement coordinates first-contact decisions but does not guarantee absence
  of every cross-record deadlock.
hops:
  - 'n': 1
    actor_or_service: Console contact editor
    action: >-
      Show the change review and send only changed fields as JSON Patch,
      beginning with a test of the editor’s original version.
    anchor: 'react-console:lib/contacts/crm-live.ts:contactPatchDirectives'
    seam: sync
    on_failure: >-
      Offer reload or comparison after conflict; continuing to edit retains the
      original base version.
    left_inconsistent: No partial business write remains.
    observer: The editor presents conflict recovery.
    provenance: test
    source: >-
      react-console:acceptance/crm/playwright/journeys/contacts/CRM-ACC-0009.spec.ts#CRM-ACC-0009
  - 'n': 2
    actor_or_service: Entities route
    action: >-
      Check update permission and evaluated unlock permission. Apply a patch to
      the read record or map a replacement, then validate the complete resulting
      record.
    anchor: 'services-crm:modules/entities/aws/api/contacts.routes.ts:contactsRoutes'
    seam: sync
    on_failure: >-
      Refuse missing version tests, forbidden lock changes, absent records or
      merged identities.
    left_inconsistent: No partial business write remains.
    observer: The caller receives the result.
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers 400
      naming sys_version to a patch that tests no version
  - 'n': 3
    actor_or_service: Shared record-asset pipeline
    action: >-
      Make changed photos and file-valued custom fields permanent before
      persistence.
    anchor: >-
      services-crm:modules/entities/services/assets/entity-assets.ts:entityAssets
    seam: sync
    on_failure: Refuse asset preparation while retaining the previously saved file.
    left_inconsistent: Copies prepared for a later failed save await cleanup.
    observer: Asset cleanup records identify abandoned copies.
    provenance: observed
    source: >-
      services-crm:modules/entities/services/persistence/contacts.ts:updateContact
  - 'n': 4
    actor_or_service: Entities persistence (one transaction)
    action: >-
      Lock and check the contact, prepare relationship changes and eligible
      roles, validate links and values, then persist the owner, spouse, assets,
      collections, affiliations, defaults and custom fields together. Restore
      modification stamps for a net no-op.
    anchor: >-
      services-crm:modules/entities/services/persistence/contacts.ts:updateContact
    seam: sync
    on_failure: >-
      Roll back every transactional change; stale-version errors identify the
      latest modifier.
    left_inconsistent: Only prepared asset copies may remain outside the rollback.
    observer: The caller receives the result.
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#rolls
      back the contact row the transaction had already updated
  - 'n': 5
    actor_or_service: Database change capture
    action: >-
      Capture the changed aggregate in one event and revision, with independent
      capture for a spouse changed by this save.
    anchor: 'services-crm:modules/entities/db/record-revisions.table.ts:recordRevisions'
    seam: sync
    on_failure: Refuse capture without required attribution and roll back the save.
    left_inconsistent: No partial business write remains.
    observer: The saved contact revision is available in history.
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers 409
      naming the system, not a colleague, when the system cleared the spouse
      link while the contact was edited (M1-D6)
  - 'n': 6
    actor_or_service: Entities route
    action: Nudge event dispatch after committing the save.
    anchor: 'services-crm:modules/entities/aws/api/contacts.routes.ts:contactsRoutes'
    seam: event
    on_failure: A failed nudge is ignored; scheduled dispatch remains available.
    left_inconsistent: Dependent reactions wait for later publication.
    observer: Dispatcher logs identify delayed publication.
    provenance: documented
    source: 'services-crm:docs/change-events.md#The pieces'
  - 'n': 7
    actor_or_service: CRM change-event consumers
    action: >-
      Recalculate organizations named by changed affiliations or roles, while
      region, search and duplicate handlers process their own messages.
    anchor: >-
      services-crm:modules/entities/services/organizations/compliance/compliance-change-events.ts:organizationComplianceRecalculationHandler
    seam: queue
    on_failure: Retry the failed handler through SQS and dead-letter after five receives.
    left_inconsistent: >-
      Saved contact data can precede updated compliance, region and search
      state.
    observer: Handler logs and dead-letter messages expose the lag.
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/organizations/compliance/compliance-change-events.integration.spec.js#a
      contact saved with the notes DISABLE_CONTACT_ROLE_COMPLIANCE_CHECK:
      nothing (its delete still records)
branches:
  - >-
    Replacement currently clears omitted editable values and owned collections,
    while omitted affiliations are retained and custom fields follow their
    separate contract. The approved complete-payload requirement remains
    unimplemented.
  - >-
    The legacy notes escape marker suppresses save-triggered compliance
    reactions; its exact value is documented in source evidence.
  - >-
    Imports can preserve stated automatic roles and suppress compliance side
    effects.
waits: []
evidence:
  - >-
    services-crm at 7ea48a41: modules/entities/aws/api/contacts.routes.ts,
    contact-api-mapper.ts; services/persistence/contacts.ts (updateContact),
    contact-affiliations.ts (prepareAffiliationsWrite, writeAffiliations);
    services/contacts/role-assignment/contact-role-assignment.ts;
    services/organizations/compliance/compliance-change-events.ts;
    docs/entities-api.md (Versions and no-op saves, The update contract, Update
    semantics).
  - >-
    react-console at da630758: lib/contacts/crm-live.ts;
    components/contact-editor/contact-editor.tsx, unsaved-changes.tsx.
  - >-
    services-platform at ad3f6a65: docs/standards/record-write-contracts.md W1
    to W3.
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-IAM-001
  - TC-IAM-005
  - TC-IAM-006
  - 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: What It Does
    provenance: observed
    source: 'react-console:lib/contacts/crm-live.ts:contactPatchDirectives'
  - section: What It Does
    provenance: observed
    source: 'services-crm:modules/entities/aws/api/contacts.routes.ts:contactsRoutes'
  - section: What It Does
    provenance: observed
    source: >-
      services-crm:modules/entities/services/assets/entity-assets.ts:entityAssets
  - section: What It Does
    provenance: observed
    source: >-
      services-crm:modules/entities/services/persistence/contacts.ts:updateContact
  - section: What It Does
    provenance: observed
    source: 'services-crm:modules/entities/db/record-revisions.table.ts:recordRevisions'
  - section: What It Does
    provenance: documented
    source: 'services-crm:modules/entities/aws/api/contacts.routes.ts:contactsRoutes'
  - section: What It Does
    provenance: observed
    source: >-
      services-crm:modules/entities/services/organizations/compliance/compliance-change-events.ts:organizationComplianceRecalculationHandler
  - section: What Starts It
    provenance: observed
    source: 'react-console:lib/contacts/crm-live.ts:contactPatchDirectives'
  - section: What Starts It
    provenance: observed
    source: 'services-crm:modules/entities/aws/api/contacts.routes.ts:contactsRoutes'
  - section: What Starts It
    provenance: observed
    source: >-
      services-crm:modules/entities/services/assets/entity-assets.ts:entityAssets
  - section: What Starts It
    provenance: observed
    source: >-
      services-crm:modules/entities/services/persistence/contacts.ts:updateContact
  - section: What Starts It
    provenance: observed
    source: 'services-crm:modules/entities/db/record-revisions.table.ts:recordRevisions'
  - section: What Starts It
    provenance: documented
    source: 'services-crm:modules/entities/aws/api/contacts.routes.ts:contactsRoutes'
  - section: What Starts It
    provenance: observed
    source: >-
      services-crm:modules/entities/services/organizations/compliance/compliance-change-events.ts:organizationComplianceRecalculationHandler
  - section: The Flow
    provenance: observed
    source: 'react-console:lib/contacts/crm-live.ts:contactPatchDirectives'
  - section: The Flow
    provenance: observed
    source: 'services-crm:modules/entities/aws/api/contacts.routes.ts:contactsRoutes'
  - section: The Flow
    provenance: observed
    source: >-
      services-crm:modules/entities/services/assets/entity-assets.ts:entityAssets
  - section: The Flow
    provenance: observed
    source: >-
      services-crm:modules/entities/services/persistence/contacts.ts:updateContact
  - section: The Flow
    provenance: observed
    source: 'services-crm:modules/entities/db/record-revisions.table.ts:recordRevisions'
  - section: The Flow
    provenance: documented
    source: 'services-crm:modules/entities/aws/api/contacts.routes.ts:contactsRoutes'
  - section: The Flow
    provenance: observed
    source: >-
      services-crm:modules/entities/services/organizations/compliance/compliance-change-events.ts:organizationComplianceRecalculationHandler
  - section: Hop by Hop
    provenance: observed
    source: 'react-console:lib/contacts/crm-live.ts:contactPatchDirectives'
  - section: Hop by Hop
    provenance: observed
    source: 'services-crm:modules/entities/aws/api/contacts.routes.ts:contactsRoutes'
  - section: Hop by Hop
    provenance: observed
    source: >-
      services-crm:modules/entities/services/assets/entity-assets.ts:entityAssets
  - section: Hop by Hop
    provenance: observed
    source: >-
      services-crm:modules/entities/services/persistence/contacts.ts:updateContact
  - section: Hop by Hop
    provenance: observed
    source: 'services-crm:modules/entities/db/record-revisions.table.ts:recordRevisions'
  - section: Hop by Hop
    provenance: documented
    source: 'services-crm:modules/entities/aws/api/contacts.routes.ts:contactsRoutes'
  - section: Hop by Hop
    provenance: observed
    source: >-
      services-crm:modules/entities/services/organizations/compliance/compliance-change-events.ts:organizationComplianceRecalculationHandler
  - section: When Something Fails
    provenance: observed
    source: 'react-console:lib/contacts/crm-live.ts:contactPatchDirectives'
  - section: When Something Fails
    provenance: observed
    source: 'services-crm:modules/entities/aws/api/contacts.routes.ts:contactsRoutes'
  - section: When Something Fails
    provenance: observed
    source: >-
      services-crm:modules/entities/services/assets/entity-assets.ts:entityAssets
  - section: When Something Fails
    provenance: observed
    source: >-
      services-crm:modules/entities/services/persistence/contacts.ts:updateContact
  - section: When Something Fails
    provenance: observed
    source: 'services-crm:modules/entities/db/record-revisions.table.ts:recordRevisions'
  - section: When Something Fails
    provenance: documented
    source: 'services-crm:modules/entities/aws/api/contacts.routes.ts:contactsRoutes'
  - section: When Something Fails
    provenance: observed
    source: >-
      services-crm:modules/entities/services/organizations/compliance/compliance-change-events.ts:organizationComplianceRecalculationHandler
  - section: Repeats and Races
    provenance: observed
    source: 'react-console:lib/contacts/crm-live.ts:contactPatchDirectives'
  - section: Repeats and Races
    provenance: observed
    source: 'services-crm:modules/entities/aws/api/contacts.routes.ts:contactsRoutes'
  - section: Repeats and Races
    provenance: observed
    source: >-
      services-crm:modules/entities/services/assets/entity-assets.ts:entityAssets
  - section: Repeats and Races
    provenance: observed
    source: >-
      services-crm:modules/entities/services/persistence/contacts.ts:updateContact
  - section: Repeats and Races
    provenance: observed
    source: 'services-crm:modules/entities/db/record-revisions.table.ts:recordRevisions'
  - section: Repeats and Races
    provenance: documented
    source: 'services-crm:modules/entities/aws/api/contacts.routes.ts:contactsRoutes'
  - section: Repeats and Races
    provenance: observed
    source: >-
      services-crm:modules/entities/services/organizations/compliance/compliance-change-events.ts:organizationComplianceRecalculationHandler
  - section: What Is Observable
    provenance: observed
    source: 'react-console:lib/contacts/crm-live.ts:contactPatchDirectives'
  - section: What Is Observable
    provenance: observed
    source: 'services-crm:modules/entities/aws/api/contacts.routes.ts:contactsRoutes'
  - section: What Is Observable
    provenance: observed
    source: >-
      services-crm:modules/entities/services/assets/entity-assets.ts:entityAssets
  - section: What Is Observable
    provenance: observed
    source: >-
      services-crm:modules/entities/services/persistence/contacts.ts:updateContact
  - section: What Is Observable
    provenance: observed
    source: 'services-crm:modules/entities/db/record-revisions.table.ts:recordRevisions'
  - section: What Is Observable
    provenance: documented
    source: 'services-crm:modules/entities/aws/api/contacts.routes.ts:contactsRoutes'
  - section: What Is Observable
    provenance: observed
    source: >-
      services-crm:modules/entities/services/organizations/compliance/compliance-change-events.ts:organizationComplianceRecalculationHandler
  - section: What It Does
    provenance: inferred
    source: >-
      react-console:lib/contacts/crm-live.ts:contactPatchDirectives;
      services-crm:modules/entities/aws/api/contacts.routes.ts:contactsRoutes;
      services-crm:modules/entities/services/assets/entity-assets.ts:entityAssets;
      services-crm:modules/entities/services/persistence/contacts.ts:updateContact;
      services-crm:modules/entities/db/record-revisions.table.ts:recordRevisions;
      services-crm:modules/entities/aws/api/contacts.routes.ts:contactsRoutes;
      services-crm:modules/entities/services/organizations/compliance/compliance-change-events.ts:organizationComplianceRecalculationHandler
    note: >-
      The explicitly labeled rationale interprets the existing transaction,
      transport and recovery boundaries; it is not a product decision.
  - section: Open Design Questions
    provenance: documented
    source: 'rhythm-v2-docs:specs/crm/entities/FINDINGS.md#Findings'
---

# Save a contact and its affiliations

## What It Does

This workflow commits a correction to the person and any affected contact-owned relationships as one aggregate. A coordinator can replace Ruth’s work number and record an employer change in a single reviewed save, without leaving the title, primary organization and responsibilities describing different intended results.

Our reading is that the original read version is essential because the same aggregate also changes through spouse maintenance, source disconnection and scheduled work. The console reviews bound differences and submits a patch; the service also accepts replacements, whose observed omission behavior remains a separate implementation gap against the approved complete-payload requirement.

## What Starts It

An editor submits the contact draft or an integration supplies replacement or patch input. Organization roster actions also use the owning contact write. The saved base version identifies what the editor read before changing details or relationships. The workflow begins validation at submission; local chooser and collection edits alone leave the saved aggregate unchanged.

## The Flow

The diagram shows the sequence and service seams. Each synchronous stage waits for its result, while captured-event, queue and scheduled stages can run after the originating request has returned. The hop descriptions below identify the write and failure boundary, so the last arrow must not be mistaken for one atomic transaction across every participant.

```mermaid
sequenceDiagram
  participant U as Contact editor
  participant C as CRM save workflow
  participant D as CRM database
  participant Q as Change queue
  U->>C: version-tested patch
  C->>D: lock owner, validate and save aggregate
  D-->>C: commit revision and event
  C-->>U: saved contact or refusal
  C->>Q: dispatch reactions after commit
```

## Hop by Hop

### 1. Console contact editor

The contact editor compares its draft with the original record and presents Review Changes. Its JSON Patch begins with the original version test and includes edited bindings. Choosing to continue after a conflict keeps that original basis; it does not silently rebase the draft onto a newer contact.

### 2. Entities route

The route evaluates update authority and the relevant unlock capability, applies the patch to the read record or maps a replacement, and validates the result. Missing version tests, absent or merged identities and unauthorized lock changes are synchronous refusals. No successful route gate bypasses persistence guards.

### 3. Shared record-asset pipeline

Changed photo and file custom values are prepared through the asset pipeline before the save transaction. A failure leaves the previously stored contact reference in place. Newly prepared copies from a later failed save can remain outside the transaction until cleanup discovers them.

### 4. Entities persistence (one transaction)

Persistence locks the contact, compares the version and assembles relationship work before writing. Contact points, spouse references, affiliations, responsibilities, defaults and custom values share the transaction. A net unchanged save restores modification stamps; a stale request conflicts before that no-op decision.

### 5. Database change capture

Database capture records one aggregate explanation of changed contact data. A reciprocal spouse change has its own subject capture. Attribution failure rolls back the business change along with its history instead of allowing a correction with missing actor or source context.

### 6. Entities route

After the aggregate commits, the route asks dispatch to run promptly. Failure of this nudge is not a reason to undo the saved contact. Scheduled dispatch can publish the captured change later, and dependent projections remain temporarily behind that committed result.

### 7. CRM change-event consumers

Change consumers reevaluate organizations affected by affiliation or role differences and separately process region, search and duplicate work. Queue retries apply to the failing handler message. The legacy save escape and import suppression remain part of observed compliance behavior and are not new recommended editing tools.

## When Something Fails

### Hop 1 — Console contact editor

The failure boundary is: Offer reload or comparison after conflict; continuing to edit retains the original base version. The remaining work or data is: No partial business write remains. The editor presents conflict recovery.

### Hop 2 — Entities route

The failure boundary is: Refuse missing version tests, forbidden lock changes, absent records or merged identities. The remaining work or data is: No partial business write remains. The caller receives the result.

### Hop 3 — Shared record-asset pipeline

The failure boundary is: Refuse asset preparation while retaining the previously saved file. The remaining work or data is: Copies prepared for a later failed save await cleanup. Asset cleanup records identify abandoned copies.

### Hop 4 — Entities persistence (one transaction)

The failure boundary is: Roll back every transactional change; stale-version errors identify the latest modifier. The remaining work or data is: Only prepared asset copies may remain outside the rollback. The caller receives the result.

### Hop 5 — Database change capture

The failure boundary is: Refuse capture without required attribution and roll back the save. The remaining work or data is: No partial business write remains. The saved contact revision is available in history.

### Hop 6 — Entities route

The failure boundary is: A failed nudge is ignored; scheduled dispatch remains available. The remaining work or data is: Dependent reactions wait for later publication. Dispatcher logs identify delayed publication. A later attempt can be required; the scheduling or publication boundary does not establish an immediate user-visible completion.

### Hop 7 — CRM change-event consumers

The failure boundary is: Retry the failed handler through SQS and dead-letter after five receives. The remaining work or data is: Saved contact data can precede updated compliance, region and search state. Handler logs and dead-letter messages expose the lag. This failure is downstream of committed input; operators investigate its own delivery rather than treating the caller’s original save as failed.

## Repeats and Races

The service checks version before deciding that content is unchanged. A current-version no-op preserves stamps, revision and event count, but the same proposed content based on an older version still conflicts. After an uncertain response, a caller reads the contact and compares the committed result before constructing another mutation.

Contact locking serializes editors with pending-role settlement. Relevant organizations are also locked for assignment decisions before shared-detail work. These locks coordinate known races but do not establish universal deadlock freedom across records. The refresh-recovery findings still limit what has been proven through the browser.

## What Is Observable

The editor presents conflict recovery. The caller receives the result. Asset cleanup records identify abandoned copies. The saved contact revision is available in history. Dispatcher logs identify delayed publication. Handler logs and dead-letter messages expose the lag.

The person or initiating caller sees the synchronous result at its own boundary. Captured history establishes committed local changes; logs, durable job state and queue outcomes explain remaining work. A successful database change, accepted downstream request and completed delivery are different observations and must be investigated through the component that owns each one.

## Open Design Questions

The current replacement path clears some omitted fields while preserving omitted affiliations and applying the custom-field contract separately. The approved complete-payload requirement remains unresolved in implementation. Ending and removal through a general contact save also retain their existing disagreement with Platform’s explicit-operation requirement.
