---
id: WF-PFM-SEC-015
title: Link a portal member to a contact
trigger: An unlinked signed-in portal member enters the likely-contact match flow.
product_area: platform
module: security
revision: 1
status: draft
design_intent: proposed
implementation_evidence: observed
inference: []
source_sha:
  services-platform: ad3f6a6582843008ed0f5a17b131878d15829af1
implements:
- CAP-PFM-SEC-020
moves:
- STATE-PFM-SEC-011
crosses:
- SVC-PFM-SEC-security
idempotency: Existing linked membership returns the link. Sequential challenge replacement supersedes visible
  earlier rows and link writes refuse a visible existing contact claim. Concurrent exclusion across different
  member rows or simultaneous empty challenge sets is unknown. CRM creation remains separate from the later
  link write.
concurrency: Operations act on the caller’s membership. Challenge attempts lock their own row; different members
  claiming one contact do not share that row lock. The conditional absence check and non-unique contact index
  do not prove concurrent exclusion. CRM creation can commit before an uncompleted member link.
hops:
- n: 1
  actor_or_service: Security service (match)
  action: Ask CRM for likely contacts; link an eligible exact match, offer up to three masked sealed candidates,
    or create and link a new contact.
  anchor: services-platform:modules/security/services/portal/contact-match.ts:getContactMatch
  seam: sync
  on_failure: Return none when the directory functions are unavailable; propagate other failures.
  left_inconsistent: CRM creation can succeed before the later membership link fails.
  observer: The member response and service logs expose the outcome.
  provenance: observed
  source: services-platform:modules/security/services/portal/contact-match.ts (header)
- n: 2
  actor_or_service: Security service (challenge)
  action: Validate the sealed candidate and current channels, store a six-digit-code digest, then send the
    code through the permitted channel.
  anchor: services-platform:modules/security/services/portal/contact-match.ts:startContactChallenge
  seam: sync
  on_failure: Refuse invalid candidates; on send failure attempt to spend the challenge and return service-unavailable.
  left_inconsistent: A failed send can leave persisted challenge state until its supersession write succeeds;
    older challenges are already superseded.
  observer: The member sees challenge or delivery refusal; logs retain the cause.
  provenance: observed
  source: services-platform:modules/security/services/portal/contact-match.ts (header)
- n: 3
  actor_or_service: Security service (verify)
  action: Verify the submitted code and transact attempt accounting with contact linking.
  anchor: services-platform:modules/security/services/portal/contact-match.ts:verifyContactChallenge
  seam: sync
  on_failure: Reject spent/expired challenges; consume the fifth wrong attempt.
  left_inconsistent: A refused verification adds no contact link, while wrong-attempt accounting remains committed.
  observer: The member receives verification guidance.
  provenance: observed
  source: services-platform:modules/security/services/portal/contact-match.ts (header)
- n: 4
  actor_or_service: Security service and CRM (decline)
  action: Ask CRM to create a contact from sign-up details, flag a declined candidate for duplicate review
    and link the new contact.
  anchor: services-platform:modules/security/services/portal/contact-match.ts:declineContactMatch
  seam: sync
  on_failure: Refuse an already linked member; later link failure does not roll back CRM’s separate transaction.
  left_inconsistent: A new or possible-duplicate CRM contact can remain without the intended membership link.
  observer: Member results and CRM duplicate review expose follow-up work.
  provenance: observed
  source: services-platform:modules/security/services/portal/contact-match.ts (header)
- n: 5
  actor_or_service: Authorizer (next fresh evaluation)
  action: Include the current contact link and applicable assignments in the next fresh signed context.
  anchor: services-platform:modules/security/authorizer/context/signed-context.ts
  seam: sync
  on_failure: Normal authorization and signing failures refuse the request.
  left_inconsistent: Earlier cached contexts can carry the previous link until their expiry.
  observer: Fresh context and the member access document show updated access.
  provenance: observed
  source: services-platform:modules/security/authorizer/context/signed-context.ts (header)
branches: []
waits:
- Code challenges last ten minutes; SMS is available only when the sender setting enables it.
evidence:
- 'services-platform at ad3f6a65: modules/security/services/portal/contact-match.ts, contact-match-rules.ts,
  candidate-token.ts, contact-directory.ts, verification-code-sender.ts; aws/api/portal-contact-match.routes.ts;
  docs/security/portal-contact-claim.md.'
examined_at: '2026-10-23'
examiner: codex
controls: [TC-AVL-001, TC-AVL-004, 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-MON-001, TC-MON-003, TC-PI-001, TC-PI-002, TC-PI-003, TC-PI-004, 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]
---

# Link a portal member to a contact

## What It Does

Likely matching separates candidate selection, possession proof and the eventual member/contact link. Candidate offers are sealed, while challenge storage retains a digest and bounded attempts.

## The Flow

```mermaid
flowchart TD
    H1["Select likely match"]
    H2["Create and send challenge"]
    H3["Verify and link"]
    H4["Decline and create contact"]
    H5["Evaluate updated contact link"]
    H1 -->|candidate selected| H2
    H2 --> H3
    H1 -->|decline candidates| H4
    H1 -->|already or automatically linked| H5
    H3 -->|verified| H5
    H4 -->|linked| H5
```

The diagram separates the relevant success, alternative and failure paths. The numbered hop rows retain the detailed authority checks, delegated work and retry limits.

## When Something Fails

CRM calls use separate transactions from Security writes. Creating a contact does not guarantee the later membership link succeeds. Delivery failure spends the challenge where possible, and unlinked created contacts or possible duplicates remain explicit reconciliation work.

Existing linked membership returns the link. Sequential challenge replacement supersedes visible earlier rows and link writes refuse a visible existing contact claim. Concurrent exclusion across different member rows or simultaneous empty challenge sets is unknown. CRM creation remains separate from the later link write.

Operations act on the caller’s membership. Challenge attempts lock their own row; different members claiming one contact do not share that row lock. The conditional absence check and non-unique contact index do not prove concurrent exclusion. CRM creation can commit before an uncompleted member link.

## Open Design Questions

The recorded findings retain any stronger planning expectation, missing writer or unproved recovery path. Observed behavior does not approve those outstanding product choices.
