---
id: CAP-CRM-ENT-003
title: Locate people and organizations
product_area: crm
module: entities
revision: 1
status: approved
product_intent: proposed
implementation_evidence: observed
source_sha:
  services-crm: 7ea48a414c42eade10d60753b8bcc323b1dd726f
  react-console: da63075876b2c5f4a6af07d457bb24ea8bf4c0c7
intent_sources:
  - >-
    services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#3.
    How it works for the user
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#M1-D1'
  - 'services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.md#M2-D3'
  - >-
    services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.md#CRM-ACC-0064
  - 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#D-01'
purpose: >-
  Staff and integrations locate records and read the information their work
  requires.
outcomes:
  - A list walks the tenant’s records a page at a time with a stable cursor.
  - >-
    The people beneath an organization are the same set wherever they are
    listed.
  - >-
    A record returns every stored detail, and another tenant’s record is never
    reachable.
actors:
  - ACT-CRM-001
  - ACT-CRM-003
  - ACT-CRM-004
  - ACT-CRM-002
surfaces:
  - console
  - api
preconditions: []
postconditions: []
permissions:
  - key: crm.contacts.read
    where_checked: api
    scope: tenant
  - key: crm.organizations.read
    where_checked: api
    scope: tenant
  - key: crm.contacts.query
    where_checked: service
    scope: tenant
  - key: crm.organizations.query
    where_checked: service
    scope: tenant
  - key: crm.application.access
    where_checked: service
    scope: tenant
