---
id: ATS-PFM-SEC-020
scope: CAP-PFM-SEC-020
product_area: platform
module: security
revision: 3
status: proposed
approved_by: null
approved_at: null
source_sha:
  services-platform: ad3f6a6582843008ed0f5a17b131878d15829af1
scenarios:
  - id: ATS-PFM-SEC-020-01
    title: Link an exact address match or create a contact
    level: api
    proves:
      - BR-CAP-PFM-SEC-020-01
    preconditions: Prepare one eligible matching address and a member with no likely CRM match.
    steps: Evaluate matching for both members.
    expected: Link the exact match directly; create and link a contact for the unmatched member.
    negative: false
    data_needs: Use the records described in the setup.
    bound_to:
      - >-
        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
      - TST-PFM-SEC-175
      - >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#creates
        and links a new contact when CRM finds nobody likely, recording no declined pair
      - >-
        services-platform:modules/security/tests/unit/services/portal/contact-match-rules.spec.ts#links
        without asking the one contact holding the member’s own address, compared case-insensitively
      - TST-PFM-SEC-217
    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
    category: rule
    test_references:
      - >-
        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
      - >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#creates
        and links a new contact when CRM finds nobody likely, recording no declined pair
      - >-
        services-platform:modules/security/tests/unit/services/portal/contact-match-rules.spec.ts#links
        without asking the one contact holding the member’s own address, compared case-insensitively
    action_evidence:
      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
    assertion_evidence:
      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
    binding_scope: >-
      The binding applies to the cited executable observations and their dependency boundary. Extra
      setup, operations or combinations in this scenario still require evidence before the whole
      scenario is qualified.
  - id: ATS-PFM-SEC-020-02
    title: Exclude contacts linked by another member
    level: api
    proves:
      - BR-CAP-PFM-SEC-020-02
    preconditions: Prepare a candidate already linked in the workspace.
    steps: Evaluate candidate selection.
    expected: Neither offer nor link that contact.
    negative: true
    data_needs: Use the records described in the setup.
    bound_to:
      - >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#never
        offers a contact another member of the tenant already links
      - TST-PFM-SEC-175
      - >-
        services-platform:modules/security/tests/unit/services/portal/contact-match-rules.spec.ts#never
        offers a contact a portal member already links, and links nothing through one
      - TST-PFM-SEC-217
    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
    category: invariant
    test_references:
      - >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#never
        offers a contact another member of the tenant already links
      - >-
        services-platform:modules/security/tests/unit/services/portal/contact-match-rules.spec.ts#never
        offers a contact a portal member already links, and links nothing through one
    action_evidence:
      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
    assertion_evidence:
      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
    binding_scope: >-
      The binding applies to the cited executable observations and their dependency boundary. Extra
      setup, operations or combinations in this scenario still require evidence before the whole
      scenario is qualified.
  - id: ATS-PFM-SEC-020-03
    title: Return a limited masked candidate list
    level: unit
    proves:
      - BR-CAP-PFM-SEC-020-03
    preconditions: Prepare five scored candidate contacts.
    steps: Request the candidate response.
    expected: Return the highest three distinct candidates with masked hints and no contact identifiers.
    negative: true
    data_needs: Use the records described in the setup.
    bound_to:
      - >-
        services-platform:modules/security/tests/unit/services/portal/contact-match-rules.spec.ts#offers
        at most three, highest score first, each contact once
      - TST-PFM-SEC-217
      - >-
        services-platform:modules/security/tests/unit/services/portal/contact-match-rules.spec.ts#gives
        each candidate its masked hints and nothing else of the contact
      - >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#is
        never shown a contact id
      - TST-PFM-SEC-175
      - >-
        services-platform:modules/security/tests/unit/services/portal/contact-match-rules.spec.ts#masks
        an e-mail address to its first letter, its domain’s first letter and its top-level domain
    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
    category: boundary
    test_references:
      - >-
        services-platform:modules/security/tests/unit/services/portal/contact-match-rules.spec.ts#offers
        at most three, highest score first, each contact once
      - >-
        services-platform:modules/security/tests/unit/services/portal/contact-match-rules.spec.ts#gives
        each candidate its masked hints and nothing else of the contact
      - >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#is
        never shown a contact id
      - >-
        services-platform:modules/security/tests/unit/services/portal/contact-match-rules.spec.ts#masks
        an e-mail address to its first letter, its domain’s first letter and its top-level domain
    action_evidence:
      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
    assertion_evidence:
      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
    binding_scope: >-
      The binding applies to the cited executable observations and their dependency boundary. Extra
      setup, operations or combinations in this scenario still require evidence before the whole
      scenario is qualified.
  - id: ATS-PFM-SEC-020-04
    title: Bind candidate tokens to their intended recipient
    level: unit
    proves:
      - BR-CAP-PFM-SEC-020-04
    preconditions: Prepare a sealed candidate token.
    steps: Open a modified or expired token and reuse the original with another member or workspace.
    expected: Reject all four attempts.
    negative: true
    data_needs: Use the records described in the setup.
    bound_to:
      - >-
        services-platform:modules/security/tests/unit/services/portal/candidate-token.spec.ts#refuses
        a token with one character changed
      - TST-PFM-SEC-216
      - >-
        services-platform:modules/security/tests/unit/services/portal/candidate-token.spec.ts#refuses
        an expired token
      - >-
        services-platform:modules/security/tests/unit/services/portal/candidate-token.spec.ts#refuses
        another member’s token
      - >-
        services-platform:modules/security/tests/unit/services/portal/candidate-token.spec.ts#refuses
        a token of another tenant
      - >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#is
        refused a candidate id that was not sealed for them
      - TST-PFM-SEC-175
    provenance: test
    source: >-
      services-platform:modules/security/tests/unit/services/portal/candidate-token.spec.ts#refuses
      a token with one character changed
    category: validation
    test_references:
      - >-
        services-platform:modules/security/tests/unit/services/portal/candidate-token.spec.ts#refuses
        a token with one character changed
      - >-
        services-platform:modules/security/tests/unit/services/portal/candidate-token.spec.ts#refuses
        an expired token
      - >-
        services-platform:modules/security/tests/unit/services/portal/candidate-token.spec.ts#refuses
        another member’s token
      - >-
        services-platform:modules/security/tests/unit/services/portal/candidate-token.spec.ts#refuses
        a token of another tenant
      - >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#is
        refused a candidate id that was not sealed for them
    action_evidence:
      provenance: test
      source: >-
        services-platform:modules/security/tests/unit/services/portal/candidate-token.spec.ts#refuses
        a token with one character changed
    assertion_evidence:
      provenance: test
      source: >-
        services-platform:modules/security/tests/unit/services/portal/candidate-token.spec.ts#refuses
        a token with one character changed
    binding_scope: >-
      The binding applies to the cited executable observations and their dependency boundary. Extra
      setup, operations or combinations in this scenario still require evidence before the whole
      scenario is qualified.
  - id: ATS-PFM-SEC-020-05
    title: Bound verification challenges
    level: api
    proves:
      - BR-CAP-PFM-SEC-020-05
      - INV-DOM-PFM-SEC-003-22
    preconditions: Prepare an eligible member and candidate.
    steps: >-
      Start six challenges within an hour, submit five incorrect codes and submit a correct code
      after expiry.
    expected: >-
      Reject the sixth start, exhaust the fifth failed attempt and refuse expired proof. Store a
      digest without the code.
    negative: true
    data_needs: Use the records described in the setup.
    bound_to:
      - >-
        services-platform:modules/security/tests/integration/services/persistence/portal-contact-challenges.integration.spec.js#records
        the member, the contact, the channel and a ten-minute expiry, with the digest of the code
        and never the code
      - TST-PFM-SEC-160
      - >-
        services-platform:modules/security/tests/integration/services/persistence/portal-contact-challenges.integration.spec.js#supersedes
        the member’s live challenge, so an old code can never be entered after a new one was sent
      - >-
        services-platform:modules/security/tests/integration/services/persistence/portal-contact-challenges.integration.spec.js#refuses
        a sixth start within an hour
      - >-
        services-platform:modules/security/tests/integration/services/persistence/portal-contact-challenges.integration.spec.js#spends
        the challenge exhausted on the fifth wrong code
      - >-
        services-platform:modules/security/tests/integration/services/persistence/portal-contact-challenges.integration.spec.js#spends
        an expired challenge as expired, even with the right code
      - >-
        services-platform:modules/security/tests/unit/services/portal/verification-code-sender.spec.ts#refuses
        an SMS while SMS is off, sending nothing
      - TST-PFM-SEC-219
    provenance: test
    source: >-
      services-platform:modules/security/tests/integration/services/persistence/portal-contact-challenges.integration.spec.js#records
      the member, the contact, the channel and a ten-minute expiry, with the digest of the code and
      never the code
    category: boundary
    test_references:
      - >-
        services-platform:modules/security/tests/integration/services/persistence/portal-contact-challenges.integration.spec.js#records
        the member, the contact, the channel and a ten-minute expiry, with the digest of the code
        and never the code
      - >-
        services-platform:modules/security/tests/integration/services/persistence/portal-contact-challenges.integration.spec.js#supersedes
        the member’s live challenge, so an old code can never be entered after a new one was sent
      - >-
        services-platform:modules/security/tests/integration/services/persistence/portal-contact-challenges.integration.spec.js#refuses
        a sixth start within an hour
      - >-
        services-platform:modules/security/tests/integration/services/persistence/portal-contact-challenges.integration.spec.js#spends
        the challenge exhausted on the fifth wrong code
      - >-
        services-platform:modules/security/tests/integration/services/persistence/portal-contact-challenges.integration.spec.js#spends
        an expired challenge as expired, even with the right code
      - >-
        services-platform:modules/security/tests/unit/services/portal/verification-code-sender.spec.ts#refuses
        an SMS while SMS is off, sending nothing
    action_evidence:
      provenance: test
      source: >-
        services-platform:modules/security/tests/integration/services/persistence/portal-contact-challenges.integration.spec.js#records
        the member, the contact, the channel and a ten-minute expiry, with the digest of the code
        and never the code
    assertion_evidence:
      provenance: test
      source: >-
        services-platform:modules/security/tests/integration/services/persistence/portal-contact-challenges.integration.spec.js#records
        the member, the contact, the channel and a ten-minute expiry, with the digest of the code
        and never the code
    binding_scope: >-
      The binding applies to the cited executable observations and their dependency boundary. Extra
      setup, operations or combinations in this scenario still require evidence before the whole
      scenario is qualified.
  - id: ATS-PFM-SEC-020-06
    title: Establish one link from valid proof
    level: api
    proves:
      - BR-CAP-PFM-SEC-020-06
      - INV-DOM-PFM-SEC-003-22
    preconditions: Prepare a live challenge.
    steps: 'Submit an incorrect code, then the correct code, then request another challenge.'
    expected: 'Report remaining attempts, establish the link and reject further challenge creation.'
    negative: true
    data_needs: Use the records described in the setup.
    bound_to:
      - >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#refuses
        a wrong code, saying how many attempts are left
      - TST-PFM-SEC-175
      - >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#links
        the member to the contact on the right code
      - >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#refuses
        another challenge once the member is linked
    provenance: test
    source: >-
      services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#refuses
      a wrong code, saying how many attempts are left
    category: transition
    test_references:
      - >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#refuses
        a wrong code, saying how many attempts are left
      - >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#links
        the member to the contact on the right code
      - >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#refuses
        another challenge once the member is linked
    action_evidence:
      provenance: test
      source: >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#refuses
        a wrong code, saying how many attempts are left
    assertion_evidence:
      provenance: test
      source: >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#refuses
        a wrong code, saying how many attempts are left
    binding_scope: >-
      The binding applies to the cited executable observations and their dependency boundary. Extra
      setup, operations or combinations in this scenario still require evidence before the whole
      scenario is qualified.
  - id: ATS-PFM-SEC-020-07
    title: Retain a possible duplicate after declining
    level: api
    proves:
      - BR-CAP-PFM-SEC-020-07
    preconditions: Prepare a member with ranked candidates.
    steps: Decline every candidate.
    expected: Create and link a new contact while recording the highest-ranked declined contact for review.
    negative: true
    data_needs: Use the records described in the setup.
    bound_to:
      - >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#is
        linked to a contact CRM created from the sign-up details
      - TST-PFM-SEC-175
      - >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#names
        the best declined candidate, so staff review the pair
    provenance: test
    source: >-
      services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#is
      linked to a contact CRM created from the sign-up details
    category: transition
    test_references:
      - >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#is
        linked to a contact CRM created from the sign-up details
      - >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#names
        the best declined candidate, so staff review the pair
    action_evidence:
      provenance: test
      source: >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#is
        linked to a contact CRM created from the sign-up details
    assertion_evidence:
      provenance: test
      source: >-
        services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#is
        linked to a contact CRM created from the sign-up details
    binding_scope: >-
      The binding applies to the cited executable observations and their dependency boundary. Extra
      setup, operations or combinations in this scenario still require evidence before the whole
      scenario is qualified.
  - id: ATS-PFM-SEC-020-08
    title: Refresh the contact claim on authorizer evaluation
    level: api
    proves:
      - BR-CAP-PFM-SEC-020-08
    preconditions: Prepare portal and console callers.
    steps: >-
      Evaluate the portal caller, change and clear its contact link and evaluate again without
      gateway cache reuse; also evaluate the console caller.
    expected: >-
      Fresh portal contexts carry the current link or omit a cleared link. Console contexts omit the
      contact claim. The binding does not establish gateway propagation timing.
    negative: true
    data_needs: Use the records described in the setup.
    bound_to:
      - >-
        services-platform:modules/security/tests/integration/services/authorizer-portal-contact.integration.spec.js#carries
        the linked contact as `cid`, read from the user’s row under the deployed role
      - TST-PFM-SEC-091
      - >-
        services-platform:modules/security/tests/integration/services/authorizer-portal-contact.integration.spec.js#signs
        a changed link, and then a cleared one, on the very next run
      - >-
        services-platform:modules/security/tests/integration/services/authorizer-portal-contact.integration.spec.js#carries
        no `cid` for a console user, who is nobody’s contact
    provenance: test
    source: >-
      services-platform:modules/security/tests/integration/services/authorizer-portal-contact.integration.spec.js#carries
      the linked contact as `cid`, read from the user’s row under the deployed role
    category: interaction
    test_references:
      - >-
        services-platform:modules/security/tests/integration/services/authorizer-portal-contact.integration.spec.js#carries
        the linked contact as `cid`, read from the user’s row under the deployed role
      - >-
        services-platform:modules/security/tests/integration/services/authorizer-portal-contact.integration.spec.js#signs
        a changed link, and then a cleared one, on the very next run
      - >-
        services-platform:modules/security/tests/integration/services/authorizer-portal-contact.integration.spec.js#carries
        no `cid` for a console user, who is nobody’s contact
    action_evidence:
      provenance: test
      source: >-
        services-platform:modules/security/tests/integration/services/authorizer-portal-contact.integration.spec.js#carries
        the linked contact as `cid`, read from the user’s row under the deployed role
    assertion_evidence:
      provenance: test
      source: >-
        services-platform:modules/security/tests/integration/services/authorizer-portal-contact.integration.spec.js#carries
        the linked contact as `cid`, read from the user’s row under the deployed role
    binding_scope: >-
      The binding applies to the cited executable observations and their dependency boundary. Extra
      setup, operations or combinations in this scenario still require evidence before the whole
      scenario is qualified.
  - id: ATS-PFM-SEC-020-09
    title: 'Portal contact challenge: live to verified'
    category: transition
    level: api
    proves:
      - BR-CAP-PFM-SEC-020-06
    state_model: STATE-PFM-SEC-011
    transition:
      from: live
      to: verified
      index: 0
    preconditions: >-
      Prepare a run-owned portal contact challenge in live state with the authority and inputs
      required by the cited transition.
    steps:
      - do: Submit the code delivered for this member’s selected contact and read the link.
        expect: >-
          After the stated action, observe the portal contact challenge in verified state. This case
          does not require an additional history event unless the transition explicitly defines one.
        provenance: proposed
        source:
          - >-
            services-platform:modules/security/services/portal/contact-match.ts:verifyContactChallenge
          - >-
            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.
    expected: >-
      After the stated action, observe the portal contact challenge in verified state. This case
      does not require an additional history event unless the transition explicitly defines one.
    negative: false
    data_needs: >-
      A disposable customer, workspace and named actor; a controllable clock where the transition
      depends on time.
    bound_to: []
    provenance: proposed
    source:
      - 'services-platform:modules/security/services/portal/contact-match.ts:verifyContactChallenge'
      - >-
        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.
    guard_cases: >-
      The challenge belongs to the member, is usable, and the contact is not already linked in this
      workspace.
    side_effects_to_verify:
      - Write the contact link; fresh context evaluation exposes it
    transition_assertion:
      state: verified
      provenance: proposed
      source:
        - 'services-platform:modules/security/services/portal/contact-match.ts:verifyContactChallenge'
        - >-
          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.
    action_evidence:
      provenance: proposed
      source:
        - 'services-platform:modules/security/services/portal/contact-match.ts:verifyContactChallenge'
        - >-
          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.
    assertion_evidence:
      provenance: proposed
      source:
        - 'services-platform:modules/security/services/portal/contact-match.ts:verifyContactChallenge'
        - >-
          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.
    binding_scope: >-
      No executable assertion is bound. The scenario specifies proposed proof and does not establish
      implemented or passing behavior.
  - id: ATS-PFM-SEC-020-10
    title: 'Portal contact challenge: live to exhausted'
    category: transition
    level: api
    proves:
      - BR-CAP-PFM-SEC-020-05
    state_model: STATE-PFM-SEC-011
    transition:
      from: live
      to: exhausted
      index: 1
    preconditions: >-
      Prepare a run-owned portal contact challenge in live state with the authority and inputs
      required by the cited transition.
    steps:
      - do: Submit five incorrect codes and try the correct code after the fifth failure.
        expect: >-
          After the stated action, observe the portal contact challenge in exhausted state. This
          case does not require an additional history event unless the transition explicitly defines
          one.
        provenance: proposed
        source:
          - >-
            services-platform:modules/security/db/portal-contact-challenges.table.ts:PORTAL_CHALLENGE_MAX_ATTEMPTS
          - >-
            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.
    expected: >-
      After the stated action, observe the portal contact challenge in exhausted state. This case
      does not require an additional history event unless the transition explicitly defines one.
    negative: false
    data_needs: >-
      A disposable customer, workspace and named actor; a controllable clock where the transition
      depends on time.
    bound_to: []
    provenance: proposed
    source:
      - >-
        services-platform:modules/security/db/portal-contact-challenges.table.ts:PORTAL_CHALLENGE_MAX_ATTEMPTS
      - >-
        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.
    guard_cases: Attempt accounting reaches the configured limit.
    side_effects_to_verify: []
    transition_assertion:
      state: exhausted
      provenance: proposed
      source:
        - >-
          services-platform:modules/security/db/portal-contact-challenges.table.ts:PORTAL_CHALLENGE_MAX_ATTEMPTS
        - >-
          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.
    action_evidence:
      provenance: proposed
      source:
        - >-
          services-platform:modules/security/db/portal-contact-challenges.table.ts:PORTAL_CHALLENGE_MAX_ATTEMPTS
        - >-
          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.
    assertion_evidence:
      provenance: proposed
      source:
        - >-
          services-platform:modules/security/db/portal-contact-challenges.table.ts:PORTAL_CHALLENGE_MAX_ATTEMPTS
        - >-
          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.
    binding_scope: >-
      No executable assertion is bound. The scenario specifies proposed proof and does not establish
      implemented or passing behavior.
  - id: ATS-PFM-SEC-020-11
    title: 'Portal contact challenge: live to expired'
    category: transition
    level: api
    proves:
      - BR-CAP-PFM-SEC-020-05
    state_model: STATE-PFM-SEC-011
    transition:
      from: live
      to: expired
      index: 2
    preconditions: >-
      Prepare a run-owned portal contact challenge in live state with the authority and inputs
      required by the cited transition.
    steps:
      - do: Advance ten minutes from challenge creation and submit the previously correct code.
        expect: >-
          After the stated action, observe the portal contact challenge in expired state. This case
          does not require an additional history event unless the transition explicitly defines one.
        provenance: proposed
        source:
          - >-
            services-platform:modules/security/db/portal-contact-challenges.table.ts:PORTAL_CHALLENGE_OUTCOMES
          - >-
            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.
    expected: >-
      After the stated action, observe the portal contact challenge in expired state. This case does
      not require an additional history event unless the transition explicitly defines one.
    negative: false
    data_needs: >-
      A disposable customer, workspace and named actor; a controllable clock where the transition
      depends on time.
    bound_to: []
    provenance: proposed
    source:
      - >-
        services-platform:modules/security/db/portal-contact-challenges.table.ts:PORTAL_CHALLENGE_OUTCOMES
      - >-
        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.
    guard_cases: Usability is checked when the challenge is used.
    side_effects_to_verify: []
    transition_assertion:
      state: expired
      provenance: proposed
      source:
        - >-
          services-platform:modules/security/db/portal-contact-challenges.table.ts:PORTAL_CHALLENGE_OUTCOMES
        - >-
          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.
    action_evidence:
      provenance: proposed
      source:
        - >-
          services-platform:modules/security/db/portal-contact-challenges.table.ts:PORTAL_CHALLENGE_OUTCOMES
        - >-
          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.
    assertion_evidence:
      provenance: proposed
      source:
        - >-
          services-platform:modules/security/db/portal-contact-challenges.table.ts:PORTAL_CHALLENGE_OUTCOMES
        - >-
          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.
    binding_scope: >-
      No executable assertion is bound. The scenario specifies proposed proof and does not establish
      implemented or passing behavior.
  - id: ATS-PFM-SEC-020-12
    title: 'Portal contact challenge: live to superseded'
    category: transition
    level: api
    proves:
      - BR-CAP-PFM-SEC-020-05
    state_model: STATE-PFM-SEC-011
    transition:
      from: live
      to: superseded
      index: 3
    preconditions: >-
      Prepare a run-owned portal contact challenge in live state with the authority and inputs
      required by the cited transition.
    steps:
      - do: Start a replacement challenge and try verifying the earlier challenge.
        expect: >-
          After the stated action, observe the portal contact challenge in superseded state. This
          case does not require an additional history event unless the transition explicitly defines
          one.
        provenance: proposed
        source:
          - >-
            services-platform:modules/security/services/portal/contact-match.ts:startContactChallenge
          - >-
            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.
    expected: >-
      After the stated action, observe the portal contact challenge in superseded state. This case
      does not require an additional history event unless the transition explicitly defines one.
    negative: false
    data_needs: >-
      A disposable customer, workspace and named actor; a controllable clock where the transition
      depends on time.
    bound_to: []
    provenance: proposed
    source:
      - 'services-platform:modules/security/services/portal/contact-match.ts:startContactChallenge'
      - >-
        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.
    guard_cases: The member has not already linked a contact.
    side_effects_to_verify: []
    transition_assertion:
      state: superseded
      provenance: proposed
      source:
        - 'services-platform:modules/security/services/portal/contact-match.ts:startContactChallenge'
        - >-
          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.
    action_evidence:
      provenance: proposed
      source:
        - 'services-platform:modules/security/services/portal/contact-match.ts:startContactChallenge'
        - >-
          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.
    assertion_evidence:
      provenance: proposed
      source:
        - 'services-platform:modules/security/services/portal/contact-match.ts:startContactChallenge'
        - >-
          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.
    binding_scope: >-
      No executable assertion is bound. The scenario specifies proposed proof and does not establish
      implemented or passing behavior.
  - id: ATS-PFM-SEC-020-13
    title: >-
      Match portal memberships to CRM contacts with derive member access from appointments and
      eligibility
    category: interaction
    level: api
    proves:
      - BR-CAP-PFM-SEC-014-01
      - BR-CAP-PFM-SEC-020-08
    exercises:
      - CAP-PFM-SEC-020
      - CAP-PFM-SEC-014
    preconditions: An unlinked portal member has a contact carrying derived authority.
    steps: Complete contact possession proof and request a fresh authorization context.
    expected: >-
      The linked contact claim enables its applicable derived assignments; a console context carries
      no contact claim.
    negative: false
    data_needs: >-
      An isolated customer with two workspaces, ordinary and staff identities, and records owned by
      this run.
    bound_to: []
    provenance: proposed
    source:
      - >-
        services-platform:modules/security/tests/e2e/derived-assignments.e2e.spec.js#is refused once
        the member is no longer linked to the contact, so the link is what carries the grant
      - 'services-platform:docs/security/portal-contact-claim.md#When a change is seen'
    action_evidence:
      provenance: proposed
      source:
        - >-
          services-platform:modules/security/tests/e2e/derived-assignments.e2e.spec.js#is refused
          once the member is no longer linked to the contact, so the link is what carries the grant
        - 'services-platform:docs/security/portal-contact-claim.md#When a change is seen'
    assertion_evidence:
      provenance: proposed
      source:
        - >-
          services-platform:modules/security/tests/e2e/derived-assignments.e2e.spec.js#is refused
          once the member is no longer linked to the contact, so the link is what carries the grant
        - 'services-platform:docs/security/portal-contact-claim.md#When a change is seen'
    binding_scope: >-
      No executable assertion is bound. The scenario specifies proposed proof and does not establish
      implemented or passing behavior.
