Skip to content

7. Palm Verification & Operations

Palm enrollment and verification — the PalmVerifier port, the palm flows (pull/push), score model, restrictions, duplicate detection, and the small/large model split.

7.1 Overview

Identity Platform uses ports and adapters (hexagonal) for palm verification: the core domain defines the PalmVerifier interface, and vendor-specific adapters implement it.

7.2 Architecture

flowchart TB
    subgraph IP["Identity Platform"]
        subgraph CD["Core Domain"]
            D["User, PalmTemplate, Enrollment, Challenge"]
        end
        CD --> Port["PalmVerifier Port (interface)"]
        Port --> Adapter["X-Telcom BioWave Pass Adapter (sole MVP)"]
    end

7.3 PalmVerifier Port (Interface)

The PalmVerifier Port defines the abstract interface that all palm verification vendor adapters must implement.

Method Parameters Returns Description
enroll user_id, palm_template EnrollmentResult Store a palm template for a user
verify user_id, palm_scan VerificationResult 1:1 match against stored template
identify palm_scan, tenant_id IdentificationResult 1:N search across all templates
get_status user_id EnrollmentStatus Check if user has an enrolled template
delete_template user_id boolean Remove a stored palm template

verify (1:1) vs identify (1:N) — the difference is whether the caller already names the user:

  • verify (1:1) checks a claimed identity: the caller already knows the user_id (e.g. a verification challenge from an integrator carries it), and the platform compares against that one template. This is the public, challenge-based path for personal scanners (§8.2, §7.4).
  • identify (1:N) answers "who is this?" from a bare palm with no claim. Device-initiated transactions are claim-less, so they always use identify — and it runs internally inside the broker (§9), not as a public endpoint.

7.4 Palm Flows

Every palm enrollment and verification flow, grouped by initiation pattern. Pull = an integrator backend creates a challenge that a personal scanner polls for and completes. Push = a device (POS / gate / kiosk) initiates directly over mTLS. Device pairing and certificates live in §8; the broker authorize contract lives in §9.

7.4.1 Pull — challenge-based (integrator-initiated)

All palm operations on personal scanners — verification and enrollment — are challenge-based: an integrator backend creates a challenge (POST /v1/verify or POST /v1/enroll/both), the scanner polls GET /v1/challenges/pending (mTLS) and completes it via POST /v1/challenges/{id}/complete, and Identity calls the vendor and applies the tenant's match policy. The integrator calls with external_user_id/hardware_id; Identity resolves these to the internal user_id/device_id, the scanner only ever sees a user_id inside the challenge envelope, and Identity authoritatively maps user_id to palm_id. The two sequences below show verify and enroll, and both assume the scanner is already paired (§8.2).

The mechanism is generic — the integrator passes application data in metadata (echoed back on the webhook). E-signature is one integrator use case, not a platform feature: in the worked example below, InvestGlass creates a verify challenge and passes the document id/hash in metadata. The platform provides only the generic verify/enroll challenge + polling.

Diagram — Pull verify (personal-scanner challenge; InvestGlass document-signing example)

sequenceDiagram
    autonumber
    participant User
    participant IGApp as InvestGlass Web App
    participant IG as InvestGlass Backend
    participant Identity as Identity Platform
    participant Scanner as Personal Scanner
    participant Palm as X-Telcom BioWave Pass Palm Server
    participant DB as PostgreSQL
    participant Audit as Audit Log

    Note over Identity, Audit: All audit logging is conditional<br/>on tenant audit_enabled setting

    User->>IGApp: Click "Sign Document"
    IGApp->>IG: POST /documents/{id}/sign

    IG->>Identity: POST /v1/verify<br/>Authorization: Bearer {token}<br/>{external_user_id: "user_456",<br/>hardware_id: "dev_789",<br/>metadata: {document_id: "doc_123",<br/>document_hash: "sha256:..."}}

    Identity->>DB: Validate user enrolled
    alt User not enrolled
        DB-->>Identity: palm_enrolled: false
        Identity-->>IG: 400 Bad Request<br/>"User not enrolled"
    else User enrolled
        Identity->>DB: Create challenge<br/>(type: verify, user_id,<br/>device_id, status: pending,<br/>expires_at, metadata)
        Identity->>Audit: Log: challenge_created<br/>(challenge_id, user_id, device_id)
        Identity-->>IG: {challenge_id: "ch_abc",<br/>status: "pending",<br/>expires_at: "..."}
    end

    IG-->>IGApp: "Please scan your palm<br/>on the scanner at your counter"

    loop Scanner polling (every 2-3 sec)
        Scanner->>Identity: GET /v1/challenges/pending<br/>(mTLS — device cert identifies scanner)

        Identity->>DB: Get device from cert fingerprint
        Identity->>DB: Get pending challenges for device_id

        alt No pending challenges
            Identity-->>Scanner: {challenges: []}
        else Challenge available
            Identity-->>Scanner: {challenges: [{<br/>challenge_id: "ch_abc",<br/>type: "verify",<br/>expires_at: "..."}]}
        end
    end

    Scanner-->>Scanner: Display "Verification needed<br/>for document signing"
    Scanner-->>User: "Place palm to sign document"
    User->>Scanner: Place palm

    Scanner->>Scanner: Capture palm<br/>Extract features

    Scanner->>Identity: POST /v1/challenges/ch_abc/complete<br/>(mTLS — device cert identifies scanner)<br/>{palm_template}

    Identity->>DB: Get challenge, validate not expired
    Identity->>DB: Get user's enrolled palm reference

    Note over Identity, Palm: X-Telcom BioWave Pass has no dedicated 1:1 endpoint.<br/>We use /KZ/query (1:N) and validate the<br/>returned user_id matches the challenge's expected user_id.<br/>For tighter binding, use §7.8 to set query_type<br/>per user.

    Identity->>Palm: POST /KZ/query<br/>Headers: request_id: {uuid}<br/>Content-Type: multipart/form-data<br/>Body:<br/>- features_rgb.bin (optional)<br/>- features_ir.bin (optional)<br/>- image_rgb.png (required)<br/>- image_ir.png (required)<br/>- metadata: {is_encrypted: false}

    alt Match returned — code: 0
        Palm-->>Identity: {code: 0,<br/>data: {results: [{user_id, id,<br/>query_type, scores: [4 floats]}],<br/>thresholds: [4 floats]}}

        Identity->>Identity: Verify returned user_id ==<br/>challenge.expected_user_id<br/>AND apply tenant score policy

        alt user_id matches & policy passes
            Identity->>DB: Update challenge<br/>(status: completed)
            Identity->>Audit: Log: verification_complete<br/>(challenge_id, user_id, scores)

            Identity-->>Scanner: {status: "completed",<br/>user_id, scores, timestamp}
            Scanner-->>User: "Verification successful"

            opt Webhook subscribed
                Identity->>IG: Send webhook: verification.complete<br/>{challenge_id: "ch_abc",<br/>external_user_id: "user_456",<br/>scores, timestamp,<br/>metadata: {document_id, document_hash}}
            end

            IG->>IG: Mark document signed<br/>Store verification metadata as proof
            IG-->>IGApp: Push notification
            IGApp-->>User: "Document signed!"

        else user_id mismatch (matched a different user)
            Identity->>DB: Increment attempt count
            Identity->>Audit: Log: verification_failed<br/>(challenge_id, attempt,<br/>reason: identity_mismatch,<br/>severity: high)
            Identity-->>Scanner: {status: "failed",<br/>reason: "Identity mismatch"}
            Scanner-->>User: "Wrong user — please try again"
        end

    else No match — platform-decided (scores < thresholds)
        Palm-->>Identity: {code: 0}<br/>(no candidate ≥ thresholds → platform no-match)
        Identity->>DB: Increment attempt count
        Identity->>Audit: Log: verification_failed<br/>(challenge_id, user_id, attempt,<br/>reason: not_found)
        Identity-->>Scanner: {status: "failed",<br/>reason: "Palm not recognized",<br/>attempts_remaining: 2}
        Scanner-->>User: "Please try again"

    else Low confidence — platform-decided
        Palm-->>Identity: {code: 0}<br/>(scores < thresholds → platform low-confidence)
        Identity->>DB: Increment attempt count
        Identity->>Audit: Log: verification_failed<br/>(challenge_id, user_id, attempt,<br/>reason: low_confidence)

        alt Max attempts reached
            Identity->>DB: Update challenge (status: failed)
            Identity-->>Scanner: {status: "failed",<br/>reason: "Max attempts exceeded"}
            opt Webhook subscribed
                Identity->>IG: Send webhook: verification.failed<br/>{external_user_id, challenge_id, reason}
            end
        else Attempts remaining
            Identity-->>Scanner: {status: "failed",<br/>reason: "Low confidence",<br/>attempts_remaining: 2}
            Scanner-->>User: "Please try again"
        end

    else Query type forbidden — code: 30008
        Note over Palm: User has restriction set<br/>(see §7.8)
        Palm-->>Identity: {code: 30008,<br/>msg: "DB query type is forbidden"}
        Identity->>Audit: Log: verification_failed<br/>(challenge_id, reason: query_type_restricted)
        Identity-->>Scanner: {status: "failed",<br/>reason: "Palm verification disabled<br/>for this user"}
    end

