---
id: CAP-CRM-ENT-016
title: Inspect a record’s changes over time
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.plan.md#2.
    Scope
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A2'
  - >-
    services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.plan.md#2.
    Scope
purpose: >-
  Readers trace changes to contacts and organizations, including records that
  have been deleted.
outcomes:
  - >-
    Every committed save leaves exactly one revision, and a save that changes
    nothing leaves none.
  - A reader can filter the timeline and open any revision section by section.
  - History cannot be forged or rewritten by the application.
actors:
  - ACT-CRM-001
  - ACT-CRM-003
  - ACT-CRM-004
  - ACT-CRM-002
surfaces:
  - console
  - api
preconditions: []
postconditions: []
permissions:
  - key: crm.contacts.history
    where_checked: api
    scope: tenant
  - key: crm.organizations.history
    where_checked: api
    scope: tenant
  - key: crm.contacts.read
    where_checked: api
    scope: tenant
  - key: crm.organizations.read
    where_checked: api
    scope: tenant
rules:
  - id: BR-CAP-CRM-ENT-016-01
    proved_by: [ATS-CRM-ENT-013-16, ATS-CRM-ENT-015-12, ATS-CRM-ENT-016-01, ATS-CRM-ENT-016-07, ATS-CRM-ENT-016-08, ATS-CRM-ENT-016-10, TST-CRM-ENT-786, TST-CRM-ENT-794]
    statement: >-
      Each changed contact or organization save commits one revision containing
      field-level old and new values and the contemporaneous record name, while
      an unchanged save creates no revision.
    kind: invariant
    enforced_at:
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.history.integration.spec.js#writes
      exactly one revision, whatever the save touched
    origin: none
  - id: BR-CAP-CRM-ENT-016-02
    proved_by: [ATS-CRM-ENT-014-12, ATS-CRM-ENT-016-02, ATS-CRM-ENT-016-09, ATS-CRM-ENT-016-11, TST-CRM-ENT-786]
    statement: >-
      History access requires the history permission and reach to an existing
      record, returns not found for inaccessible live records and uses the
      history permission alone for deleted records marked as deleted.
    kind: permission
    enforced_at:
      - api
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/organizations-history.e2e.spec.js#answers
      a caller with the history key but no read of organizations 404, as the
      organization itself would
    origin: none
  - id: BR-CAP-CRM-ENT-016-03
    proved_by: [ATS-CRM-ENT-016-03, TST-CRM-ENT-786]
    statement: >-
      History pages are newest first, use a stable cursor, accept actor,
      operation, source and date filters, return at most 100 revisions and
      reject unsupported parameters.
    kind: validation
    enforced_at:
      - api
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts-history.e2e.spec.js#refuses
      a query parameter it does not know, rather than ignoring the filter
    origin: none
  - id: BR-CAP-CRM-ENT-016-04
    proved_by: [ATS-CRM-ENT-016-05, TST-CRM-ENT-786, TST-CRM-ENT-794]
    statement: >-
      Revision attribution uses the actor’s current display name including
      former users, falls back to the captured name when unavailable and
      identifies console, portal, public, integration, job or maintenance as the
      source.
    kind: calculation
    enforced_at:
      - service
    must_prove: false
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/record-revisions.queries.integration.spec.js#still
      answers for a user who has been deleted, because former users are named
      too
    origin: none
  - id: BR-CAP-CRM-ENT-016-05
    proved_by: [ATS-CRM-ENT-016-05, TST-CRM-ENT-786, TST-CRM-ENT-794]
    statement: >-
      Contact revision details group changes into named contact, affiliation,
      role, e-mail, phone, address, preference and custom-field sections,
      combine name parts and resolve relationship and custom-field labels while
      hiding excluded captured columns.
    kind: policy
    enforced_at:
      - service
    must_prove: false
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts-history.e2e.spec.js#shows
      the role removed and the role added, each under the name it has now
    origin: none
  - id: BR-CAP-CRM-ENT-016-06
    proved_by: []
    statement: >-
      Merged-away contact history routes identify the survivor with a gone
      response, and the survivor provides access to that contact’s retained
      history.
    kind: policy
    enforced_at:
      - api
    must_prove: false
    provenance: observed
    source: >-
      services-crm:modules/entities/aws/api/contacts.routes.ts:absorbedContactHistoryRoutes
    origin: 'D-20 (cited in services-crm:modules/entities/aws/api/contacts.routes.ts)'
  - id: BR-CAP-CRM-ENT-016-07
    proved_by: [ATS-CRM-ENT-016-04, TST-CRM-ENT-794]
    statement: >-
      Application access to revisions is read-only and tenant-isolated, and
      revisions have no record foreign key so deletion does not remove them.
    kind: invariant
    enforced_at:
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/db/record-revisions.table.integration.spec.js#may
      not insert a revision, so nothing the application runs can forge one
    origin: none
  - id: BR-CAP-CRM-ENT-016-08
    proved_by: [ATS-CRM-ENT-016-06]
    statement: >-
      A record change without actor and source attribution fails together with
      its attempted revision.
    kind: invariant
    enforced_at:
      - database
    must_prove: false
    provenance: documented
    source: >-
      services-crm:docs/record-history.md#Fail closed, and the maintenance
      helper
    origin: none
  - id: BR-CAP-CRM-ENT-016-09
    proved_by: []
    statement: Record revisions remain until the tenant is purged.
    kind: policy
    enforced_at:
      - database
    must_prove: false
    provenance: documented
    source: 'services-crm:docs/record-history.md#Retention'
    origin: none