evidence:
  - >-
    Pinned services-platform references for the structured rows:
    modules/security/tests/integration/services/authorizer-portal-contact.integration.spec.js;
    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;
    modules/security/tests/unit/services/portal/verification-code-sender.spec.ts.
  - >-
    Testing back-fill reviews the pinned suite assertions, related capabilities, domain invariants,
    state transitions and workflow failure boundaries. New unbound rows remain proposed.
coverage:
  rule: covered
  transition: covered
  validation: covered
  permission: 'gap: FND-PFM-SEC-077'
  edge: 'gap: FND-PFM-SEC-077'
  failure: 'gap: FND-PFM-SEC-077'
  boundary: covered
  concurrency: 'gap: FND-PFM-SEC-077'
  invariant: covered
  interaction: covered
coverage_notes:
  permission: No scenario isolates this category. The referenced finding retains the missing design proof.
  edge: No scenario isolates this category. The referenced finding retains the missing design proof.
  failure: No scenario isolates this category. The referenced finding retains the missing design proof.
  concurrency: No scenario isolates this category. The referenced finding retains the missing design proof.
invariant_proofs:
  - scenario: ATS-PFM-SEC-020-05
    proves:
      - INV-DOM-PFM-SEC-003-22
    provenance: test
    source: >-
      services-platform:modules/security/tests/integration/services/persistence/portal-contact-challenges.integration.spec.js#records
      the member, the contact, the channel and a ten-minute expiry, with the digest of the code and
      never the code
    proof_scope: >-
      The cited check exercises one part of an invariant whose full guarantee remains unknown. It
      supplies no proof of concurrent exclusion or unrestricted history protection.
  - scenario: ATS-PFM-SEC-020-06
    proves:
      - INV-DOM-PFM-SEC-003-22
    provenance: test
    source: >-
      services-platform:modules/security/tests/integration/services/portal/contact-match.integration.spec.js#refuses
      a wrong code, saying how many attempts are left
    proof_scope: >-
      The cited check exercises one part of an invariant whose full guarantee remains unknown. It
      supplies no proof of concurrent exclusion or unrestricted history protection.