Diagram — Pull enrollment at a personal scanner (both hands)

Enrollment always captures both hands via POST /v1/enroll/both — single-hand enrollment is not offered.

Open question (Q1): the diagram shows both palms captured and registered. The exact vendor fan-out to /KZ/add (one combined call vs one per hand) and whether the scanner submits both templates in a single complete are to be confirmed with the team; the sequence below does not assert the call shape.

sequenceDiagram
    autonumber
    participant Operator as Bank Operator
    participant IGApp as InvestGlass Web App
    participant IG as InvestGlass Backend
    participant Identity as Identity Platform
    participant Scanner as Personal Scanner
    participant Palm as X-Telcom BioWave Pass Palm Server
    participant DB as PostgreSQL
    participant Audit as Audit Log

    Note over Identity, Audit: All audit logging is conditional<br/>on tenant audit_enabled setting

    Operator->>IGApp: Select user → "Enroll Palm"
    IGApp->>IG: POST /users/{user_id}/enroll-palm<br/>{device_id}

    IG->>Identity: POST /v1/enroll/both<br/>Authorization: Bearer {token}<br/>(client_credentials)<br/>{external_user_id, hardware_id, metadata}

    Identity->>DB: Validate user exists,<br/>device paired and active

    rect rgb(255, 245, 230)
        Note over Identity, DB: Compliance gates<br/>(KYC + consent — same as duplicate-detection flow)
        Identity->>DB: Check kyc_required_for_enrollment<br/>+ consent_required
        alt KYC missing or consent missing
            Identity-->>IG: 403 Forbidden<br/>{error: "kyc_required" or "consent_required"}
            IG-->>IGApp: Display compliance error
        end
    end

    alt User already enrolled
        Identity-->>IG: 409 Conflict<br/>"User already enrolled"
    else Ready to enroll
        Identity->>DB: Create challenge<br/>(type: enroll, user_id,<br/>device_id, status: pending,<br/>expires_at, metadata: {palm_type: "both"})
        Identity->>Audit: Log: challenge_created<br/>(type: enroll, user_id, device_id)

        Identity-->>IG: {challenge_id: "ch_enroll_1",<br/>status: "pending"}
        IG-->>IGApp: "Ask user to place palm<br/>on scanner"
    end

    loop Scanner polling (every 2-3 sec)
        Scanner->>Identity: GET /v1/challenges/pending<br/>(mTLS — device cert identifies scanner)

        alt No pending challenges
            Identity-->>Scanner: {challenges: []}
        else Challenge available
            Identity-->>Scanner: {challenges: [{<br/>challenge_id: "ch_enroll_1",<br/>type: "enroll",<br/>user_id, palm_type: "both"}]}
        end
    end

    Scanner-->>Operator: "Enrollment ready —<br/>ask user to place palm"
    Operator->>Scanner: User places both palms
    Scanner->>Scanner: Capture both palms<br/>Extract features (RGB + IR)

    Scanner->>Identity: POST /v1/challenges/ch_enroll_1/complete<br/>(mTLS — device cert identifies scanner)<br/>{palm_templates: both hands<br/>(features_rgb, features_ir,<br/>image_rgb, image_ir per hand)}

    Identity->>DB: Get challenge, validate not expired,<br/>type == enroll

    Note over Identity, Palm: Recommended: §7.9<br/>pre-enrollment duplicate detection.<br/>⚠ Both-hands registration — vendor fan-out per Q1.

    Identity->>Palm: POST /KZ/add (both palms)<br/>Headers: request_id: {uuid}<br/>Content-Type: multipart/form-data<br/>Body:<br/>- features_rgb.bin (optional)<br/>- features_ir.bin (optional)<br/>- image_rgb.png (required)<br/>- image_ir.png (required)<br/>- metadata: {user_id,<br/>is_encrypted: false}

    alt Success — code: 0
        Palm-->>Identity: {code: 0, msg: "Success",<br/>data: {user_id, id: 100001,<br/>query_type: "all"}}

        Identity->>DB: Update user<br/>(palm_enrolled: true,<br/>palm_id: 100001, enrolled_at)
        Identity->>DB: Update challenge<br/>(status: completed)
        Identity->>Audit: Log: palm_enrolled<br/>(user_id, palm_id: 100001,<br/>palm_type: both, device_id)
        Identity->>Identity: Publish PalmEnrolled event

        opt Webhook subscribed
            Identity->>IG: Send webhook: enrollment.complete<br/>{external_user_id, palm_type: "both",<br/>hardware_id, timestamp}
        end

        Identity-->>Scanner: {status: "enrolled",<br/>user_id, enrolled_at}
        Scanner-->>Operator: "Palm enrolled successfully"

        IG-->>IGApp: Update UI
        IGApp-->>Operator: "User palm enrolled"

    else Duplicate biometrics — code: 30007
        Note over Palm: Different user_id but<br/>matching features in Milvus<br/>(potential identity fraud)
        Palm-->>Identity: {code: 30007,<br/>msg: "DB ID already registered"}
        Identity->>DB: Update challenge (status: failed)
        Identity->>Audit: Log: palm_enrollment_failed<br/>(user_id, reason: duplicate_biometrics,<br/>severity: high)
        Identity-->>Scanner: {status: "failed",<br/>reason: "Biometrics already registered"}
        Scanner-->>Operator: "Cannot enroll —<br/>contact support"

    else User already enrolled — code: 30005
        Palm-->>Identity: {code: 30005,<br/>msg: "DB register name duplicated"}
        Identity->>DB: Update challenge (status: failed)
        Identity->>Audit: Log: palm_enrollment_failed<br/>(user_id, reason: already_enrolled)
        Identity-->>Scanner: {status: "failed",<br/>reason: "Already enrolled"}

    else Enrollment failed — code: 30002 (DB insert)
        Palm-->>Identity: {code: 30002,<br/>msg: "DB insert failed"}
        Identity->>DB: Update challenge (status: failed)
        Identity->>Audit: Log: palm_enrollment_failed<br/>(user_id, reason: license_capacity)
        Identity->>Identity: Trigger ops alert:<br/>license capacity reached
        Identity-->>Scanner: {status: "failed",<br/>reason: "Service unavailable"}

    else Vendor error — code: 50000
        Palm-->>Identity: {code: 50000,<br/>msg: "Milvus error"}
        Identity->>Audit: Log: palm_vendor_unauthorized<br/>(severity: critical)
        Identity->>Identity: Trigger ops alert
        Identity-->>Scanner: {status: "failed",<br/>reason: "Service unavailable"}
    end

