---
id: CAP-CRM-ENT-004
title: Save corrections without overwriting another change
product_area: crm
module: entities
revision: 1
status: approved
product_intent: proposed
implementation_evidence: inconsistent
source_sha:
  services-crm: 7ea48a414c42eade10d60753b8bcc323b1dd726f
  react-console: da63075876b2c5f4a6af07d457bb24ea8bf4c0c7
intent_sources:
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#D-05'
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#M1-D5'
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#M1-D6'
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A4'
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A5'
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A7'
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A9'
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A14'
  - 'services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.md#D-09'
purpose: >-
  Staff correct records while retaining changes another person or the system has
  already made.
outcomes:
  - A stale save is rejected and says who changed the record.
  - A save that changes nothing leaves no trace.
  - A rejected save leaves every row as it was.
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.unlock
    where_checked: service
    scope: tenant
  - key: crm.organizations.unlock
    where_checked: service
    scope: tenant
rules:
  - id: BR-CAP-CRM-ENT-004-01
    proved_by: [ATS-CRM-ENT-001-19, ATS-CRM-ENT-002-12, ATS-CRM-ENT-004-01, ATS-CRM-ENT-004-14, ATS-CRM-ENT-004-15, ATS-CRM-ENT-004-18, ATS-CRM-ENT-004-19, ATS-CRM-ENT-004-20, ATS-CRM-ENT-004-21, ATS-CRM-ENT-004-22, ATS-CRM-ENT-004-24, ATS-CRM-ENT-006-12, ATS-CRM-ENT-009-17, ATS-CRM-ENT-009-19, ATS-CRM-ENT-013-13, ATS-CRM-ENT-015-10, TST-CRM-ENT-786]
    statement: >-
      A replacement supplies the read version and a patch begins by testing it;
      omission is rejected at sys_version, and a stale version produces a
      conflict identifying the latest person or system change.
    kind: validation
    enforced_at:
      - api
      - service
    must_prove: true
    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
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#D-05'
    examples:
      positive: >-
        Accepted example: a patch led by test /sys_version at the current
        version is applied and advances it once.
      negative: >-
        Rejected example: two patches at the same version: the second is
        rejected 409 and the first writer's value is preserved.
  - id: BR-CAP-CRM-ENT-004-02
    proved_by: [ATS-CRM-ENT-004-02, ATS-CRM-ENT-016-07, TST-CRM-ENT-774, TST-CRM-ENT-787]
    statement: >-
      An unchanged save preserves version, modification time and history, but
      still rejects a stale version.
    kind: invariant
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/record-saves.no-op.integration.spec.js#writes
      no revision
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#D-05'
  - id: BR-CAP-CRM-ENT-004-03
    proved_by: [ATS-CRM-ENT-004-03, TST-CRM-ENT-786]
    statement: >-
      A patch modifies the stored record before validating the whole result,
      preserves unnamed properties and clears nullable values when explicitly
      set to null.
    kind: policy
    enforced_at:
      - api
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#clears a
      field a later patch removes
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A5'
  - id: BR-CAP-CRM-ENT-004-04
    proved_by: [ATS-CRM-ENT-004-04, TST-CRM-ENT-776, TST-CRM-ENT-786, TST-CRM-ENT-787]
    statement: >-
      Replacement clears omitted editable scalar values and omitted e-mail,
      phone, address and communication-preference collections while retaining
      server-owned data and collections governed by explicit preservation rules.
    kind: policy
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#empties
      the collection
    origin: 'Behaviour at the pinned commit; superseded by BR-CAP-CRM-ENT-004-13 (OQ-CRM-ENT-001 decided 2026-10-22: A)'
    examples:
      positive: 'Accepted example: a replacement without job_title clears it.'
      negative: 'Rejected example: a replacement without addresses removes every address.'
  - id: BR-CAP-CRM-ENT-004-05
    proved_by: [ATS-CRM-ENT-004-05, ATS-CRM-ENT-004-16, ATS-CRM-ENT-005-11, ATS-CRM-ENT-005-14, ATS-CRM-ENT-005-17, TST-CRM-ENT-786, TST-CRM-ENT-787]
    statement: >-
      An explicit collection preserves and versions changed elements by their
      own ids, creates idless elements, removes omitted elements, rejects
      foreign or duplicate ids and allows elements to exchange types or
      preferred flags in one save.
    kind: invariant
    enforced_at:
      - api
      - service
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#moves
      the second address into the type the first left, and the preferred flag
      with it
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A9'
  - id: BR-CAP-CRM-ENT-004-06
    proved_by: [ATS-CRM-ENT-004-06, TST-CRM-ENT-774, TST-CRM-ENT-786, TST-CRM-ENT-787]
    statement: >-
      Round-trip writes accept unchanged contact names, numbers, attribution,
      locks and origin values but reject changes to those read-only values,
      ignore derived organization ancestry and compliance values, and reject a
      conflicting tenant id.
    kind: validation
    enforced_at:
      - api
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.field-rules.integration.spec.js#refuses
      a replacement whose contact number differs from the stored one, naming
      number
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#M1-D5'
  - id: BR-CAP-CRM-ENT-004-07
    proved_by: [ATS-CRM-ENT-004-07, ATS-CRM-ENT-004-17, ATS-CRM-ENT-005-10, ATS-CRM-ENT-008-06, ATS-CRM-ENT-008-10, TST-CRM-ENT-786, TST-CRM-ENT-787]
    statement: >-
      A failed contact or organization save leaves the record, its elements,
      custom values and affiliations at their previous state regardless of the
      failing validation, constraint, file, lock or version check.
    kind: invariant
    enforced_at:
      - service
      - database
    must_prove: true
    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
    origin: none
  - id: BR-CAP-CRM-ENT-004-08
    proved_by: [ATS-CRM-ENT-004-08, TST-CRM-ENT-787]
    statement: >-
      Validation refusals collect the problems detected in one response and
      identify each problem’s field path.
    kind: validation
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/organizations.integration.spec.js#refuses
      the create as ObjectInvalid, listing both problems
    origin: none
  - id: BR-CAP-CRM-ENT-004-09
    proved_by: [ATS-CRM-ENT-004-09, TST-CRM-ENT-774, TST-CRM-ENT-786, TST-CRM-ENT-788]
    statement: >-
      Input strings are trimmed, blank optional values become empty, blank
      required values are rejected and blank collection elements are omitted.
    kind: calculation
    enforced_at:
      - api
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/unit/api/contact-api-mapper.spec.js#drops
      a blank e-mail element rather than refusing the null the string standard
      made of it
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#M1-D4'
  - id: BR-CAP-CRM-ENT-004-10
    proved_by: [ATS-CRM-ENT-004-10, TST-CRM-ENT-774, TST-CRM-ENT-786]
    statement: >-
      System changes to spouse links, regions or compliance advance the record
      version and make an open editor’s next save conflict with attribution to
      the system.
    kind: policy
    enforced_at:
      - service
      - console
    must_prove: true
    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)
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#M1-D6'
  - id: BR-CAP-CRM-ENT-004-11
    proved_by: [ATS-CRM-ENT-004-11, TST-CRM-ENT-774]
    statement: >-
      The contact editor reviews changes before sending a version-tested patch
      of changed fields and offers reload or comparison after a conflict without
      silently accepting a newer version through Keep Editing.
    kind: policy
    enforced_at:
      - console
    must_prove: true
    provenance: test
    source: >-
      react-console:acceptance/crm/playwright/journeys/contacts/CRM-ACC-0009.spec.ts#CRM-ACC-0009
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A7'
  - id: BR-CAP-CRM-ENT-004-12
    proved_by: []
    statement: >-
      The product should keep unsaved changes during session refresh and
      validate the original version when restoring an editor draft.
    kind: policy
    enforced_at: []
    must_prove: false
    provenance: documented
    source: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A14'
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A14'
  - id: BR-CAP-CRM-ENT-004-13
    proved_by: [ATS-CRM-ENT-004-13]
    statement: >-
      A whole-record save of a contact or organization must include every
      replaceable property. A save that omits one is refused and names the
      missing property, null clears a value, and a read states its empty
      properties as null so that a record read back can be saved unchanged.
    kind: policy
    enforced_at:
      - api
      - service
    must_prove: true
    provenance: documented
    source: 'services-crm:docs/entities-api.md#Not yet under the complete-payload rule'
    origin: 'OQ-CRM-ENT-001 decided 2026-10-22 by Andrew: A (require complete payloads, as Activities already do); not yet implemented for these records, see FND-CRM-ENT-004'
    examples:
      positive: 'A replacement that names every replaceable property, with null for the ones to clear, is saved.'
      negative: 'A replacement that omits job_title is refused naming job_title; nothing is written.'
