---
id: STATE-PFM-SEC-011
title: Portal contact challenge
object: DOM-PFM-SEC-003
product_area: platform
module: security
revision: 1
status: draft
design_intent: proposed
implementation_evidence: observed
source_sha:
  services-platform: ad3f6a6582843008ed0f5a17b131878d15829af1
states:
- name: live
  meaning: An unspent challenge exists and its code delivery has been attempted; successful send precedes the
    returned challenge response.
  provenance: observed
  source: 'services-platform at ad3f6a65: modules/security/db/portal-contact-challenges.table.ts; services/portal/contact-match.ts,
    contact-match-rules.ts, verification-code-sender.ts.'
- name: verified
  meaning: Correct-code verification linked the member to the selected CRM contact.
  provenance: observed
  source: services-platform:modules/security/services/portal/contact-match.ts:verifyContactChallenge
- name: exhausted
  meaning: Five failed attempts consumed the challenge.
  provenance: observed
  source: services-platform:modules/security/db/portal-contact-challenges.table.ts:PORTAL_CHALLENGE_MAX_ATTEMPTS
- name: expired
  meaning: The ten-minute validity interval ended.
  provenance: observed
  source: services-platform:modules/security/db/portal-contact-challenges.table.ts:PORTAL_CHALLENGE_OUTCOMES
- name: superseded
  meaning: A replacement challenge or failed code delivery made the challenge unusable.
  provenance: observed
  source: services-platform:modules/security/services/portal/contact-match.ts:startContactChallenge
transitions:
- from: live
  to: verified
  trigger: The portal member submits the matching verification code.
  guard: The challenge belongs to the member, is usable, and the contact is not already linked in this workspace.
  side_effects:
  - Write the contact link; fresh context evaluation exposes it
  who: Portal Member
  provenance: observed
  source: services-platform:modules/security/services/portal/contact-match.ts:verifyContactChallenge
- from: live
  to: exhausted
  trigger: The member submits a fifth incorrect code.
  guard: Attempt accounting reaches the configured limit.
  side_effects: []
  who: Portal Member
  provenance: observed
  source: services-platform:modules/security/db/portal-contact-challenges.table.ts:PORTAL_CHALLENGE_MAX_ATTEMPTS
- from: live
  to: expired
  trigger: The ten-minute challenge deadline passes.
  guard: Usability is checked when the challenge is used.
  side_effects: []
  who: nobody (clock)
  provenance: observed
  source: services-platform:modules/security/db/portal-contact-challenges.table.ts:PORTAL_CHALLENGE_OUTCOMES
- from: live
  to: superseded
  trigger: The member starts a replacement challenge.
  guard: The member has not already linked a contact.
  side_effects: []
  who: Portal Member
  provenance: observed
  source: services-platform:modules/security/services/portal/contact-match.ts:startContactChallenge
invalid:
- from: verified
  to: live
  why: An already linked member cannot open another matching challenge.
  provenance: observed
  source: 'services-platform at ad3f6a65: modules/security/db/portal-contact-challenges.table.ts; services/portal/contact-match.ts,
    contact-match-rules.ts, verification-code-sender.ts.'
- from: exhausted
  to: verified
  why: An exhausted challenge cannot verify; the member needs a replacement or the decline path.
  provenance: observed
  source: 'services-platform at ad3f6a65: modules/security/db/portal-contact-challenges.table.ts; services/portal/contact-match.ts,
    contact-match-rules.ts, verification-code-sender.ts.'
concurrency: Attempts lock their challenge row and transact accounting with verification/linking. Starting
  challenges supersedes currently visible rows before insertion, while member links check that another claim
  is absent. The inspected paths do not provide a shared contact/member lock or declared live uniqueness constraint,
  so simultaneous start and cross-member linking exclusion remain unknown. Failed delivery attempts to spend
  the created challenge.
invariants:
- INV-DOM-PFM-SEC-003-22
evidence:
- 'services-platform at ad3f6a65: modules/security/db/portal-contact-challenges.table.ts; services/portal/contact-match.ts,
  contact-match-rules.ts, verification-code-sender.ts.'
examined_at: '2026-10-23'
examiner: codex
---

# Portal contact challenge

## The Lifecycle

```mermaid
stateDiagram-v2
    state "Live" as live
    state "Verified" as verified
    state "Exhausted" as exhausted
    state "Expired" as expired
    state "Superseded" as superseded
    live --> verified
    live --> exhausted
    live --> expired
    live --> superseded
```

The diagram summarizes the observed transition rows; declared edges without an observed writer remain outside the operational graph.

## Transitions Explained

The challenge proves possession of a candidate contact’s e-mail address or phone number. It is separate from the signed candidate offer and from the eventual contact link.

Delivery occurs after the challenge is stored. Failure attempts to supersede that challenge; the design does not treat the provider send and database state as an atomic operation.

## What Cannot Happen

An already linked member cannot open another matching challenge.

An exhausted challenge cannot verify; the member needs a replacement or the decline path.

## Concurrency and Timing

Attempts lock their challenge row and transact accounting with verification/linking. Starting challenges supersedes currently visible rows before insertion, while member links check that another claim is absent. The inspected paths do not provide a shared contact/member lock or declared live uniqueness constraint, so simultaneous start and cross-member linking exclusion remain unknown. Failed delivery attempts to spend the created challenge.