scenarios:
  - id: SC-CAP-CRM-ENT-016-01
    given: Ruth Kim edited three times by two staff members.
    when: A reader opens her history.
    then: >-
      Three revisions newest first, each naming the person, the console and the
      fields changed.
    provenance: test
    source: >-
      react-console:acceptance/crm/playwright/journeys/contacts/CRM-ACC-0043.spec.ts#CRM-ACC-0043
  - id: SC-CAP-CRM-ENT-016-02
    given: An organization renamed and archived.
    when: Its history is opened.
    then: >-
      One revision per save, the rename with both values and the archive with
      its reason.
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/organizations-history.e2e.spec.js#shows
      the rename with its label, data type and both values
edge_cases: []
constraints: []
depends_on: []
related:
  - CAP-CRM-ENT-004
  - CAP-CRM-ENT-013
  - CAP-CRM-ENT-014
  - CAP-CRM-ENT-015
journeys:
  - JNY-CRM-ENT-001
  - JNY-CRM-ENT-003
open_questions: []
feature: FEAT-CRM-ENT-07
concepts:
  - CON-CRM-ENT-001
  - CON-CRM-ENT-002
  - CON-CRM-ENT-033
evidence:
  - >-
    `services-crm` at `7ea48a414c42eade10d60753b8bcc323b1dd726f`: examined
    `history/contact-history.config.ts`, `history/organization-history.config.ts`,
    `services/persistence/record-revisions.ts`, `record-history-labels.ts`, `user-names.ts`,
    `aws/api/contacts.routes.ts`, `organizations.routes.ts` (history routes),
    `db/record-revisions.table.ts`, `docs/record-history.md`; tests
    `e2e/contacts-history.e2e.spec.js`, `e2e/organizations-history.e2e.spec.js`,
    `integration/services/persistence/contacts.history.integration.spec.js`,
    `record-revisions.queries.integration.spec.js`,
    `integration/db/record-revisions.table.integration.spec.js`.
  - >-
    `react-console` at `da63075876b2c5f4a6af07d457bb24ea8bf4c0c7`: examined inventory
    RUL-CRM-CON-040; journeys CRM-ACC-0043, 0141, 0212.
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-DAT-004, TC-LOG-001, TC-LOG-002, TC-LOG-003, TC-LOG-004, TC-LOG-005, TC-PI-001, TC-PRV-001, TC-PRV-002, TC-TEN-001, TC-TEN-002, TC-TEN-003, TC-TEN-004]
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.history.integration.spec.js#writes
      exactly one revision, whatever the save touched;
      services-crm:modules/entities/tests/e2e/organizations-history.e2e.spec.js#answers
      a caller with the history key but no read of organizations 404, as the
      organization itself would;
      services-crm:modules/entities/tests/e2e/contacts-history.e2e.spec.js#refuses
      a query parameter it does not know, rather than ignoring the filter;
      services-crm:modules/entities/tests/integration/services/persistence/record-revisions.queries.integration.spec.js#still
      answers for a user who has been deleted, because former users are named
      too;
      services-crm:modules/entities/tests/e2e/contacts-history.e2e.spec.js#shows
      the role removed and the role added, each under the name it has now;
      services-crm:modules/entities/aws/api/contacts.routes.ts:absorbedContactHistoryRoutes;
      services-crm:modules/entities/tests/integration/db/record-revisions.table.integration.spec.js#may
      not insert a revision, so nothing the application runs can forge one;
      services-crm:docs/record-history.md#Fail closed, and the maintenance
      helper; services-crm:docs/record-history.md#Retention
    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/record-history.md
      - modules/entities/aws/api/contacts.routes.ts
      - modules/entities/tests/e2e/contacts-history.e2e.spec.js
      - modules/entities/tests/e2e/organizations-history.e2e.spec.js
      - >-
        modules/entities/tests/integration/db/record-revisions.table.integration.spec.js
      - >-
        modules/entities/tests/integration/services/persistence/contacts.history.integration.spec.js
      - >-
        modules/entities/tests/integration/services/persistence/record-revisions.queries.integration.spec.js
  - repo: react-console
    role: console
    paths:
      - 'app/(console)/console/[tenantId]/crm/contacts/'
      - acceptance/crm/playwright/journeys/contacts/CRM-ACC-0043.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-786, TST-CRM-ENT-794]
