10. Linked Services & Device Transactions (Broker)¶
The broker that routes device-initiated transactions to linked services — the authorize contract, caller authentication, reliability, and health monitoring.
10.1 Overview¶
A linked service is a product backend (e.g. Wallet, Access) the platform calls synchronously during a device-initiated transaction. When a user scans at a pos or gate device, the device makes one mTLS call to POST /v1/device/transactions (§14.1). The platform identifies the palm (1:N), then synchronously calls the device's bound linked service and relays its allow/deny decision back to the device on that same response.
The product owns the business decision (charge the wallet, open the gate); the platform owns identification + routing. This keeps the IdP boundary intact — the platform never learns payment or access-control mechanics, only "call product X, relay its verdict." Webhooks are never on this decision path (a device at a gate cannot block on an async webhook); the broker is the synchronous channel.
10.2 Linked Service Registry¶
Each tenant registers one entry per product it routes to. The linked_services schema (product_key, base_url, authorize_path, timeout_ms, fail_mode, status) is defined in §5.4.
Managed via /v1/linked-services CRUD (Console, Tenant Admin — §14.1). The product authenticates inbound authorize calls by verifying the identity_assertion JWT against the platform JWKS — the platform stores no per-product secret (§10.5). Within the Link Holdings tenant there are linked services for wallet, access, and each Access sub-product; external tenants register their own.
Diagram 8.4 — Register a Linked Service
sequenceDiagram
autonumber
participant Admin as Tenant Admin
participant Console as Web Console
participant Identity as Identity Platform
participant DB as PostgreSQL
participant Audit as Audit Log
Note over Identity, Audit: All audit logging is conditional<br/>on tenant audit_enabled setting
Admin->>Console: Open "Linked Services" → "Add service"
Console-->>Admin: Show form:<br/>product_key, base_url, authorize_path,<br/>health_path, timeout_ms, fail_mode
Admin->>Console: Fill form, click Save
Console->>Identity: POST /v1/linked-services<br/>Authorization: Console session<br/>{product_key: "wallet", base_url,<br/>authorize_path, health_path,<br/>timeout_ms: 800, fail_mode: "closed"}
Identity->>Identity: Validate admin role (Tenant Admin),<br/>extract tenant_id, check product_key<br/>unique per tenant + URLs well-formed
Identity->>DB: Store linked_services record<br/>(id, tenant_id, product_key, base_url,<br/>authorize_path, health_path, timeout_ms,<br/>fail_mode, status: active,<br/>health_status: unknown)
Identity->>Audit: Log: linked_service_created<br/>(tenant_id, product_key, actor: admin)
Identity-->>Console: {id: "lsvc_abc", product_key: "wallet",<br/>status: "active"}
Console-->>Admin: "Wallet backend linked"
Note over Console, Identity: No secret exchanged. The product verifies the<br/>identity_assertion JWT via the platform JWKS (PRD §6.12);<br/>it must implement the authorize endpoint (PRD §10.4)<br/>and a health endpoint (PRD §10.7).
Note over Identity: Devices bound to product_key "wallet"<br/>(bound_product, PRD §9.3) now route here<br/>via the broker (§4.3).
10.3 Broker Sequence¶
Full diagram: identity-platform-diagrams.md §4.3.
- Device →
POST /v1/device/transactions(mTLS) with{scan, context, idempotency_key}. - Platform derives
device_id/tenant_idfrom the SAN URI (§9.2.1) and looks up the device'sbound_product+bound_action(§9.3). - Platform runs 1:N
identify(PalmVerifier port, §8.3). No match → returnsnot_recognizedin-band; no product call. Small-model branch (§8.13): under the small model the device SDK has already matched and brings the matcheduser_idin the request; the platform skips this server-side identify and proceeds to step 4 with the device-supplieduser_id. Steps 4–6, the mTLS edge, and theidentity_assertionare unchanged. - Match → platform calls the linked service's
authorizeendpoint with a signedidentity_assertion. - Product returns
{decision, display_message, reference_id, ttl}. - Platform relays the decision in-band, audit-logs the transaction (§11.3), and emits
device.transaction.completed(§16) to async subscribers (delivered via §12).
Diagram 4.3 — Device-Initiated Palm 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 — PRD §9.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<br/>(sub: user_id, aud: product_key,<br/>context_hash, jti, exp ≤ 60s)
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 4.4 — Device-Initiated Enrollment (POS → Consent → Enroll)
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 palm
Device->>Device: Capture palm image (RGB + IR)
Device->>Identity: POST /v1/device/enroll (mTLS)<br/>{user_id, scan}
alt consent satisfied
Identity->>Palm: POST /KZ/add<br/>tenant-prefixed user_id
Palm-->>Identity: {code: 0, palm_id}
Identity->>Audit: Log: enrollment.complete
opt Webhook subscribed
Identity->>Identity: Send webhook: enrollment.complete
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
10.4 The authorize Contract¶
Each product implements one narrow endpoint (base_url + authorize_path from its registry entry, §5.4):
POST {base_url}{authorize_path}
Authorization: Bearer <identity_assertion JWT>
{ "user_id": "...", "tenant_id": "...", "device_id": "...",
"action": "pay", "context": { "amount": 50.00, "currency": "SAR" },
"idempotency_key": "..." }
{ "decision": "allow", "display_message": "Approved · SAR 50.00",
"reference_id": "wal_txn_889", "ttl_seconds": 30 }
decision ∈ allow / deny. The product MUST be idempotent on idempotency_key — a device retry must not double-charge.
10.5 Caller Authentication — identity_assertion JWT¶
The platform is the signing authority (the IdP), so the assertion is the caller credential — no shared secret is stored by the platform. The product verifies the JWT against the platform's JWKS (/.well-known/jwks.json, §6.12).
| Claim | Meaning |
|---|---|
iss |
The platform |
aud |
The linked service's product_key |
sub |
user_id (the identified user) |
device_id, action |
Which device, which action |
context_hash |
SHA-256 of the request context — binds the assertion to this exact request |
idempotency_key |
Ties the assertion to the device call |
iat, exp, jti |
Issued-at, expiry (≤60s), unique id for replay defense |
The product rejects expired assertions and replayed jti values. mTLS is not used for this hop — it is a first-party internal call and the asymmetric assertion already authenticates the caller; mTLS is reserved for the device→platform edge (§9.2). A service mesh providing L4 mTLS is additive, not required.
10.6 Reliability — Timeout, Circuit Breaker, Fail Mode¶
The authorize call sits on the device's critical path, so it is tightly bounded:
| Control | Behavior |
|---|---|
timeout_ms |
Per-linked-service deadline on the authorize call. The product must answer fast — 1:N identify alone is ~850ms (§8.6). |
| Circuit breaker | Repeated timeouts/5xx open the circuit; calls short-circuit to the fail mode until it half-opens. |
fail_mode |
On timeout / open circuit: closed (default — return deny; a gate that cannot confirm must not open) or open (return allow; only for low-value, high-throughput lanes). Per linked service. |
Every transaction — match, no-match, product decision, fail-mode fallback — is audit-logged (device_transaction, §11.3) with latencies, for SAMA traceability. The decision is returned to the device synchronously; the device.transaction.completed webhook (§16) carries the same event to async consumers but is never the device's decision channel. This circuit breaker is the reactive live-path control; proactive detection of a down service is §10.7.
10.7 Health Monitoring¶
The platform proactively probes each active linked service so an outage is caught before it hits a transaction — parallel to the palm-vendor monitor (§8.12.1), and complementary to the per-transaction circuit breaker (§10.6, which remains the live-path control).
- Probe:
GET {base_url}{health_path}on a periodic cadence (platform default 60s), expecting HTTP 2xx (optionally{status, version}). - Unhealthy: N consecutive failures (default 3) flip the service to
unhealthyand emitlinked_service_unhealthy(§16) — audit event + tenant webhook + a console banner for the Tenant Admin. A later successful probe flips it back tohealthy(shown in the status read; no separate event). - Status: surfaced in
GET /v1/linked-services(health_status,last_health_check_at) and the console.
Diagram 9.4 — Linked Service Health Check
sequenceDiagram
autonumber
participant Cron as Health Monitor
participant Identity as Identity Platform
participant Product as Linked Service (Wallet / Access Backend)
participant Audit as Audit Log
participant Notify as Tenant Webhook / Console
Cron->>Identity: Trigger health check<br/>(per active linked service, ~60s)
Identity->>Product: GET {base_url}{health_path}<br/>(no body)
alt Healthy — HTTP 2xx
Product-->>Identity: 200 OK {status: "ok", version}
Identity->>Identity: health_status = healthy,<br/>last_health_check_at = now
else Unhealthy — timeout / 5xx
Product-->>Identity: timeout / 5xx
Identity->>Identity: consecutive_failures++;<br/>after N (default 3) →<br/>health_status = unhealthy
Identity->>Audit: Log: linked_service_unhealthy<br/>(tenant_id, product_key, reason,<br/>consecutive_failures, severity)
Identity->>Notify: webhook linked_service.unhealthy<br/>+ console banner (Tenant Admin)
end
Note over Identity, Product: Recovery: next successful probe → healthy.<br/>The circuit breaker (PRD §10.6) still guards<br/>the live transaction independently.