5. Data Model¶
This is the canonical home for every persisted entity. Capability sections (§6–§12) describe behavior and reference these schemas rather than redefining them. Every entity carries tenant_id unless noted (platform-level singletons do not). Where the platform's prior specification never formalized an entity's fields, that is called out as an ⚠ Open Question rather than invented here.
5.1 Entity Catalog¶
| Entity | Purpose | Key / Scope | Defined in |
|---|---|---|---|
tenant |
A customer/integrator boundary | tenant_id (slug) |
§5.2 |
users |
End-user account (managed identity) | user_id + tenant_id; mobile is the natural key |
§5.2 |
profile |
End-user profile data (sub-object of users) |
— | §5.2 |
console_users |
Platform/tenant operators (separate domain — §3.2) | id + role (+ tenant_id) |
§5.2 |
oauth_clients |
Integrator backend credentials | client_id + tenant_id |
§5.3 |
refresh_tokens |
End-user refresh tokens | per user | §5.3 |
webhook_endpoints |
Outbound webhook subscriptions + signing secret | id + tenant_id (or platform scope) |
§5.3 |
devices |
Identity-managed hardware (scanner/pos/gate/kiosk) | device_id + tenant_id |
§5.4 |
challenges |
Integrator-initiated verify/enroll jobs (personal scanner) | challenge_id + tenant_id |
§5.4 |
device_transactions |
Append-only log of device-initiated transactions | per tenant/device | §5.4 |
linked_services |
Per-tenant product-backend registry for the broker | id; unique per tenant_id + product_key |
§5.4 |
consent_records |
Consent grants/withdrawals | per user | §5.5 |
audit_log |
All business events | per tenant | §5.5 |
kyc_data |
KYC state/result on a user (hooks; [POST-MVP] detail — §7) | sub-object of users |
§5.5 |
review_case |
Ops fraud-review case opened when duplicate detection is set to flag (§8.10) |
per tenant | §5.5 |
platform_secrets |
Server TLS cert + platform CA (platform singleton) | by kind |
§5.6 |
5.2 Tenancy & Identity¶
tenant (provisioning lifecycle in §4.3–4.4):
| Field | Description |
|---|---|
tenant_id |
Unique identifier (slug format) |
name |
Display name |
status |
provisioning, active, suspended, deactivating, deleted |
region |
Deployment region (e.g., KSA, UAE) |
config |
Tenant configuration (the §4.5 settings) |
created_at |
Creation timestamp |
created_by |
Platform Admin who provisioned |
users (the unified end-user table). All fields are available to all tenants; each is populated based on which capabilities the tenant has enabled.
| Field | Description | Populated When |
|---|---|---|
user_id |
Unique user identifier | Always — platform-generated or provided via POST /v1/users |
tenant_id |
Tenant this user belongs to | Always |
mobile |
Mobile number | Optional |
mobile_verified |
Whether mobile is verified | Auth enabled |
email |
Email address | Optional |
email_verified |
Whether email is verified | Auth enabled |
password_hash |
Hashed password | Password auth enabled |
google_id |
Google OAuth ID | Google auth enabled |
apple_id |
Apple OAuth ID | Apple auth enabled |
profile |
User profile data (JSON — see below) | Auth enabled |
kyc_status |
KYC verification status (§5.5) | KYC enabled |
kyc_data |
Verified KYC data (§5.5) | KYC enabled |
status |
active / suspended / deleted | Always |
palm_enrolled |
Whether palm template exists | Always |
created_at |
Account creation timestamp | Always |
enrolled_at |
Palm enrollment timestamp | Palm enrolled |
Mobile is the account key; palm enrollment is a separate lifecycle. A user record is created by mobile number — either at a device in signup mode (§13) or in a product app/dashboard.
palm_enrolledis independent of that: it flips totrueonly when a palm is captured at a physical device (pos/kiosk) — never in an app. Every surface (wallet app, access dashboard) readspalm_enrolledto prompt an un-enrolled user to "enroll at a POS/kiosk." Within the Link Holdings tenant, one record + palm serves all products (§4.6).
profile (JSON sub-object of users):
{
"name": "string",
"name_ar": "string",
"date_of_birth": "date",
"nationality": "string",
"address": "string",
"national_id": "string",
"avatar_url": "string",
"custom_fields": {}
}
console_users (separate identity domain — §3.2 / §3.6; never overlaps users):
| Field | Description |
|---|---|
id |
Unique console-user identifier |
email |
Login email |
password_hash |
bcrypt hash |
role |
platform_admin, tenant_admin, or tenant_operator (§3.3) |
tenant_id |
Owning tenant (null for platform_admin) |
must_change_password |
Forces a password reset on next login (set for the env-seeded first admin — §3.6) |
mfa_enabled |
MFA required for Platform/Tenant Admins (§3.6) |
5.3 Authentication & Clients¶
oauth_clients (integrator backend credentials; lifecycle in §6.4):
| Field | Type | Description |
|---|---|---|
client_id |
string | Unique identifier (UUID, platform-generated) |
tenant_id |
string | Owning tenant |
name |
string | Human-readable label (e.g., "Wallet Backend", "Analytics Pipeline") |
grant_types |
string[] | Allowed grant types (default: ["client_credentials"]) |
status |
string | active or revoked |
secret_hash |
string | bcrypt hash of the current secret |
created_at |
timestamp | Creation time |
created_by |
string | Console user who created the client |
last_rotated_at |
timestamp | Last time the secret was regenerated |
revoked_at |
timestamp | When the client was revoked (if applicable) |
webhook_endpoints (outbound subscriptions; behavior in §12). Each endpoint owns its own signing secret, separate from OAuth client secrets (§6.5). Fields (see §12.6):
| Field | Description |
|---|---|
id |
Endpoint identifier |
tenant_id |
Owning tenant (omitted/platform-level when scope = platform) |
url |
Destination URL (POST target) |
events |
Subscribed event types (§16) |
status |
active / inactive |
scope |
tenant (default) or platform (Platform Admin only — receives platform.* events) |
signing_secret_hash |
bcrypt hash of the HMAC-SHA256 signing secret (plaintext shown once) |
refresh_tokens — issued to end users; rotated on each refresh (§6.10); 30-day retention (§11.4); stored in the Redis token store (§20). ⚠ Open Question: the persisted field schema (token hash, user_id, issued_at, rotated_from, revocation flag) is not formalized in this PRD.
5.4 Devices & Transactions¶
devices (Identity-managed hardware; lifecycle in §9.1–§9.3):
| Field | Description |
|---|---|
device_id |
Unique device identifier (cert Subject CN) |
tenant_id |
Owning tenant (cert SAN URI) |
device_class |
personal_scanner | pos | gate | kiosk (§9.1) |
bound_product |
product_key this device routes to — pos/gate/kiosk only; null for personal_scanner (§9.3) |
bound_action |
Action the bound product authorizes (e.g. pay, entry) — non-scanner only (§9.3) |
status |
pending_pairing | paired | revoked (§9.2.1) |
pairing_code_hash |
Hashed pairing code, 5-min TTL, single-use (§9.2.1) |
cert_fingerprint |
Current client-cert fingerprint (revocation checks against the denylist) |
device_info |
{model, firmware, serial, hardware_id} captured at pairing (§9.2) |
created_at |
Registration timestamp |
challenges (integrator-initiated verify/enroll jobs for personal scanners; behavior in §9.2; see also the challenge_created event, §16):
| Field | Description |
|---|---|
challenge_id |
Unique challenge identifier |
tenant_id |
Owning tenant |
user_id |
Subject user (the challenge carries the claimed identity) |
device_id |
Target device that polls for it |
type |
verify or enroll |
status |
pending / completed / expired |
context |
Passthrough metadata echoed to webhooks (e.g. document_id) |
created_at |
Creation timestamp |
device_transactions (append-only log of every device-initiated transaction — §10). Retained 10 years (SAMA — §11.4):
| Field | Description |
|---|---|
tenant_id |
Owning tenant |
device_id |
Device that initiated |
user_id |
Identified user (nullable — null on not_recognized) |
product_key |
Bound product the broker called |
action |
Bound action (e.g. pay, entry) |
context |
Request context, redacted (e.g. amount/currency) |
identify_result |
Match outcome / scores summary |
decision |
allow / deny / not_recognized / fail-mode fallback |
product_reference |
Product's reference id from authorize |
idempotency_key |
De-dupes device retries |
latencies |
Per-stage latency (identify, authorize) |
created_at |
Timestamp |
linked_services (per-tenant product-backend registry for the broker — §10):
| Field | Description |
|---|---|
id |
Surrogate id used by the management API path (/v1/linked-services/{id}, §14.1) |
tenant_id |
Owning tenant |
product_key |
Stable id for the product (e.g. wallet, access). Devices bind to it via bound_product (§9.3); it is the aud of the identity_assertion (§10). |
base_url |
Product backend base URL |
authorize_path |
Path of the product's authorize endpoint (appended to base_url) |
health_path |
Path of the product's health endpoint (appended to base_url), probed periodically (§10.7) |
timeout_ms |
Deadline for the authorize call (default 800) |
fail_mode |
closed (default — deny on timeout/open circuit) or open (allow) |
status |
active / disabled |
health_status (healthy / unhealthy / unknown) and last_health_check_at are platform-tracked (not admin-set) and surfaced via the API / console (§10.7).
5.5 Compliance¶
consent_records (consent grants/withdrawals — §11.1; see also the consent events, §16). Retained 5 years (§11.4):
| Field | Description |
|---|---|
consent_id |
Unique consent record id |
tenant_id |
Owning tenant |
user_id |
Subject user |
consent_type |
The consent purpose/type |
version |
Consent text version granted |
purposes |
Purposes covered |
status |
granted / withdrawn |
granted_at |
Grant timestamp |
withdrawn_at |
Withdrawal timestamp (if withdrawn) |
audit_log — common fields below; the full event-type catalog is in §16 (Event Reference). Retained 10 years (SAMA — §11.4):
| Field | Description |
|---|---|
event_id |
Unique event identifier |
event_type |
Event name (catalog: §16) |
timestamp |
ISO 8601 timestamp |
tenant_id |
Tenant that triggered the event |
actor |
Who performed the action: {type: "user"|"client"|"system", id: "..."} |
ip_address |
Source IP address |
user_agent |
Client user agent string |
result |
success or failure |
metadata |
Event-specific fields (§16) |
kyc_data (hooks). The users.kyc_status ∈ {none, pending, verified, failed, expired} and users.kyc_data fields carry KYC state so palm-enrollment gating (§13.2) and the Web Console can read it. The detailed verified_data schema, KYC sessions, and results are deferred — KYC integration is [POST-MVP] (§7).
review_case — opened (instead of rejecting) when a tenant's palm_duplicate_action is flag and pre-enrollment duplicate detection finds a possible duplicate (§8.10); enrollment proceeds, but the case is queued for ops/fraud investigation. It captures the same signals as the palm_duplicate_detected event (enrolling_user_id, matched_user_ids, scores) plus a review status. ⚠ Open Question: the full persisted field schema is not yet specified.
5.6 Platform Infrastructure¶
platform_secrets — singleton-by-kind rows storing the backend's server TLS cert and the platform CA (§9.2.1, §9.2.2):
| Field | Description |
|---|---|
kind |
server_tls or platform_ca (one row each) |
cert_pem |
PEM-encoded certificate |
private_key_pem |
PEM-encoded private key (CA private key never touches disk after load) |
uploaded_by |
Console user who uploaded/generated it |
uploaded_at |
Timestamp |
fingerprint |
Cert fingerprint |
last_alert_threshold_days |
Last expiry-alert threshold fired, for idempotent alerting (§9.2.2) |
Database engine encryption-at-rest (RDS, Cloud SQL, on-prem Postgres TDE) protects the data layer; no app-level encryption, no env vars, no external secret manager. The app loads both rows into memory at startup — the platform CA private key never touches disk.
5.7 Data Separation & Namespacing¶
The Link Identity backend owns identity, auth, device, and compliance data. Palm templates live only in the Verification Server, reached through the PalmVerifier port — they are never stored in or exposed by the Identity backend.
┌─────────────────────────┐ ┌─────────────────────────┐
│ Link Identity Backend │ │ Verification Server │
│ (Link-owned) │ │ (via PalmVerifier Port) │
│ │ │ │
│ - user_id │────►│ - palm_template │
│ - identities │ │ - user_id reference │
│ - password_hash │ │ │
│ - refresh_tokens │ │ Vendor adapters: │
│ - profile │ │ - X-Telcom BioWave Pass│
│ - kyc_data │ │ (sole vendor; via │
│ - consent records │ │ PalmVerifier port) │
│ - client registry │ │ │
│ - device registry │ │ │
│ - platform_secrets │ │ │
│ - audit logs │ │ │
└─────────────────────────┘ └─────────────────────────┘
Palm namespace. Palm templates are tenant-scoped — the user_id sent to the vendor is namespace-prefixed with <tenant_slug>__ (§4.7). Within a tenant the namespace is shared across products, so Link Holdings' Wallet and Access resolve the same enrollment.
Palm model (deployment-wide). The active palm model (small/large — defined in §8.13) and the §8.14 verification-server endpoint are deployment-level settings — not per-tenant, not per-user — stored once at the platform level alongside the §8.12.2 global thresholds. Migration (§8.14) transitions the whole deployment. Under the small model devices' client SDKs reach the verification server directly, so the PalmVerifier-port integration is the large-model path.
Diagram 1.3 — Data Architecture
flowchart LR
subgraph Link Identity Backend
subgraph PostgreSQL
USERS[(users<br/>user_id, tenant_id,<br/>mobile, status,<br/>palm_enrolled, profile)]
IDENT[(identities<br/>mobile, email,<br/>google_id, apple_id)]
KYC[(kyc_records<br/>status, provider,<br/>verified_data)]
CONSENT[(consent_records<br/>type, version,<br/>granted_at, evidence)]
CLIENTS[(clients<br/>client_id,<br/>secret_hash)]
LINKED[(linked_services<br/>product_key, base_url,<br/>authorize_path, fail_mode)]
DEVICES[(devices<br/>device_id, tenant_id,<br/>device_class, bound_product,<br/>cert_fingerprint, status)]
DEVTXN[(device_transactions<br/>device_id, user_id?,<br/>decision, idempotency_key,<br/>retention: 10 years)]
AUDIT[(audit_logs<br/>event, timestamp,<br/>actor, details,<br/>retention: 10 years)]
end
subgraph Redis
TOKENS[(refresh_tokens)]
OTP[(otp_codes)]
CHALLENGES[(challenges)]
SESSIONS[(rate_limits)]
end
end
subgraph Palm Verification Server
subgraph X-Telcom BioWave Pass
PALM[(palm_templates<br/>user_id reference,<br/>features_rgb,<br/>features_ir)]
end
end
USERS --> IDENT
USERS --> KYC
USERS --> CONSENT
USERS -.->|user_id reference| PALM