rules:
  - id: BR-CAP-CRM-ENT-003-01
    proved_by: [ATS-CRM-ENT-003-01, TST-CRM-ENT-786]
    statement: >-
      Contact and organization lists use number order, return the requested page
      size from 1 to 100 with 25 as the default, supply an opaque continuation
      cursor and reject unsupported parameters, invalid limits and unrecognized
      cursors.
    kind: validation
    enforced_at:
      - api
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers 400
      for a cursor it did not answer
    origin: none
    examples:
      positive: >-
        Accepted example: limit=2 over five contacts walks three pages, the last
        without a cursor.
      negative: 'Rejected example: limit=101 is rejected 400.'
  - id: BR-CAP-CRM-ENT-003-02
    proved_by: [ATS-CRM-ENT-003-02, TST-CRM-ENT-787, TST-CRM-ENT-795]
    statement: >-
      Name search checks display and sort names without case sensitivity, treats
      % and _ as literal characters, accepts up to 100 characters and disregards
      blank input; nicknames can match within computed names, while e-mails and
      record numbers are excluded.
    kind: validation
    enforced_at:
      - api
      - service
    must_prove: true
    provenance: observed
    source: >-
      services-crm:modules/entities/services/persistence/contacts.ts:listContactFields
    origin: none
    examples:
      positive: 'Accepted example: "smith, j" finds "Smith, John".'
      negative: 'Rejected example: a search of 101 characters is rejected.'
  - id: BR-CAP-CRM-ENT-003-03
    proved_by: [ATS-CRM-ENT-003-03, ATS-CRM-ENT-003-15, ATS-CRM-ENT-009-18, TST-CRM-ENT-787]
    statement: >-
      An organization filter returns each contact once when any affiliation to
      that organization is effective on the workspace’s current day, whether
      primary or secondary, and returns no contacts for an unknown organization.
    kind: policy
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.queries.integration.spec.js#answers
      a contact whose affiliation to the organization is not its primary one
    origin: 'services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.md#M2-D3'
  - id: BR-CAP-CRM-ENT-003-04
    proved_by: [ATS-CRM-ENT-002-13, ATS-CRM-ENT-003-04, ATS-CRM-ENT-003-14, TST-CRM-ENT-776, TST-CRM-ENT-787]
    statement: >-
      The descendant listing returns each currently affiliated contact once
      across the organization and all descendants, without paging, in a
      depth-first traversal ordered by organization and contact names, skipping
      cycles and returning nothing for an unknown root.
    kind: calculation
    enforced_at:
      - service
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.queries.integration.spec.js#includes
      a contact whose affiliation in effect to an organization of the walk is
      not its primary one (M2-D3)
    origin: 'services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.md#M2-D3'
  - id: BR-CAP-CRM-ENT-003-05
    proved_by: [ATS-CRM-ENT-003-05, ATS-CRM-ENT-003-16, ATS-CRM-ENT-013-12, TST-CRM-ENT-786, TST-CRM-ENT-787]
    statement: >-
      Selecting contacts through the list excludes contacts whose status is
      inactive or deceased.
    kind: policy
    enforced_at:
      - api
      - service
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers only
      the contacts a picker may offer: not the inactive, not the deceased
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#M1-D1'
  - id: BR-CAP-CRM-ENT-003-06
    proved_by: [ATS-CRM-ENT-001-18, ATS-CRM-ENT-003-10, ATS-CRM-ENT-003-12, ATS-CRM-ENT-003-13, ATS-CRM-ENT-007-07, TST-CRM-ENT-774]
    statement: >-
      A record read includes its collections and current creator and modifier
      display names, or only the requested projected fields together with its
      id.
    kind: policy
    enforced_at:
      - api
      - service
    must_prove: false
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers only
      the fields `?fields=` named, with the id
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.plan.md#A2'
  - id: BR-CAP-CRM-ENT-003-07
    proved_by: [ATS-CRM-ENT-001-23, ATS-CRM-ENT-002-19, ATS-CRM-ENT-003-06, ATS-CRM-ENT-003-17, ATS-CRM-ENT-004-23, ATS-CRM-ENT-005-18, ATS-CRM-ENT-006-17, ATS-CRM-ENT-007-09, ATS-CRM-ENT-008-11, ATS-CRM-ENT-011-17, TST-CRM-ENT-774, TST-CRM-ENT-775, TST-CRM-ENT-786]
    statement: >-
      Routes operate within their path’s tenant, reject callers without tenant
      reach and conflicting tenant ids in the body, and treat matching ids in
      separate tenants as separate records.
    kind: permission
    enforced_at:
      - api
      - service
      - database
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers the
      other tenant its own contact for that id, to a context that covers it
    origin: none
  - id: BR-CAP-CRM-ENT-003-08
    proved_by: [ATS-CRM-ENT-003-07, TST-CRM-ENT-774, TST-CRM-ENT-775]
    statement: >-
      After authorization, routes naming a merged-away record return 410 with
      its survivor id, while ids that never existed return 404.
    kind: policy
    enforced_at:
      - api
    must_prove: true
    provenance: observed
    source: 'services-crm:modules/entities/aws/api/contacts.routes.ts:contactsRoutes'
    origin: 'D-20 (cited in services-crm:modules/entities/aws/api/contacts.routes.ts)'
  - id: BR-CAP-CRM-ENT-003-09
    proved_by: [ATS-CRM-ENT-001-23, ATS-CRM-ENT-002-19, ATS-CRM-ENT-003-08, ATS-CRM-ENT-003-17, ATS-CRM-ENT-004-14, ATS-CRM-ENT-004-23, ATS-CRM-ENT-005-18, ATS-CRM-ENT-006-17, ATS-CRM-ENT-007-09, ATS-CRM-ENT-008-11, ATS-CRM-ENT-011-17, TST-CRM-ENT-786]
    statement: >-
      CRM routes reject callers lacking application access even when they hold
      the record permissions.
    kind: permission
    enforced_at:
      - api
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/application-access-enforced.e2e.spec.js#is
      refused where the assignment DENIES crm.application.access, though the
      route itself is served
    origin: none
  - id: BR-CAP-CRM-ENT-003-10
    proved_by: [ATS-CRM-ENT-003-09, ATS-CRM-ENT-003-17, TST-CRM-ENT-786]
    statement: >-
      A Rhythm staff grant spanning every tenant does not cross a protected
      customer tenant boundary; customer access requires a customer-level grant.
    kind: permission
    enforced_at:
      - api
    must_prove: true
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/platform-scope.e2e.spec.js#refuses
      every one of them to a staff slice reaching every tenant, 403 at the
      tenant boundary
    origin: none
  - id: BR-CAP-CRM-ENT-003-11
    proved_by: [ATS-CRM-ENT-003-17]
    statement: >-
      Contact and organization dataset queries require both the dataset query
      permission and the corresponding record read permission.
    kind: permission
    enforced_at:
      - service
    must_prove: false
    provenance: documented
    source: >-
      services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.md#CRM-ACC-0064
    origin: >-
      services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.md#CRM-ACC-0064
  - id: BR-CAP-CRM-ENT-003-12
    proved_by: [ATS-CRM-ENT-003-10, TST-CRM-ENT-774]
    statement: >-
      Contact reads include affiliated organization names even when the caller
      lacks organization read permission.
    kind: policy
    enforced_at:
      - service
    must_prove: false
    provenance: test
    source: >-
      react-console:acceptance/crm/playwright/journeys/contacts/CRM-ACC-0064.spec.ts#CRM-ACC-0064
    origin: none
  - id: BR-CAP-CRM-ENT-003-13
    proved_by: [ATS-CRM-ENT-003-12]
    statement: >-
      Record and list reads add public image links for photos and logos and
      15-minute attachment download links for documents; writes disregard
      returned display links.
    kind: calculation
    enforced_at:
      - api
      - service
    must_prove: false
    provenance: test
    source: >-
      services-crm:modules/entities/tests/e2e/display-links.e2e.spec.js#answers
      a presigned GET that lasts 15 minutes and downloads as an attachment for a
      document custom field
    origin: none
  - id: BR-CAP-CRM-ENT-003-14
    proved_by: []
    statement: >-
      The product should display stored contact details through metadata-driven
      Contact 360 cards and identify communication restrictions and deceased
      status in the header.
    kind: policy
    enforced_at: []
    must_prove: false
    provenance: documented
    source: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#D-01'
    origin: 'services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#D-01'
  - id: BR-CAP-CRM-ENT-003-15
    proved_by: []
    statement: >-
      Quick Search processes committed record changes through change events,
      allowing a successful save to precede an updated search result.
    kind: policy
    enforced_at:
      - service
    must_prove: false
    provenance: observed
    source: >-
      services-crm:modules/entities/services/quick-search/quick-search-indexer.ts:quickSearchIndexerOf
    origin: none
