---
id: CAP-CRM-ENT-015
title: Control record locks and system protection
product_area: crm
module: entities
revision: 1
status: approved
product_intent: confirmed
implementation_evidence: partially-observed
source_sha:
  services-crm: 7ea48a414c42eade10d60753b8bcc323b1dd726f
  react-console: da63075876b2c5f4a6af07d457bb24ea8bf4c0c7
intent_sources:
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#D-08'
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A17'
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A18'
  - 'services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.plan.md#A5'
purpose: >-
  Administrators control customer locks while Rhythm preserves the separate
  protections of system records.
outcomes:
  - 'A lock is set and released only by an authorised, audited action.'
  - A locked record or element rejects what its lock names.
  - A protected system record cannot be unlocked or deleted.
actors:
  - ACT-CRM-001
  - ACT-CRM-003
  - ACT-CRM-004
surfaces:
  - console
  - api
preconditions: []
postconditions: []
permissions:
  - key: crm.contacts.lock
    where_checked: api
    scope: tenant
  - key: crm.contacts.unlock
    where_checked: api
    scope: tenant
  - key: crm.organizations.lock
    where_checked: api
    scope: tenant
  - key: crm.organizations.unlock
    where_checked: api
    scope: tenant
  - key: crm.contact-roles.lock
    where_checked: api
    scope: tenant
  - key: crm.contact-roles.unlock
    where_checked: api
    scope: tenant
  - key: crm.contacts.read
    where_checked: api
    scope: tenant
  - key: crm.organizations.read
    where_checked: api
    scope: tenant
  - key: crm.contact-roles.read
    where_checked: api
    scope: tenant
rules:
  - id: BR-CAP-CRM-ENT-015-01
    proved_by: [ATS-CRM-ENT-015-01, ATS-CRM-ENT-015-16, TST-CRM-ENT-774]
    statement: >-
      Contacts, organizations and contact roles expose separate lock and unlock
      operations, each requiring its own administrator permission.
    kind: permission
    enforced_at:
      - api
    must_prove: true
    provenance: observed
    source: >-
      services-crm:modules/entities/aws/api/record-locks.routes.ts:defineRecordLockRoutes
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#D-08'
  - id: BR-CAP-CRM-ENT-015-02
    proved_by: [ATS-CRM-ENT-015-02, ATS-CRM-ENT-015-09, ATS-CRM-ENT-015-15, TST-CRM-ENT-794]
    statement: >-
      Lock changes require the read version and a nonblank reason up to 1,000
      characters, reject stale versions, invalid locks and missing records, and
      make no audit or record change when the requested lock already applies.
    kind: validation
    enforced_at:
      - api
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/record-locks.integration.spec.js#leaves
      the role at its version and lock, and records nothing
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#D-08'
  - id: BR-CAP-CRM-ENT-015-03
    proved_by: [ATS-CRM-ENT-015-03, ATS-CRM-ENT-015-12, ATS-CRM-ENT-015-15, ATS-CRM-ENT-016-10, TST-CRM-ENT-794]
    statement: >-
      Lock changes advance record version and append actor, time, reason and
      before-and-after lock values to system-owned audit entries that readers
      can page newest first.
    kind: lifecycle
    enforced_at:
      - service
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/record-locks.queries.integration.spec.js#answers
      the newest two changes of the record, every property mapped, and where the
      page ended
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#D-08'
  - id: BR-CAP-CRM-ENT-015-04
    proved_by: [ATS-CRM-ENT-015-04, ATS-CRM-ENT-015-13, ATS-CRM-ENT-015-16, ATS-CRM-ENT-017-16, TST-CRM-ENT-786]
    statement: >-
      Ordinary contact and organization writes accept unchanged locks but reject
      lock changes; contact-role writes can change delete locks, while full-lock
      changes use audited actions.
    kind: permission
    enforced_at:
      - api
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers 403
      naming sys_locked for a replacement that sets a lock (E-14)
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A17'
  - id: BR-CAP-CRM-ENT-015-05
    proved_by: [ATS-CRM-ENT-004-20, ATS-CRM-ENT-014-08, ATS-CRM-ENT-015-05, ATS-CRM-ENT-015-10, ATS-CRM-ENT-015-15, TST-CRM-ENT-775, TST-CRM-ENT-786, TST-CRM-ENT-794]
    statement: >-
      A full lock blocks ordinary updates and deletion until released, while a
      delete lock blocks deletion alone.
    kind: invariant
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers 409
      IllegalOperation, not 403, for a replacement of a contact locked against
      updates, the lock sent back unchanged
    origin: none
  - id: BR-CAP-CRM-ENT-015-06
    proved_by: [ATS-CRM-ENT-014-11, ATS-CRM-ENT-015-06, ATS-CRM-ENT-015-11, TST-CRM-ENT-786, TST-CRM-ENT-794]
    statement: >-
      Element full locks block changes and all element locks block removal;
      audited unlock releases all locks or selected element locks and rejects
      unknown elements without releasing anything.
    kind: lifecycle
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/record-locks.integration.spec.js#refuses
      an element id the organization does not have, releasing nothing
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#D-08'
  - id: BR-CAP-CRM-ENT-015-07
    proved_by: [ATS-CRM-ENT-015-07, ATS-CRM-ENT-015-16, TST-CRM-ENT-786]
    statement: >-
      A write that only clears an element lock requires the record’s unlock
      permission and otherwise fails.
    kind: permission
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers 409
      to a replacement that only clears an element lock without the unlock key,
      and 200 with it (CRM-ACC-0035)
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#D-08'
  - id: BR-CAP-CRM-ENT-015-08
    proved_by: [ATS-CRM-ENT-015-08, TST-CRM-ENT-794]
    statement: >-
      Customer permissions cannot lock, unlock or delete protected system
      records or change their locks through ordinary writes, but ordinary fields
      remain editable; no seeded record currently carries protection.
    kind: invariant
    enforced_at:
      - service
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/record-locks.integration.spec.js#refuses
      the unlock, naming the protection
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A18'
  - id: BR-CAP-CRM-ENT-015-09
    proved_by: [ATS-CRM-ENT-015-14, ATS-CRM-ENT-018-13]
    statement: >-
      Contact, organization, affiliation, address and phone types enforce their
      locks but currently expose no lock or unlock action.
    kind: policy
    enforced_at:
      - service
    must_prove: false
    provenance: documented
    source: 'services-crm:docs/record-locks.md#Not covered yet'
    origin: none