7.4.2 Push — device-initiated

A device (POS / gate / kiosk) initiates the operation directly over mTLS. The platform identifies the user (large model) or records a device-supplied result (small model, §7.10 — small-model sequence variants live there); for transactions it then routes to the bound product through the broker (authorize contract and step list in §9.3).

Diagram — Device-initiated transaction (synchronous broker)

sequenceDiagram
    autonumber
    participant User
    participant Device as POS / Gate (Identity-managed)
    participant Identity as Identity Platform
    participant Palm as X-Telcom BioWave Pass Palm Server
    participant Product as Wallet / Access Backend (linked service)
    participant Audit as Audit Log

    Note over Identity, Audit: Device is bound to one {product, action}<br/>(bound_product, bound_action — §8.3)

    User->>Device: Place palm on scanner
    Device->>Device: Capture palm image (RGB + IR)
    Device->>Identity: POST /v1/device/transactions<br/>(mTLS — device cert)<br/>{scan, context: {amount, currency},<br/>idempotency_key}

    Identity->>Identity: Parse SAN URI → device_id, tenant_id<br/>Look up bound_product, bound_action
    Identity->>Palm: POST /KZ/query (1:N)<br/>tenant-prefixed user_id namespace

    alt Match — code: 0
        Palm-->>Identity: {code: 0, data: {results: [{user_id, scores: [4]}],<br/>thresholds: [4]}}
        Identity->>Identity: Apply tenant match_policy<br/>sign identity_assertion JWT (claims in §9.5)
        Identity->>Product: POST {base_url}{authorize_path}<br/>Authorization: Bearer {identity_assertion}<br/>{user_id, action, context, idempotency_key}

        alt Product responds within timeout_ms
            Product->>Product: Verify JWT vs Identity JWKS<br/>business decision (balance / access rights)
            Product-->>Identity: {decision: "allow" | "deny",<br/>display_message, reference_id, ttl}
            Identity->>Audit: Log: device_transaction<br/>(user_id, device_id, product, decision, latency_ms)
            opt Webhook subscribed
                Identity->>Identity: Send webhook:<br/>device.transaction.completed
            end
            Identity-->>Device: {decision, display_message,<br/>product_reference} (in-band)
            Device-->>User: Open gate / "Approved" (or decline)
        else Timeout / circuit open
            Note over Identity, Product: fail_mode per linked service<br/>(default: closed)
            Identity->>Audit: Log: device_transaction<br/>(decision: fail_closed,<br/>reason: authorize_timeout)
            Identity-->>Device: {decision: "deny"} (fail-closed)
            Device-->>User: "Try again"
        end

    else No match — platform-decided (scores < thresholds)
        Palm-->>Identity: {code: 0}<br/>(no candidate ≥ thresholds)
        Identity->>Audit: Log: device_transaction (not_recognized)
        Identity-->>Device: {decision: "not_recognized"}
        Device-->>User: "Palm not recognized"

    else Low confidence — platform-decided
        Palm-->>Identity: {code: 0}<br/>(scores < thresholds)
        Identity->>Audit: Log: device_transaction (not_recognized, low_confidence)
        Identity-->>Device: {decision: "not_recognized"}
        Device-->>User: "Palm not recognized — try again"

    else Query type forbidden — code: 30008
        Palm-->>Identity: {code: 30008, msg: "query type forbidden"}
        Identity->>Audit: Log: device_transaction (forbidden, query_type_restricted)
        Identity-->>Device: {decision: "deny", reason: "palm_type_not_allowed"}
        Device-->>User: "Try other hand"

    else Vendor error — code: 50000
        Palm-->>Identity: {code: 50000, msg: "Milvus error"}
        Identity->>Audit: Log: palm_vendor_unauthorized (severity: critical)
        Identity-->>Device: 503 Service Unavailable
    end