scenarios:
  - id: SC-CAP-CRM-ENT-003-01
    given: Vic works at Acme Chapter but his primary organization is his school.
    when: >-
      The staff member lists Acme National's people and filters the contacts
      list by Acme Chapter.
    then: Vic is in both returns.
    provenance: test
    source: >-
      react-console:acceptance/crm/playwright/journeys/affiliations/CRM-ACC-0189.spec.ts#CRM-ACC-0189
  - id: SC-CAP-CRM-ENT-003-02
    given: A contact merged into another.
    when: The old id is read.
    then: '410 with the survivor''s id, and the console is saved on the survivor.'
    provenance: test
    source: >-
      react-console:acceptance/crm/playwright/journeys/contacts/CRM-ACC-0050.spec.ts#CRM-ACC-0050
edge_cases:
  - id: SC-CAP-CRM-ENT-003-03
    given: 'A parent cycle one, two, three, one.'
    when: The people beneath two are listed.
    then: Each organization is walked once and nothing is missed.
    provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/contacts.integration.spec.js#walks
      the cycle once from an organization in its middle
constraints: []
depends_on: []
related:
  - CAP-CRM-ENT-001
  - CAP-CRM-ENT-002
  - CAP-CRM-ENT-009
  - CAP-CRM-ENT-013
journeys:
  - JNY-CRM-ENT-001
  - JNY-CRM-ENT-002
  - JNY-CRM-ENT-003
  - JNY-CRM-ENT-005