scenarios:
  - id: SC-CAP-CRM-ENT-004-01
    given: Two staff members open the same contact.
    when: The second saves after the first.
    then: >-
      The second is told the contact changed, reloads and re-applies without
      losing the first change.
    provenance: test
    source: >-
      react-console:acceptance/crm/playwright/journeys/contacts/CRM-ACC-0009.spec.ts#CRM-ACC-0009
  - id: SC-CAP-CRM-ENT-004-02
    given: An API client retrieves a contact and sends it back unmodified.
    when: The replacement is saved.
    then: 'The version, stamp and history are unmodified.'
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/record-saves.no-op.integration.spec.js#keeps
      the stored version and last-modified stamp
edge_cases:
  - id: SC-CAP-CRM-ENT-004-03
    given: A contact edited while its spouse is deleted.
    when: The editor saves.
    then: 409 naming the system change.
    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)
constraints: []
depends_on: []
related:
  - CAP-CRM-ENT-001
  - CAP-CRM-ENT-002
  - CAP-CRM-ENT-005
  - CAP-CRM-ENT-009
  - CAP-CRM-ENT-013
  - CAP-CRM-ENT-015
journeys:
  - JNY-CRM-ENT-001
open_questions:
  - OQ-CRM-ENT-001
feature: FEAT-CRM-ENT-07
concepts:
  - CON-CRM-ENT-001
  - CON-CRM-ENT-002
  - CON-CRM-ENT-033