Diagram — Device-initiated enrollment (POS → consent → enroll, both hands)

sequenceDiagram
    autonumber
    participant User
    participant Device as POS / Kiosk (Identity-managed)
    participant Identity as Identity Platform
    participant Palm as X-Telcom BioWave Pass Palm Server
    participant SMS as SMS Provider
    participant Audit as Audit Log

    User->>Device: Tap "Enroll" / "Sign up"
    Device->>Identity: POST /v1/device/signup (mTLS)<br/>{mobile}
    Identity->>Identity: Find or create user by mobile<br/>(tenant-scoped)
    Identity->>SMS: Send OTP
    SMS-->>User: OTP code
    User->>Device: Enter OTP
    Device->>Identity: POST /v1/device/signup/verify (mTLS)<br/>{challenge_id, code}
    Identity-->>Device: {user_id, is_new_user,<br/>palm_enrolled: false,<br/>consent_status, kyc_status}

    opt Product requires KYC and kyc_status != verified
        Device-->>User: Direct to complete KYC first<br/>(app / Nafath)
    end

    opt consent_required and consent_status = none
        Device->>User: Show consent screen
        User->>Device: Accept
        Device->>Identity: POST /v1/device/consent (mTLS)<br/>{user_id, consent_type, version}
        Identity->>Audit: Log: consent.granted
    end

    User->>Device: Place both palms
    Device->>Device: Capture both palms (RGB + IR)
    Device->>Identity: POST /v1/device/enroll (mTLS)<br/>{user_id, scan (both palms)}

    alt consent satisfied
        Identity->>Palm: POST /KZ/add (both palms)<br/>tenant-prefixed user_id
        Palm-->>Identity: {code: 0, palm_id}
        Identity->>Audit: Log: enrollment.complete<br/>(palm_type: both)
        opt Webhook subscribed
            Identity->>Identity: Send webhook: enrollment.complete<br/>{palm_type: "both"}
        end
        Identity-->>Device: {palm_enrolled: true}
        Device-->>User: "Enrolled — palm now works across Link"
    else consent missing — 403
        Identity-->>Device: 403 {error: consent_required}
        Device-->>User: "Consent required"
    end

7.4.3 Enrollment paths

Account creation and palm enrollment are separate lifecycles (§13.2). The account is keyed by mobile number and can be created in a product app/dashboard or at a device in signup mode; the palm is always enrolled (both hands) at a physical device (pos/kiosk). All device steps are device→Identity over mTLS — no vertical backend in the path.

Three entry points converge on a single enrollment call. The account may pre-exist (created in an app) or be created at the device in signup mode; the palm is always captured at a physical device, and KYC/consent gates apply on the way:

Diagram — Enrollment paths (entry point → gates → enroll)

flowchart TD
    Start([User needs palm enrollment]) --> Q{Account created where?}
    Q -->|App or dashboard| App[Sign up in app<br/>OTP, email or social]
    Q -->|At the device| POS[POST /v1/device/signup<br/>mobile OTP at POS or kiosk]
    App --> KYC{Product requires KYC<br/>and user unverified?}
    POS --> KYC
    KYC -->|Yes| DoKYC[Complete KYC first<br/>platform surfaces status, product gates]
    KYC -->|No| Consent
    DoKYC --> Consent{consent_required<br/>and not yet granted?}
    Consent -->|Yes| GetConsent[Capture consent<br/>POST /v1/device/consent mTLS, or in-app POST /v1/consent]
    Consent -->|No| Enroll
    GetConsent --> Enroll
    Enroll[POST /v1/device/enroll mTLS<br/>both palms captured at the device] --> Done([palm_enrolled true<br/>recognized across all Link products])

All device-side palm enrollment converges at POST /v1/device/enroll (mTLS) and captures both hands. The platform does not enforce KYC — it surfaces KYC status and the product decides whether to gate on it. When consent_required: true, the platform enforces consent (§10.1). Once enrolled, the palm is recognized across all Link Holdings products (§4.6).

7.4.4 Complete onboarding (end-to-end)

Diagram — Complete user onboarding (signup → KYC → consent → palm)