open_questions: []
feature: FEAT-CRM-ENT-01
concepts:
  - CON-CRM-ENT-001
  - CON-CRM-ENT-002
  - CON-CRM-ENT-003
evidence:
  - >-
    `services-crm` at `7ea48a414c42eade10d60753b8bcc323b1dd726f`: examined
    `aws/api/contacts.routes.ts`, `organizations.routes.ts`, `paging.ts`, `contact-api-mapper.ts`,
    `display-links.ts`, `services/persistence/contacts.ts` (list, descendants), `organizations.ts`
    (list), `queries/contacts.query.ts`, `aws/crm.service.ts`; tests `e2e/contacts.e2e.spec.js`,
    `e2e/organizations.e2e.spec.js`, `e2e/display-links.e2e.spec.js`,
    `e2e/application-access-enforced.e2e.spec.js`, `e2e/platform-scope.e2e.spec.js`,
    `integration/services/api/list-pages.integration.spec.js`,
    `contacts.queries.integration.spec.js`, `organizations.queries.integration.spec.js`.
  - >-
    `react-console` at `da63075876b2c5f4a6af07d457bb24ea8bf4c0c7`: examined inventory
    RUL-CRM-CON-028 to 034, 036, 044, RUL-CRM-ORG-033, 034, RUL-CRM-AFF-054 to 058; journeys
    CRM-ACC-0038 to 0040, 0045 to 0050, 0055, 0058, 0064, 0189.
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-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
      for a cursor it did not answer;
      services-crm:modules/entities/services/persistence/contacts.ts:listContactFields;
      services-crm:modules/entities/tests/integration/services/persistence/contacts.queries.integration.spec.js#answers
      a contact whose affiliation to the organization is not its primary one;
      services-crm:modules/entities/tests/integration/services/persistence/contacts.queries.integration.spec.js#includes
      a contact whose affiliation in effect to an organization of the walk is
      not its primary one (M2-D3);
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers only
      the contacts a picker may offer: not the inactive, not the deceased;
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers only
      the fields `?fields=` named, with the id;
      services-crm:modules/entities/tests/e2e/contacts.e2e.spec.js#answers the
      other tenant its own contact for that id, to a context that covers it;
      services-crm:modules/entities/aws/api/contacts.routes.ts:contactsRoutes;
      services-crm:modules/entities/tests/e2e/application-access-enforced.e2e.spec.js#is
      refused where the assignment DENIES crm.application.access, though the
      route itself is served;
      services-crm:modules/entities/tests/e2e/platform-scope.e2e.spec.js#refuses
      every one of them to a staff slice reaching every tenant, 403 at the
      tenant boundary;
      services-crm:docs/milestones/CRM-M2-affiliations-roles-compliance.md#CRM-ACC-0064;
      react-console:acceptance/crm/playwright/journeys/contacts/CRM-ACC-0064.spec.ts#CRM-ACC-0064;
      services-crm:modules/entities/tests/e2e/display-links.e2e.spec.js#answers
      a presigned GET that lasts 15 minutes and downloads as an attachment for a
      document custom field;
      services-crm:docs/milestones/CRM-M1-contacts-and-organizations.md#D-01;
      services-crm:modules/entities/services/quick-search/quick-search-indexer.ts:quickSearchIndexerOf
    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/milestones/CRM-M1-contacts-and-organizations.md
      - docs/milestones/CRM-M2-affiliations-roles-compliance.md
      - modules/entities/aws/api/contacts.routes.ts
      - modules/entities/services/persistence/contacts.ts
      - modules/entities/services/quick-search/quick-search-indexer.ts
      - modules/entities/tests/e2e/application-access-enforced.e2e.spec.js
      - modules/entities/tests/e2e/contacts.e2e.spec.js
      - modules/entities/tests/e2e/display-links.e2e.spec.js
      - modules/entities/tests/e2e/platform-scope.e2e.spec.js
      - >-
        modules/entities/tests/integration/services/persistence/contacts.integration.spec.js
      - >-
        modules/entities/tests/integration/services/persistence/contacts.queries.integration.spec.js
  - repo: react-console
    role: console
    paths:
      - 'app/(console)/console/[tenantId]/crm/contacts/'
      - acceptance/crm/playwright/journeys/affiliations/CRM-ACC-0189.spec.ts
      - acceptance/crm/playwright/journeys/contacts/CRM-ACC-0050.spec.ts
      - acceptance/crm/playwright/journeys/contacts/CRM-ACC-0064.spec.ts