permission_proof_review:
  - key: platform.security-self.read
    scenario_candidates: []
    qualification: >-
      These are candidate scenarios for the authority boundary, not an exhaustive allowed/refused
      matrix for this key. The finding retains combinations not established by the executable
      assertions.
    gap: FND-PFM-SEC-077
    provenance: inferred
    source: >-
      specs/platform/security/planning/capabilities/CAP-PFM-SEC-020-link-a-portal-member-to-their-contact-record.md
examiner: codex
examined_at: '2026-10-23'
final_text: OpenAI
---
# Match portal memberships to CRM contacts: Capability Test Specification

## Coverage

| Required rule | Scenario or finding |
|---|---|
| `BR-CAP-PFM-SEC-020-01` | `ATS-PFM-SEC-020-01` |
| `BR-CAP-PFM-SEC-020-02` | `ATS-PFM-SEC-020-02` |
| `BR-CAP-PFM-SEC-020-03` | `ATS-PFM-SEC-020-03` |
| `BR-CAP-PFM-SEC-020-04` | `ATS-PFM-SEC-020-04` |
| `BR-CAP-PFM-SEC-020-05` | `ATS-PFM-SEC-020-05`, `ATS-PFM-SEC-020-10`, `ATS-PFM-SEC-020-11`, `ATS-PFM-SEC-020-12` |
| `BR-CAP-PFM-SEC-020-06` | `ATS-PFM-SEC-020-06`, `ATS-PFM-SEC-020-09` |
| `BR-CAP-PFM-SEC-020-07` | `ATS-PFM-SEC-020-07` |
| `BR-CAP-PFM-SEC-020-08` | `ATS-PFM-SEC-020-08`, `ATS-PFM-SEC-020-13` |

The category map records specified proof. A covered category can contain proposed scenarios; it does not mean the checks have run. The bindings identify individual executable contributions, while findings retain missing rules, permissions, transitions and assertions.

## Interactions With Related Capabilities

The interaction scenarios connect this capability to the related actions and workflow handoffs named in their proof references. Each case observes the outcome after the connected action changes records or available authority. A collection of separately bound checks does not establish an ordered feature journey.

Source revisions and assertion references are recorded in frontmatter. Steps and expected observations carry separate evidence annotations. Proposed checks remain distinct from executable test statements, and static review supplies no run result.