sequenceDiagram
    autonumber
    participant User
    participant App as Wallet Mobile App
    participant Wallet as Wallet Backend
    participant Identity as Identity Platform
    participant Nafath as Nafath
    participant NafathApp as Nafath App
    participant Kiosk as POS / Kiosk
    participant Palm as X-Telcom BioWave Pass
    participant Audit as Audit Log

    Note over Identity, Audit: All audit logging is conditional<br/>on tenant audit_enabled setting

    rect rgb(230, 245, 255)
        Note over User, Audit: Phase 1: Mobile Signup
        User->>App: Download app, enter mobile
        App->>Identity: Send OTP
        Identity->>Audit: Log: otp_sent
        Identity-->>User: SMS with OTP
        User->>App: Enter OTP
        App->>Identity: Verify OTP
        Identity->>Identity: Create user
        Identity->>Audit: Log: user_created
        Identity->>Audit: Log: token_issued
        Identity-->>App: {access_token, user_id}
        App-->>User: "Welcome!"
    end

    rect rgb(255, 245, 230)
        Note over User, Audit: Phase 2: KYC Verification
        User->>App: Tap "Verify Identity"
        App->>Wallet: Start KYC
        Wallet->>Identity: Initiate KYC
        Identity->>Audit: Log: kyc_initiated
        Identity->>Nafath: Request verification
        Nafath-->>Identity: {transId, random: "47"}
        Identity-->>App: Display "47"

        User->>NafathApp: Open, select "47"
        NafathApp->>Nafath: Confirm
        Nafath->>Identity: Callback (verified)
        Identity->>Audit: Log: kyc_verified
        Identity-->>App: "Identity verified!"
    end

    opt Consent from mobile (optional)
        Note over User, Audit: Phase 2B: Mobile Consent
        User->>App: Tap "Grant Consent"
        App->>Wallet: Grant consent
        Wallet->>Identity: POST /v1/consent<br/>{user_id, consent_type: "biometric_enrollment"}
        Identity->>Audit: Log: consent_granted
        Identity-->>App: {consent_id, status: "granted"}
        App-->>User: "Consent recorded"
    end

    rect rgb(230, 255, 230)
        Note over User, Audit: Phase 3: Palm Enrollment at POS (device-direct, mTLS)
        User->>Kiosk: Go to kiosk, enter mobile
        Kiosk->>Identity: POST /v1/device/signup (mTLS)<br/>{mobile} → Send OTP
        User->>Kiosk: Enter OTP
        Kiosk->>Identity: POST /v1/device/signup/verify (mTLS)
        Identity-->>Kiosk: {user_id, palm_enrolled: false,<br/>kyc_status ✓, consent_status}

        alt Consent already granted (from app)
            Note over Kiosk: Skip consent screen
        else Consent not on record
            Kiosk-->>User: "Consent required for<br/>biometric enrollment"
            User->>Kiosk: Accept consent
            Kiosk->>Identity: POST /v1/device/consent (mTLS)
            Identity->>Audit: Log: consent_granted
        end

        Kiosk-->>User: "Place both palms"
        User->>Kiosk: Scan both palms
        Kiosk->>Identity: POST /v1/device/enroll (mTLS)<br/>{user_id, scan (both palms)}
        Note right of Identity: Optional: /KZ/query duplicate check<br/>(see §7.9)
        Identity->>Palm: POST /KZ/add (both palms)<br/>(request_id header, tenant-prefixed user_id)
        Palm-->>Identity: {code: 0, data: {id: 100001}}
        Identity->>Audit: Log: palm_enrolled
        Identity-->>Kiosk: {palm_enrolled: true}
    end

    rect rgb(245, 230, 255)
        Note over User, Audit: Phase 4: First Transaction (device-initiated broker)
        User->>User: Go to merchant, place palm on POS
        Note right of User: POS → Identity POST /v1/device/transactions (mTLS)<br/>→ /KZ/query 1:N (4 scores/thresholds)<br/>→ broker authorize → Wallet decides (§9)
        Identity->>Audit: Log: device_transaction (allow)
        User->>User: Payment approved!
    end

7.5 Score Model

X-Telcom BioWave Pass returns a 4-element scores array alongside a 4-element thresholds array per query — IR and RGB features evaluated against the large and small models. Each index corresponds to a model variant:

Index Variant
0 Large-model IR
1 Large-model RGB
2 Small-model IR
3 Small-model RGB

Match Policy (configured per tenant via palm_match_policy):

Policy Behavior
all_thresholds All 4 scores must be ≥ corresponding thresholds (default — most strict)
majority At least 3 of 4 scores ≥ corresponding thresholds (relaxed)
any At least 1 score ≥ corresponding threshold (least strict — discouraged)

Under the large model, the platform applies the policy after receiving the vendor response — the matching decision (matched: true/false) is platform-controlled, not vendor-controlled. Under the small model the SDK/verification server decides and the platform records the device-reported result (§7.10).

7.6 Verification Result

{
  "user_id": "user_123",
  "matched": true,
  "scores": [1.0, 1.0, 1.0, 0.9495],
  "thresholds": [0.7018, 0.7211, 0.7072, 0.7253],
  "match_policy": "all_thresholds",
  "latency_ms": 180,
  "vendor": "biowave",
  "timestamp": "2026-02-25T10:00:00Z"
}

7.7 Identification Result

{
  "matched": true,
  "user_id": "user_123",
  "palm_id": 100001,
  "scores": [1.0, 1.0, 1.0, 0.9495],
  "thresholds": [0.7018, 0.7211, 0.7072, 0.7253],
  "match_policy": "all_thresholds",
  "candidates": 1,
  "search_pool_size": 124500,
  "latency_ms": 850,
  "vendor": "biowave",
  "timestamp": "2026-02-25T10:00:00Z"
}

7.8 Palm Type Restrictions

Per-user, verification-time restriction on which palm types can verify that user — it constrains verification/identification, not enrollment (enrollment is always both hands, §7.4.1).

Endpoint: PUT /v1/users/{user_id}/palm-restriction (Console session, Tenant Admin or Operator)

Body:

{ "query_type": "left" }

Allowed values for query_type:

Value Behavior
left Only left-palm verifications allowed
right Only right-palm verifications allowed
all Both palms allowed (default after enrollment)
disable All palm verifications blocked without deleting the template (used for soft-suspension)

When a verification or identification call is made and the user's restriction does not allow the submitted palm type, the platform returns 403 Forbidden with error palm_type_restricted (mapped from vendor error code 30008 — see §14).

Audit event: palm_restriction_set (records user_id, query_type, and set_by).

Common patterns: - After enrollment, lock to a specific palm type (e.g., left) - On account suspension, set to disable - On reinstatement, restore to original

Diagram — Lock a user to a specific palm type (verification-time)

sequenceDiagram
    autonumber
    participant Admin as Tenant Admin
    participant Console as Web Console
    participant Identity as Identity Platform
    participant Palm as X-Telcom BioWave Pass Palm Server
    participant DB as PostgreSQL
    participant Audit as Audit Log

    Note over Admin, Audit: Use cases:<br/>- Lock to specific palm after enrollment<br/>- Suspend palm verification on account hold<br/>- Re-enable on reinstatement

    Admin->>Console: Set palm restriction for user<br/>(e.g., "left only" or "disable")
    Console->>Identity: PUT /v1/users/{user_id}/palm-restriction<br/>Authorization: Bearer {token}<br/>{query_type: "left"}

    Identity->>DB: Validate user belongs to tenant
    DB-->>Identity: User found

    Identity->>Palm: POST /KZ/set_query_type<br/>Headers: request_id: {uuid}<br/>Content-Type: application/json<br/>Body: {user_id, query_type: "left"}

    alt Success — code: 0
        Palm-->>Identity: {code: 0, msg: "Success"}
        Identity->>DB: Update user.palm_query_type = "left"
        Identity->>Audit: Log: palm_restriction_set<br/>(user_id, query_type: "left",<br/>set_by: admin_id)

        opt Webhook subscribed
            Identity->>Identity: Send webhook: user.palm_restriction_changed<br/>{user_id, query_type}
        end

        Identity-->>Console: {user_id, query_type: "left",<br/>updated_at}
        Console-->>Admin: "Restriction applied"

    else User not found in X-Telcom BioWave Pass — code: 30006
        Palm-->>Identity: {code: 30006,<br/>msg: "DB ID not found"}
        Identity->>Audit: Log: palm_restriction_failed<br/>(user_id, reason: not_enrolled)
        Identity-->>Console: 404 Not Found<br/>"User not enrolled in palm"
    end

    Note over Admin, Audit: Subsequent /v1/device/transactions or /v1/verify<br/>calls with mismatched palm_type<br/>will fail with code 30008<br/>(see §7.4.2 — query type forbidden)