scenarios:
  - id: SC-CAP-CRM-ENT-015-01
    given: A contact locked against updates and deletes by an earlier process.
    when: An administrator unlocks it with a reason.
    then: It can be edited and deleted again and the unlock is audited.
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/record-locks.integration.spec.js#lets
      the contact be replaced and then deleted
  - id: SC-CAP-CRM-ENT-015-02
    given: A standard user tries to lock a contact.
    when: The request is made.
    then: It is rejected for want of the lock key.
    provenance: test
    source: >-
      react-console:acceptance/crm/playwright/journeys/contacts/CRM-ACC-0035.spec.ts#CRM-ACC-0035
edge_cases: []
constraints: []
depends_on: []
related:
  - CAP-CRM-ENT-004
  - CAP-CRM-ENT-014
  - CAP-CRM-ENT-016
  - CAP-CRM-ENT-017
  - CAP-CRM-ENT-018
journeys: []
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
    `aws/api/record-locks.routes.ts`, `record-lock-api-mapper.ts`,
    `services/persistence/record-locks.ts`, `db/record-lock-changes.table.ts`,
    `db/protected-records.table.ts`, `docs/record-locks.md`; tests
    `unit/api/record-lock-api-mapper.spec.js`,
    `integration/services/persistence/record-locks.integration.spec.js`,
    `record-locks.queries.integration.spec.js`, `e2e/contacts.e2e.spec.js`,
    `e2e/organizations.e2e.spec.js`.
  - >-
    `react-console` at `da63075876b2c5f4a6af07d457bb24ea8bf4c0c7`: examined inventory
    RUL-CRM-CON-026, RUL-CRM-ORG-027, 028, RUL-CRM-AFF-024; journeys CRM-ACC-0035, 0129.