---

# Inspect a record’s changes over time

## Purpose

Staff investigating a discrepancy need to understand what changed, who changed it, and which source performed the work. This capability presents attributed revisions of contacts and organizations without turning history into another editable record. It preserves useful evidence after deletion and distinguishes inspection of the past from restoring an earlier version.

## How It Works

A changed save commits one revision containing old and new field values and the record's contemporaneous name. An unchanged save contributes no revision. The capture requires actor and source attribution; an unattributed change fails together with its attempted revision rather than leaving an unexplained write.

History reads use separate authority and reach rules. Live records require access to the record, while deleted-record history can use the history permission and identifies deletion. Pages are newest first with stable cursors and supported filters. Details group contact, affiliation, role, communication, and custom-value changes into readable sections, resolving appropriate labels while hiding excluded captured fields.

Actor display uses the current name, including former users, with a captured fallback. Source identifies the channel or process. Merged-away contact routes direct callers to the survivor's retained history path. Revisions are read-only to the application and survive parent deletion until workspace purge; they do not provide a restore action.

## Example

A coordinator investigates why Ruth's title and work phone differ from last week. The history page shows a grouped revision identifying the editor and source, with old and new values for the changed sections. An unchanged round-trip save adds no distracting entry. If the contact is later deleted, an authorized history reader can still inspect retained revisions marked with the record's deleted state.

For Ruth's investigation, the coordinator filters revisions by the relevant date range and follows the returned continuation cursor rather than assuming the first page is the complete history. The old and new title values explain the correction, while the displayed actor can use the person's current name. A relationship label likewise does not certify a historical snapshot of every related name. If Ruth is later merged into another contact, the absorbed identity points to its survivor and the supported survivor path exposes retained history. If she is deleted instead, retained history has its deleted-record access boundary; neither route reconstructs a live Ruth record.

## Why It Works This Way

Our reading is that a single revision per changed aggregate connects related edits without requiring staff to reconstruct one save from many independent entries. Commit-time capture protects the agreement between live data and its explanation. Fail-closed attribution prevents a successful write from becoming unauditable, and application read-only access prevents rewriting that account afterwards.

Separate history authority recognizes that earlier values can disclose information beyond an ordinary present-day read. Retention without a parent foreign key prevents deletion from erasing its own evidence. Readable labels and grouped sections make changes interpretable, while contemporaneous record names preserve context when the live name changes. Current actor names and captured fallbacks support recognition without claiming every label is a historical snapshot.

## Relationships

Record saves, archive, deletion, and system changes supply the committed differences. Platform identity supplies user display information, and Platform metadata supplies field labels used in presentation. Data Quality supplies merge survivors and retained absorbed-contact access. Workspace lifecycle owns purge. This capability presents captured revisions; it does not own a rollback workflow or allow callers to insert revisions.