7.9 Pre-Enrollment Duplicate Detection

Detects identity fraud and accidental re-enrollment before committing a new palm template. Opt-in per tenant via palm_duplicate_check_enabled.

Before committing a new template the platform runs a 1:N POST /KZ/query. A conflict exists when a match passes thresholds with a different user_id than the enrolling user (a true duplicate also surfaces vendor code 30007 on /KZ/add). The full sequence is shown below. On conflict, the tenant's palm_duplicate_action decides:

Action Behavior
reject Enrollment fails with HTTP 409. User cannot enroll until ops reviews. Audit event: palm_duplicate_detected with severity: high and matched_user_ids.
flag Enrollment proceeds but a review_case record is created for ops to investigate.

When to enable: High-value tenants (banks, regulated finance) and any tenant where a single user enrolling under multiple identities is a fraud risk.

Performance impact: Adds one extra round-trip to the vendor per enrollment (~200–800ms). Not enabled by default.

Diagram — Pre-enrollment duplicate detection

sequenceDiagram
    autonumber
    participant Kiosk as POS / Kiosk
    participant Wallet as Wallet Backend
    participant Identity as Identity Platform
    participant Palm as X-Telcom BioWave Pass Palm Server
    participant DB as PostgreSQL
    participant Audit as Audit Log

    Note over Kiosk: User has placed both palms,<br/>features extracted

    Kiosk->>Wallet: POST /enroll/palm<br/>{user_id, features, images}
    Wallet->>Identity: POST /v1/enroll/both<br/>{external_user_id, hardware_id, metadata}

    Identity->>DB: Get tenant config
    DB-->>Identity: {palm_duplicate_check_enabled: true,<br/>duplicate_action: "reject" | "flag"}

    rect rgb(255, 245, 230)
        Note over Identity, Palm: Step 1: Similarity search<br/>(top-5 candidates)
        Identity->>Palm: POST /KZ/query<br/>Headers: request_id: {uuid}<br/>Content-Type: multipart/form-data<br/>Body:<br/>- features_rgb.bin (optional)<br/>- features_ir.bin (optional)<br/>- image_rgb.png (required)<br/>- image_ir.png (required)<br/>- metadata: {is_encrypted: false}<br/>(returns up to 5 candidates,<br/>deduped by user_id)

        alt Candidates above threshold returned
            Palm-->>Identity: {code: 0,<br/>data: {results: [up to 5 candidates,<br/>each with user_id, id, scores: [4]],<br/>thresholds: [4]}}

            Identity->>Identity: Filter results where<br/>all 4 scores ≥ thresholds<br/>AND user_id != current user_id

            alt Match found — possible fraud/duplicate
                Identity->>Audit: Log: palm_duplicate_detected<br/>(enrolling_user_id, matched_user_ids,<br/>scores, severity: high)

                alt Tenant config: reject
                    Identity-->>Wallet: 409 Conflict<br/>{error: "duplicate_palm",<br/>message: "Biometrics already registered"}
                    Wallet-->>Kiosk: "Cannot enroll —<br/>contact support"
                else Tenant config: flag for review
                    Identity->>DB: Create review_case<br/>(enrolling_user_id, matched_user_ids,<br/>status: pending)
                    Note over Identity: Continue to /KZ/add<br/>but flag for ops review
                end
            else No conflicting match
                Note over Identity: Safe to enroll —<br/>proceed to the /KZ/add enrollment step (§7.4.1)
            end

        else No candidate match (platform-decided)
            Palm-->>Identity: {code: 0}<br/>(no candidate ≥ thresholds → platform no-match)
            Note over Identity: Clean palm, proceed to enroll
        end
    end

    rect rgb(230, 255, 230)
        Note over Identity, Palm: Step 2: Proceed with enrollment
        Note over Identity: See §7.4.1 for the full /KZ/add<br/>request/response, error codes 30007/30005<br/>in §14
        Identity->>Palm: POST /KZ/add (see §7.4.1)
        Palm-->>Identity: {code: 0, data: {user_id, id, query_type}}
        Identity->>DB: Mark palm_enrolled = true,<br/>store palm_id
        Identity->>Audit: Log: palm_enrolled
        Identity-->>Wallet: {status: "enrolled"}
    end

7.10 Palm Model (Small vs Large)