evidence:
  - >-
    `services-crm` at `7ea48a414c42eade10d60753b8bcc323b1dd726f`: examined
    `aws/api/contact-api-mapper.ts`, `organization-api-mapper.ts`, `lib/api/read-only-fields.ts`
    (via the mappers), `services/persistence/contacts.ts`, `organizations.ts`,
    `docs/entities-api.md` ("Versions and no-op saves", "The update contract (E-09)", "Update
    semantics"); tests `e2e/contacts.e2e.spec.js`, `e2e/organizations.e2e.spec.js`,
    `integration/services/persistence/contacts.integration.spec.js`,
    `organizations.integration.spec.js`, `contacts.field-rules.integration.spec.js`,
    `organizations.field-rules.integration.spec.js`, `record-saves.no-op.integration.spec.js`,
    `unit/api/contact-api-mapper.spec.js`.
  - >-
    `react-console` at `da63075876b2c5f4a6af07d457bb24ea8bf4c0c7`: examined inventory
    RUL-CRM-CON-006, 023 to 025, 041, 046, 047, RUL-CRM-ORG-015 to 019, 021 to 023; journeys
    CRM-ACC-0004, 0006 to 0014, 0020, 0060, 0068, 0072, 0102 to 0111.
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.e2e.spec.js#answers 400
      naming sys_version to a patch that tests no version;
      services-crm:modules/entities/tests/integration/services/persistence/record-saves.no-op.integration.spec.js#writes
      no revision;
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#clears a
      field a later patch removes;
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#empties
      the collection;
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#moves
      the second address into the type the first left, and the preferred flag
      with it;
      services-crm:modules/entities/tests/integration/services/persistence/contacts.field-rules.integration.spec.js#refuses
      a replacement whose contact number differs from the stored one, naming
      number;
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#rolls
      back the contact row the transaction had already updated;
      services-crm:modules/entities/tests/integration/services/persistence/organizations.integration.spec.js#refuses
      the create as ObjectInvalid, listing both problems;
      services-crm:modules/entities/tests/unit/api/contact-api-mapper.spec.js#drops
      a blank e-mail element rather than refusing the null the string standard
      made of it;
      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);
      react-console:acceptance/crm/playwright/journeys/contacts/CRM-ACC-0009.spec.ts#CRM-ACC-0009;
      services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A14;
      services-crm:docs/entities-api.md#Not yet under the complete-payload rule
    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/entities-api.md
      - docs/milestones/CRM-M1-contacts-and-organizations.plan.md
      - modules/entities/tests/e2e/contacts.e2e.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/organizations.integration.spec.js
      - >-
        modules/entities/tests/integration/services/persistence/record-saves.no-op.integration.spec.js
      - modules/entities/tests/unit/api/contact-api-mapper.spec.js
  - repo: react-console
    role: console
    paths:
      - 'app/(console)/console/[tenantId]/crm/contacts/'
      - acceptance/crm/playwright/journeys/contacts/CRM-ACC-0009.spec.ts
