---
id: CAP-PFM-SEC-020
title: Match portal memberships to CRM contacts
feature: FEAT-PFM-SEC-06
product_area: platform
module: security
revision: 1
status: draft
product_intent: proposed
implementation_evidence: observed
supersedes: []
source_sha:
  services-platform: ad3f6a6582843008ed0f5a17b131878d15829af1
intent_sources:
  - 'services-platform:docs/security/portal-contact-claim.md#D-26'
purpose: >-
  Contact matching can attach a portal member to an existing CRM contact or create a new one. Ambiguous
  candidates require verification using a channel belonging to the chosen contact.
outcomes:
  - A portal membership can identify the association contact behind it.
  - Ambiguous links require proof delivered through the candidate’s channel.
  - A declined match can leave a possible-duplicate pair for later review.
concepts:
  - CON-PFM-SEC-003
actors:
  - ACT-PFM-003
  - ACT-PFM-006
surfaces:
  - portal
  - api
preconditions: []
postconditions: []
permissions:
  - key: platform.security-self.read
    where_checked: api
    scope: record
rules:
  - id: BR-CAP-PFM-SEC-020-01
    proved_by: [ATS-PFM-SEC-020-01, TST-PFM-SEC-175, TST-PFM-SEC-217]
    statement: >-
      A single eligible contact sharing the member’s email address, compared without case, is linked
      automatically. If CRM returns no likely match, signup details are used to create and link a new
      contact.
    kind: calculation
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#links
      the one contact holding the member’s own address without asking
    origin: none
  - id: BR-CAP-PFM-SEC-020-02
    proved_by: [ATS-PFM-SEC-020-02, TST-PFM-SEC-175, TST-PFM-SEC-217]
    statement: >-
      A contact already linked to another portal member in that workspace cannot be selected or linked
      again.
    kind: invariant
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#never
      offers a contact another member of the tenant already links
    origin: none
  - id: BR-CAP-PFM-SEC-020-03
    proved_by: [ATS-PFM-SEC-020-03, TST-PFM-SEC-175, TST-PFM-SEC-217]
    statement: >-
      Ambiguous matching returns no more than three distinct candidates ordered by score. Responses
      expose masked hints and available verification channels without contact identifiers.
    kind: policy
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-platform:modules/security/tests/unit/services/portal/contact-match-rules.spec.ts#offers at
      most three, highest score first, each contact once
    origin: none
  - id: BR-CAP-PFM-SEC-020-04
    proved_by: [ATS-PFM-SEC-020-04, TST-PFM-SEC-175, TST-PFM-SEC-216]
    statement: >-
      An offered candidate is represented by a sealed token scoped to the member, workspace and expiry.
      Candidates or channels outside that offer are rejected.
    kind: validation
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-platform:modules/security/tests/unit/services/portal/candidate-token.spec.ts#refuses
      another member’s token
    origin: none
  - id: BR-CAP-PFM-SEC-020-05
    proved_by: [ATS-PFM-SEC-020-05, ATS-PFM-SEC-020-10, ATS-PFM-SEC-020-11, ATS-PFM-SEC-020-12, TST-PFM-SEC-160, TST-PFM-SEC-219]
    statement: >-
      Verification sends a code to the candidate contact’s email, or to its phone only when SMS is
      enabled. Challenges last ten minutes, replace earlier live challenges, allow at most five starts
      per hour and are exhausted by five incorrect codes.
    kind: validation
    enforced_at:
      - service
      - database
    must_prove: true
    provenance: test
    source: >-
      services-platform:modules/security/tests/integration/services/persistence/portal-contact-challenges.integration.spec.js#refuses
      a sixth start within an hour
    origin: none
  - id: BR-CAP-PFM-SEC-020-06
    proved_by: [ATS-PFM-SEC-019-11, ATS-PFM-SEC-020-06, ATS-PFM-SEC-020-09, TST-PFM-SEC-175]
    statement: >-
      A valid verification code establishes the contact link. Linked members cannot start another
      challenge, and persisted challenges contain a code digest rather than the code.
    kind: lifecycle
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#links
      the member to the contact on the right code
    origin: none
  - id: BR-CAP-PFM-SEC-020-07
    proved_by: [ATS-PFM-SEC-020-07, TST-PFM-SEC-175]
    statement: >-
      Declining all candidates creates and links a contact from signup details. The highest-ranked
      declined candidate is retained as a possible duplicate for staff review.
    kind: lifecycle
    enforced_at:
      - service
    must_prove: true
    provenance: test
    source: >-
      services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#names
      the best declined candidate, so staff review the pair
    origin: none
  - id: BR-CAP-PFM-SEC-020-08
    proved_by: [ATS-PFM-SEC-014-13, ATS-PFM-SEC-020-08, ATS-PFM-SEC-020-13, TST-PFM-SEC-091]
    statement: >-
      A fresh authorizer evaluation includes the portal member’s current contact link and excludes that
      claim for console users. Gateway caching can retain a previous link for sixty seconds; signed
      authority retains its claim until expiry.
    kind: invariant
    enforced_at:
      - api
    must_prove: true
    provenance: documented
    source: 'services-platform:docs/security/portal-contact-claim.md#When a change is seen'
    origin: 'services-platform:docs/security/portal-contact-claim.md#D-26'
  - id: BR-CAP-PFM-SEC-020-09
    proved_by: []
    statement: >-
      When CRM matching functions are absent, the service returns no matching state and creates no
      contact link.
    kind: policy
    enforced_at:
      - service
    must_prove: false
    provenance: test
    source: >-
      services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#answers
      none and links nothing
    origin: none