The palm match runs in one of two models, chosen once per deployment by a Platform Admin — deployment-wide, not per-tenant or per-user (like the global thresholds in §7.14.2). The two models differ in one thing: who runs the match.

  • Large model — the platform matches. The device sends the scan to the platform, which calls the verification server over /KZ/*, applies palm_match_policy (§7.5), and owns the decision. This is the model the rest of §7 describes, and device-initiated verification runs a 1:N identify under it (§7.4.2).
  • Small model — the device matches. The device's client SDK calls the verification server directly; the platform sits outside the match path. The device reports the result — matched user_id + a pass/fail boolean — to the platform over its mTLS channel (§8.2). The platform records it and, for pos/gate, then authorizes the bound product with that user_id (§9).

The PalmVerifier port (§7.3) abstracts the large model only — the small model is not another adapter behind it. Under the small model the platform has no verification-server connection; one is added only to migrate (§7.11).

What each model supports:

Feature Large model Small model
palm_match_policy / 4-element score model (§7.5) platform decides N/A — SDK/server decides; platform records boolean
Pre-enrollment duplicate detection (§7.9) available N/A
Global threshold config (§7.14.2) applies N/A (SDK/server-side)
Palm type restrictions (§7.8) applies N/A in matching path
PalmVerifier-port identify/verify (§7.3) yes no — device SDK direct
Platform role matcher + router recorder + router (device-trusted)

Same endpoints, different payload. The active model decides what every device-facing palm endpoint carries: under the large model the device sends scans (the platform matches); under the small model it sends results (the platform records). This covers /v1/device/transactions, /v1/device/enroll, and the challenge endpoints /v1/verify + /v1/enroll/both + /v1/challenges/{id}/complete (transaction, enrollment, and verify flows). The platform implements both paths; migration (§7.11) flips which one is live.

Small-model results are device-trusted — mTLS authenticates the reporting device, not the match. Use the large model for production or fraud-sensitive scale.

This section is the canonical definition of the palm model (referenced by §13.7, §9.3, §16.1). The large-model sequences are in Palm Flows (§7.4.2); the small-model variants are shown below.

Small-model device flows (variants) — the device SDK matches or enrolls directly and the platform records the device-reported result:

Diagram — Small-model device transaction (client SDK direct)

sequenceDiagram
    autonumber
    participant User
    participant Device as POS / Gate (client SDK)
    participant Palm as X-Telcom BioWave Pass (verification server)
    participant Identity as Identity Platform
    participant Product as Wallet / Access Backend (linked service)
    participant Audit as Audit Log

    User->>Device: Place palm on scanner
    Device->>Device: Capture palm image (RGB + IR)

    rect rgb(255, 245, 230)
        Note over Device, Palm: Device-trusted — match decided SDK/server-side,<br/>NOT by the platform
        Device->>Palm: SDK 1:N match (direct — no platform on path)
        Palm-->>Device: {user_id, pass/fail, scores}
    end

    alt Match (SDK pass)
        Device->>Identity: POST /v1/device/transactions<br/>(mTLS — device cert)<br/>{user_id, matched: true,<br/>context, idempotency_key}
        Identity->>Identity: Parse SAN URI → device_id, tenant_id<br/>record result (no /KZ/* call)<br/>skip server-side identify<br/>sign identity_assertion JWT
        Identity->>Product: POST {base_url}{authorize_path}<br/>Authorization: Bearer {identity_assertion}<br/>{user_id, action, context, idempotency_key}
        Product-->>Identity: {decision, display_message,<br/>reference_id, ttl}
        Identity->>Audit: Log: device_transaction<br/>(source: device_reported, decision)
        Identity-->>Device: {decision, display_message,<br/>product_reference} (in-band)
        Device-->>User: Open gate / "Approved" (or decline)
    else No match (SDK fail)
        Device->>Identity: POST /v1/device/transactions<br/>{matched: false, context, idempotency_key}
        Identity->>Audit: Log: device_transaction (not_recognized)
        Identity-->>Device: {decision: "not_recognized"}
        Device-->>User: "Palm not recognized"
    end

Diagram — Small-model device enrollment (client SDK direct)

sequenceDiagram
    autonumber
    participant User
    participant Device as POS / Kiosk (client SDK)
    participant Identity as Identity Platform
    participant Palm as X-Telcom BioWave Pass (verification server)
    participant SMS as SMS Provider
    participant Audit as Audit Log

    User->>Device: Tap "Enroll" / "Sign up"
    Device->>Identity: POST /v1/device/signup (mTLS)<br/>{mobile}
    Identity->>Identity: Find or create user by mobile<br/>(tenant-scoped)
    Identity->>SMS: Send OTP
    SMS-->>User: OTP code
    User->>Device: Enter OTP
    Device->>Identity: POST /v1/device/signup/verify (mTLS)<br/>{challenge_id, code}
    Identity-->>Device: {user_id, is_new_user,<br/>palm_enrolled: false,<br/>consent_status, kyc_status}

    opt Product requires KYC and kyc_status != verified
        Device-->>User: Direct to complete KYC first<br/>(app / Nafath)
    end

    opt consent_required and consent_status = none
        Device->>User: Show consent screen
        User->>Device: Accept
        Device->>Identity: POST /v1/device/consent (mTLS)<br/>{user_id, consent_type, version}
        Identity->>Audit: Log: consent.granted
    end

    Note over Device: Consent is gated HERE, device-side, BEFORE enrolling —<br/>the platform is off the enroll path under the small model.

    User->>Device: Place both palms
    Device->>Device: Capture both palms (RGB + IR)

    rect rgb(255, 245, 230)
        Note over Device, Palm: Device-trusted — enrollment done SDK/server-side,<br/>NOT by the platform
        Device->>Palm: SDK enroll (direct — no platform on path)
        Palm-->>Device: {palm_id, success}
    end

    Device->>Identity: POST /v1/device/enroll (mTLS)<br/>{user_id, enrolled: true}
    Identity->>Identity: Record palm_enrolled (no /KZ/add)<br/>trust device-reported result
    Identity->>Audit: Log: enrollment.complete<br/>(source: device_reported)
    opt Webhook subscribed
        Identity->>Identity: Send webhook: enrollment.complete
    end
    Identity-->>Device: {palm_enrolled: true}
    Device-->>User: "Enrolled — palm now works across Link"

7.11 Small→Large Migration

A Platform Admin migrates a deployment from the small model to the large model. The migration is one-way and reprocesses the already-stored RGB+IR enrollments into large-model representations — no re-enrollment and no user action.

Prerequisite — verification-server endpoint. Under the small model the platform has no connection to the verification server (the device SDK does). Before migrating — and for large-model operation afterward — a Platform Admin configures the deployment's verification-server endpoint (connection URL) so the platform can reach it (GET/PUT /v1/admin/palm/verification-server, §12.2; stored deployment-level, §13.7).

Triggered via POST /v1/admin/palm/migrate (§12.2), the platform connects to the configured verification-server endpoint, runs the vendor-supplied migration script (reprocessing stored RGB+IR enrollments into large-model representations), and monitors progress (GET /v1/admin/palm/model). On success it atomically switches the deployment small→large — request handling moves from the small-model record path to the large-model /KZ/* broker path, with in-flight operations finishing under the old model — emitting palm_model_migration_startedpalm_model_migration_completed + palm_model_changed. Failure emits palm_model_migration_failed and leaves the model unchanged. The full sequence is shown below.

Diagram — Small→large model migration

sequenceDiagram
    autonumber
    participant Admin as Platform Admin
    participant Console as Web Console
    participant Identity as Identity Platform
    participant Palm as X-Telcom BioWave Pass (verification server)
    participant DB as PostgreSQL
    participant Audit as Audit Log

    Note over Admin, Identity: Prerequisite — verification-server endpoint configured<br/>(PUT /v1/admin/palm/verification-server — §13.7)

    Admin->>Console: "Migrate to large model" (confirm)
    Console->>Identity: POST /v1/admin/palm/migrate
    Identity->>Audit: Log: palm_model_migration_started

    rect rgb(255, 245, 230)
        Note over Identity, Palm: Platform runs the vendor-supplied migration script<br/>against the configured verification-server endpoint
        Identity->>Palm: Connect + run migration script<br/>(reprocess stored RGB+IR → large model)
        loop Until complete
            Identity->>Palm: Poll migration progress
            Palm-->>Identity: {status}
        end
    end

    alt Migration succeeded
        rect rgb(230, 255, 230)
            Identity->>DB: Switch deployment palm model small→large
            Identity->>Audit: Log: palm_model_migration_completed + palm_model_changed
            Identity-->>Console: {model: "large", migration_status: "completed"}
            Console-->>Admin: "Migration complete — flows now use the large-model server API (§7.4.2)"
        end
    else Migration failed
        Identity->>Audit: Log: palm_model_migration_failed (model unchanged)
        Identity-->>Console: 500 {error: migration_failed}
        Console-->>Admin: Error — deployment stays on the small model
    end

7.12 Supported Palm Vendors

Vendor Technology Strengths Integration
X-Telcom BioWave Pass Palm vein (IR + RGB; large/small models) Sole MVP vendor; large-scale 1:N comparison; self-hostable REST API (/KZ/*)

Additional vendors can be plugged in behind the PalmVerifier port (§7.3) with no business-logic change; BioWave Pass is the only vendor documented for MVP.

7.13 Vendor Configuration (per Tenant)

{
  "tenant_id": "wallet",
  "palm_config": {
    "vendor": "biowave",
    "vendor_config": {
      "base_url": "http://palm.internal.link.sa:8080",
      "request_id_header": "request_id",
      "timeout_ms": 2000
    },
    "match_policy": "all_thresholds",
    "duplicate_check_enabled": true,
    "duplicate_action": "reject"
  }
}

7.14 Operational

7.14.1 Health Monitoring

The platform monitors vendor reachability (POST /KZ/connect) and version (GET /pv/version) on a periodic cadence (default: every 60s). Failed checks trigger a palm_vendor_unhealthy audit event and page the on-call rotation. Version changes trigger palm_vendor_version_change for traceability.

Diagram — X-Telcom BioWave Pass health check

sequenceDiagram
    autonumber
    participant Cron as Health Monitor
    participant Identity as Identity Platform
    participant Palm as X-Telcom BioWave Pass Palm Server
    participant Audit as Audit Log
    participant Alert as Ops Alerting

    Cron->>Identity: Trigger health check<br/>(startup / interval / post-deploy)

    par Connection test
        Identity->>Palm: POST /KZ/connect<br/>Headers: request_id: {uuid}<br/>Content-Type: application/json<br/>Body: (none)
        alt Reachable
            Palm-->>Identity: {code: 0, msg: "Success"}
        else Unreachable
            Palm-->>Identity: timeout / network error
        end
    and Version probe
        Identity->>Palm: GET /pv/version<br/>Headers: request_id: {uuid}<br/>(no body, response is text/plain)
        alt Reachable
            Palm-->>Identity: "0.0.13" (text/plain)
        else Unreachable
            Palm-->>Identity: timeout / 5xx
        end
    end

    alt All checks passed
        Identity->>Identity: Update health status: healthy
        opt Version changed since last check
            Identity->>Audit: Log: palm_vendor_version_change<br/>(old: "0.0.12", new: "0.0.13")
        end
    else Any check failed
        Identity->>Identity: Update health status: unhealthy
        Identity->>Audit: Log: palm_vendor_unhealthy<br/>(reason, severity: high)
        Identity->>Alert: Page on-call —<br/>X-Telcom BioWave Pass unreachable
    end

7.14.2 Threshold Configuration

Platform Admins can tune the vendor's 4 global thresholds (vendor POST /KZ/set_thresholds) via GET/PUT /v1/admin/palm/thresholds. Used for: - Tightening after a fraud incident - Per-environment calibration (staging vs. production) - Trade-off tuning (false-accept vs. false-reject rate)

Thresholds are global at the vendor side (not per-tenant). Tenant-level relaxation is done through palm_match_policy instead.

Audit event: palm_thresholds_updated (records old and new threshold values).

Diagram — X-Telcom BioWave Pass threshold configuration

sequenceDiagram
    autonumber
    participant Admin as Platform Admin
    participant Console as Web Console
    participant Identity as Identity Platform
    participant Palm as X-Telcom BioWave Pass Palm Server
    participant DB as PostgreSQL
    participant Audit as Audit Log

    Note over Admin: Use cases:<br/>- Tighten thresholds after fraud event<br/>- Loosen for lower-security environment<br/>- Tune per-model variant accuracy

    Admin->>Console: Open palm threshold settings
    Console->>Identity: GET /v1/admin/palm/thresholds<br/>Authorization: Bearer {admin-token}
    Identity->>DB: Get current thresholds (cached)
    DB-->>Identity: thresholds: [4 floats]
    Identity-->>Console: {thresholds: [4],<br/>last_updated_at, updated_by}
    Console-->>Admin: Display current thresholds<br/>(annotated with model variant per index)

    Admin->>Console: Update thresholds<br/>(e.g., bump index 0 from 0.70 → 0.75)
    Console->>Identity: PUT /v1/admin/palm/thresholds<br/>Authorization: Bearer {admin-token}<br/>{thresholds: [4 floats]}

    Identity->>Identity: Validate: all values 0.0–1.0,<br/>array length == 4

    Identity->>Palm: POST /KZ/set_thresholds<br/>Headers: request_id: {uuid}<br/>Content-Type: application/json<br/>Body: {thresholds: [4 floats]}

    alt Success
        Palm-->>Identity: {code: 0, msg: "Success"}
        Identity->>DB: Persist thresholds<br/>(audit trail)
        Identity->>Audit: Log: palm_thresholds_updated<br/>(updated_by: admin_id,<br/>old_thresholds, new_thresholds)
        Identity-->>Console: {status: "updated",<br/>thresholds, updated_at}
        Console-->>Admin: "Thresholds updated"
    else Validation/permission error
        Palm-->>Identity: {code: 10001, msg: "..."}
        Identity-->>Console: 400 Bad Request
        Console-->>Admin: Error message
    end