exercised_by: [FAS-CRM-ENT-07-01, FAS-CRM-ENT-07-02, FAS-CRM-ENT-07-03, FAS-CRM-ENT-07-04, FAS-CRM-ENT-07-05]
suites: [TST-CRM-ENT-774, TST-CRM-ENT-776, TST-CRM-ENT-786, TST-CRM-ENT-787, TST-CRM-ENT-788]
---

# Save corrections without overwriting another change

## Purpose

Staff need to correct a record without replacing a colleague's newer work or a system change they have not seen. This capability ties each save to the version actually read and treats the person or organization and its owned information as one validation boundary. It also distinguishes a real correction from an unchanged round trip.

## How It Works

A replacement carries the read version; a patch starts with a version test and describes selected mutations. The service constructs and validates the resulting record, checks locks and lifecycle restrictions, and commits the aggregate together. A stale request conflicts and identifies the latest person or system change rather than silently rebasing the editor.

Collection entries use their own identities. A submitted collection describes retained, changed, added, and removed entries, and can exchange classifications or preferred flags in one save. Invalid or foreign element identities refuse the entire save. Server-derived and protected properties cannot be changed just because a read returned them.

Today replacement omission can clear editable scalar values and several detail collections, with explicit preservation exceptions for other information. Product's complete-payload replacement contract instead requires every replaceable property and explicit empty values; that recorded requirement remains unimplemented. The contact editor reviews changes and submits a version-tested patch, offering reload or comparison after a conflict. A current unchanged save creates no version or revision, but an unchanged stale request still conflicts.

## Example

Two coordinators open Ruth's contact. One saves a corrected phone number while the other is entering a job title. The second save is refused because it uses the older version. She reloads, sees the corrected phone, reenters her title, and saves against the new version. Both corrections then appear together; a no-change save afterwards adds no artificial revision.

The comparison matters even if the intervening edit looks unrelated. A system-cleared spouse reference can advance Ruth's version while the second coordinator is working on her title. Today that produces a conflict too, identifying system work rather than inventing a colleague's name. The coordinator rereads before deciding which title change still makes sense. If her corrected payload contains an invalid address element, no part of that correction commits. Reading the record after refusal verifies both the colleague's phone and the prior title remain. History then distinguishes successful corrections from refused attempts; the latter do not become committed revisions.

## Why It Works This Way

Our reading is that the read version represents what the editor knows, not merely a technical token. Silently replacing it after a conflict would authorize an edit against information the person never reviewed. System changes need the same treatment because spouse, region, or compliance updates can be just as material as a colleague's edit.

Whole-aggregate validation and rollback prevent a refused child entry from leaving partial person data behind. Element identities distinguish correcting a phone from replacing it, and normalize comparisons without inventing a second entry. Unchanged-save handling keeps history meaningful while retaining concurrency protection. Explicit replacement completeness would prevent omission from masquerading as intentional clearing; the narrative must explain that intended safeguard separately from the pinned behavior, not imply it already operates.

## Relationships

The record capabilities supply the parent identity, while contact points, affiliations, preferences, and custom values supply the owned information validated here. Archive and locks constrain whether ordinary changes are allowed. Inspect a record’s changes over time captures committed differences. Platform security evaluates authority, and committed change events trigger follow-up work rather than making every downstream reaction part of the editor's save.