approved_by: Andrew
approved_at: 2026-10-22
controls: [TC-AZ-001, TC-AZ-002, TC-AZ-003, TC-AZ-004, TC-AZ-005, TC-AZ-006, TC-DAT-001, TC-DAT-002, TC-DAT-003, TC-LOG-001, TC-LOG-002, TC-LOG-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/aws/api/record-locks.routes.ts:defineRecordLockRoutes;
      services-crm:modules/entities/tests/integration/services/persistence/record-locks.integration.spec.js#leaves
      the role at its version and lock, and records nothing;
      services-crm:modules/entities/tests/integration/services/persistence/record-locks.queries.integration.spec.js#answers
      the newest two changes of the record, every property mapped, and where the
      page ended;
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers 403
      naming sys_locked for a replacement that sets a lock (E-14);
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers 409
      IllegalOperation, not 403, for a replacement of a contact locked against
      updates, the lock sent back unchanged;
      services-crm:modules/entities/tests/integration/services/persistence/record-locks.integration.spec.js#refuses
      an element id the organization does not have, releasing nothing;
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers 409
      to a replacement that only clears an element lock without the unlock key,
      and 200 with it (CRM-ACC-0035);
      services-crm:modules/entities/tests/integration/services/persistence/record-locks.integration.spec.js#refuses
      the unlock, naming the protection; services-crm:docs/record-locks.md#Not
      covered yet
    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-locks.md
      - modules/entities/aws/api/record-locks.routes.ts
      - modules/entities/tests/e2e/contacts.e2e.spec.js
      - >-
        modules/entities/tests/integration/services/persistence/record-locks.integration.spec.js
      - >-
        modules/entities/tests/integration/services/persistence/record-locks.queries.integration.spec.js
  - repo: react-console
    role: console
    paths:
      - 'app/(console)/console/[tenantId]/crm/contacts/'
      - acceptance/crm/playwright/journeys/contacts/CRM-ACC-0035.spec.ts
exercised_by: [FAS-CRM-ENT-07-01, FAS-CRM-ENT-07-02, FAS-CRM-ENT-07-04]
suites: [TST-CRM-ENT-774, TST-CRM-ENT-775, TST-CRM-ENT-786, TST-CRM-ENT-794]
---

# Control record locks and system protection

## Purpose

Administrators need to protect sensitive records or individual details without creating an irreversible trap. This capability distinguishes a customer's reversible lock from Rhythm's protected system configuration and records authorized lock changes. It keeps restrictions meaningful across record saves, deletion, and owned-element handling rather than treating a lock as an editable checkbox.

## How It Works

Contacts, organizations, and contact roles expose lock and unlock actions with separate administrator permissions. A change supplies the read version and a nonblank reason, advances the record version, and appends actor, time, and before-and-after values to the lock audit. Repeating the current state produces no audit or record change.

A full lock blocks ordinary updates and deletion; a delete lock allows corrections but prevents removal. Owned-element locks constrain changes and removal independently of the parent. An audited unlock can release all or selected element locks, and an unknown element refuses the whole request. The narrower ordinary-save path for clearing an element lock still requires unlock authority and cannot combine other changes to that locked element.

Ordinary contact and organization writes cannot alter parent locks. Contact-role writes retain their specified delete-lock behavior, while full changes use the audited actions. Protected system records refuse customer lock, unlock, and delete actions even when the caller holds the keys, but ordinary fields remain editable. No seeded CRM record currently carries that protection. Other classification types enforce locks without exposing these audited actions yet.

## Example

An administrator locks Ruth's contact against deletion while leaving ordinary corrections possible, recording why retention is needed. Later she reads the current version and unlocks it with a reason. The audit identifies both decisions. If a phone element remains locked, she can explicitly release that element through the supported unlock action; a nonexistent element identity refuses the request without releasing any locks.

Ruth's delete lock leaves her coordinator able to correct an ordinary phone, but the same coordinator cannot release the lock merely by returning a different lock value in the contact payload. The administrator uses the authorized action with the version she read. If another change advanced that version, she rereads before deciding whether release is still appropriate. A request naming one known phone and one unknown element releases neither. Reading the lock audit afterwards connects the successful state changes with their reasons and actors; repeating an already-applied state supplies no additional audit event.

## Why It Works This Way

Our reading is that reversible customer protection needs an authorized route that can operate despite the restriction it is releasing. Otherwise a full lock becomes permanent by accident. Separate keys and required reasons make release accountable rather than letting any record editor weaken protection. Version checks stop an administrator from changing a restriction based on outdated information.

System protection expresses a different authority and therefore cannot be bypassed by customer unlock permission. Element safeguards prevent deleting or editing through the parent from circumventing a child restriction. Reorders can move locked catalogue entries because ordering the shared list differs from changing their protected content. Missing actions on other types and the absence of protected seeds remain boundaries, not evidence that those types are freely unlockable or that seed-protection policy is decided.

## Relationships

Save corrections without overwriting another change and Remove records and their owned information enforce locks during ordinary mutations. Archive respects update locks. Inspect a record’s changes over time captures contact and organization changes, while the lock audit records its own focused decisions. Platform security evaluates permissions; Rhythm's protected-record mechanism remains outside customer administration authority.