scenarios:
  - id: SC-CAP-PFM-SEC-020-01
    given: CRM returns two plausible contacts for a portal member.
    when: The member selects one and supplies the code delivered to that contact’s email.
    then: 'The verified contact becomes the membership’s link, and further matching returns the linked state.'
    provenance: test
    source: >-
      services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#refuses
      another challenge once the member is linked
edge_cases: []
constraints: []
depends_on:
  - CAP-PFM-SEC-019
related:
  - CAP-PFM-SEC-014
journeys: []
open_questions: []
evidence:
  - >-
    Pinned services-platform references for the structured rows: docs/security/portal-contact-claim.md;
    modules/security/tests/integration/services/persistence/portal-contact-challenges.integration.spec.js;
    modules/security/tests/integration/services/portal/contact-match.integration.spec.js;
    modules/security/tests/unit/services/portal/candidate-token.spec.ts;
    modules/security/tests/unit/services/portal/contact-match-rules.spec.ts.
controls: [TC-AVL-005, TC-AZ-001, TC-AZ-002, TC-AZ-003, TC-AZ-004, TC-AZ-006, TC-CRY-001, TC-CRY-002, TC-CRY-003, TC-CRY-004, TC-DAT-001, TC-DAT-002, TC-DAT-003, TC-IAM-001, TC-IAM-002, TC-IAM-004, TC-IAM-005, TC-LOG-004, TC-LOG-005, TC-PI-001, TC-PI-005, TC-PRV-001, TC-PRV-002, TC-TEN-001, TC-TEN-002, TC-TEN-003, TC-TEN-004, TC-VEN-001, TC-VEN-002, TC-VEN-003]
exercised_by: [FAS-PFM-SEC-06-01, FAS-PFM-SEC-06-02, FAS-PFM-SEC-06-03, FAS-PFM-SEC-06-04, FAS-PFM-SEC-06-05]
suites: [TST-PFM-SEC-091, TST-PFM-SEC-160, TST-PFM-SEC-175, TST-PFM-SEC-216, TST-PFM-SEC-217, TST-PFM-SEC-219]
---
# Match portal memberships to CRM contacts

## Purpose

Contact matching can attach a portal member to an existing CRM contact or create a new one. Ambiguous candidates require verification using a channel belonging to the chosen contact.

## How It Works

After registration, the member asks for contact matching. An eligible exact address match can be linked directly; otherwise the member reviews masked candidates or receives a new contact. Choosing a candidate starts proof through an available channel belonging to that contact. The member enters the code and sees either the linked result or a refusal explaining the remaining action. Declining all candidates creates a new contact and records the possible duplicate. When CRM matching functions are unavailable, the operation links nothing. Fresh authorization reads the current link, while an existing signed result keeps its own claim until expiry.

## Relationships

- Portal identity establishes the membership.
- Derived access uses its linked contact as a subject.
- CRM owns the contact records and matching inputs.