exercised_by: [FAS-CRM-ENT-01-01, FAS-CRM-ENT-01-02, FAS-CRM-ENT-01-03, FAS-CRM-ENT-01-04, FAS-CRM-ENT-01-05, FAS-CRM-ENT-02-01, FAS-CRM-ENT-02-02, FAS-CRM-ENT-02-04, FAS-CRM-ENT-02-05, FAS-CRM-ENT-05-04]
suites: [TST-CRM-ENT-774, TST-CRM-ENT-775, TST-CRM-ENT-776, TST-CRM-ENT-786, TST-CRM-ENT-787, TST-CRM-ENT-795]
---

# Locate people and organizations

## Purpose

Staff need to locate a person or organization before they can inspect or use its record. This capability distinguishes name discovery, current-affiliate discovery, and full record reading, so a short label does not become the only account of identity or membership. It also provides controlled reads for integrations with narrower information needs.

## How It Works

Lists return pages ordered by record number and supply an opaque continuation cursor. Name search examines the display and sort names; it is not an e-mail or record-number lookup. A direct read returns the aggregate and attribution, or a selected projection with the identity needed to recognize it.

Current organization filtering considers every affiliation effective on the workspace day. Descendant discovery traverses the organization hierarchy and returns each qualifying contact once, including secondary affiliates. This answers a different question from Quick Search's primary-organization subtitle. Quick Search reacts to committed change events, so a direct read can show a saved change before search catches up.

Access is checked at the application, workspace, and record permission boundaries. Knowing an identity does not grant reach. After authorization, a merged-away record can identify its survivor, while a never-existing identity is missing. Record reads also supply image and document display links through Platform files. The proposed metadata-card presentation remains distinguishable from service-read evidence.

## Example

Vic is primarily affiliated with his school and also currently serves Harbor West Chapter. A coordinator filters contacts by the chapter and finds Vic; a national body's descendant listing finds him once as well. Quick Search still labels him with the school. Opening his record explains both affiliations, so the different subtitle does not imply he is absent from the chapter.

The coordinator also inspects what each discovery surface proves. A chapter list answers about people with effective ties today and includes Vic only once, even if more than one tie qualifies. The organization's dated people view can instead show separate relationship rows and past service. An integration retrieving selected fields checks the returned identity with its requested values; projecting a smaller payload does not grant access to another workspace. A refused application or record permission stops the read. If Quick Search lags a save, the coordinator opens the record directly rather than treating an older subtitle as evidence that the save failed.

## Why It Works This Way

Our reading is that bounded pages and opaque cursors keep ordinary discovery predictable without exposing an implementation-specific paging key. Literal search punctuation prevents a name from becoming an unintended wildcard expression. Distinct people rather than affiliation rows answer the current-membership question without double-counting a person with several qualifying ties.

Application access and workspace reach prevent record permission keys from crossing a customer's boundary. Projection limits a caller's requested payload but does not replace authorization. A survivor response distinguishes a changed identity from missing data. Event-fed search explains why successful persistence and search freshness need separate verification. The architecture's desired freshness discipline does not establish a measured search delay at this milestone.

## Relationships

Contact Records and Organization Records establish the identities returned here. Affiliations and Roles supplies current membership and workspace-date semantics, while archive controls default availability. Platform owns query execution and Quick Search infrastructure, reached through their contracts and committed change events. Data Quality supplies merge survivors; history has separate permission and reading behavior.
