Risk & Compliance Domain¶
Fraud detection, KYC verdicts, sanctions screening, and AML compliance. Highest regulatory requirements and longest retention (10 years).
Overview¶
| Attribute | Value |
|---|---|
| Owner | Risk & Compliance Team |
| Contact | risk-compliance@chakra.fintech |
| Data Products | risk-scores, kyc-master, sanctions-list |
| Kafka Topics | risk-scores-raw, kyc-verdicts-raw |
| Iceberg Namespace | risk_compliance |
| Freshness SLA | 10 minutes |
| Availability SLA | 99.99% |
| Retention | 10 years (AML/SOX) |
| Approval Required | YES (sensitive data) |
| Approval SLA | 2 hours |
| Status | Production |
Data Products¶
fraud-scores¶
Real-time fraud risk scoring for transactions.
Schema (Avro v1.0):
{
"type": "record",
"name": "FraudScore",
"fields": [
{"name": "transaction_id", "type": "string"},
{"name": "account_id", "type": "string"},
{"name": "fraud_score", "type": "double"},
{"name": "risk_level", "type": {"type": "enum", "symbols": ["LOW", "MEDIUM", "HIGH", "CRITICAL"]}},
{"name": "reasons", "type": {"type": "array", "items": "string"}},
{"name": "evaluated_at", "type": "long"}
]
}
Quality Rules:
- name: valid_fraud_score
rule: "fraud_score >= 0.0 AND fraud_score <= 1.0"
impact: critical
- name: risk_level_matches_score
rule: |
(risk_level = 'LOW' AND fraud_score < 0.3) OR
(risk_level = 'MEDIUM' AND fraud_score >= 0.3 AND fraud_score < 0.7) OR
(risk_level = 'HIGH' AND fraud_score >= 0.7 AND fraud_score < 0.9) OR
(risk_level = 'CRITICAL' AND fraud_score >= 0.9)
impact: critical
- name: reasons_not_empty_if_high_risk
rule: "fraud_score < 0.7 OR array_length(reasons) > 0"
impact: high
Access Policy (Sensitive Data):
default: deny
approval_required: true
approval_sla_hours: 2
columns:
- name: fraud_score
classification: restricted
masked_for_roles: [everyone_except_risk_analysts]
mask_strategy: full_hash
- name: reasons
classification: restricted
masked_for_roles: [everyone_except_risk_analysts]
mask_strategy: full_hash
- name: risk_level
classification: sensitive
masked_for_roles: [external_analyst]
mask_strategy: show_high_level_only # CRITICAL vs non-CRITICAL
- name: transaction_id
classification: pii
masked_for_roles: [contractor]
mask_strategy: partial_hash
Downstream Consumers:
graph TD
A["fraud-detection-service Real-time, hot path"]
A --> A1["Consumes: Kafka topic risk-scores-raw"]
A --> A2["Purpose: Block or approve transactions in real-time"]
A --> A3["SLA: < 500ms decision time"]
B["transaction-settlement Batch"]
B --> B1["Consumes: Iceberg table risk_compliance.fraud_scores"]
B --> B2["Purpose: Link fraud decisions to settlement status"]
B --> B3["Frequency: Daily"]
C["compliance-reporting Quarterly"]
C --> C1["Consumes: Iceberg table + 10-year time-travel"]
C --> C2["Purpose: AML/SAR filing for regulators"]
C --> C3["Frequency: Quarterly + event-driven"]
kyc-verdicts¶
Know Your Customer (KYC) verification status and results.
Schema (Avro v1.0):
{
"type": "record",
"name": "KYCVerdict",
"fields": [
{"name": "account_id", "type": "string"},
{"name": "customer_id", "type": "string"},
{"name": "kyc_status", "type": {"type": "enum", "symbols": ["APPROVED", "PENDING", "REJECTED", "EXPIRED"]}},
{"name": "kyc_type", "type": {"type": "enum", "symbols": ["INITIAL", "ENHANCED", "REFRESH"]}},
{"name": "risk_profile", "type": {"type": "enum", "symbols": ["LOW", "MEDIUM", "HIGH"]}},
{"name": "approved_at", "type": ["null", "long"]},
{"name": "expires_at", "type": ["null", "long"]}
]
}
Quality Rules:
- name: approved_date_before_expiry
rule: "approved_at IS NULL OR expires_at IS NULL OR approved_at < expires_at"
impact: critical
- name: approved_status_has_date
rule: "kyc_status != 'APPROVED' OR approved_at IS NOT NULL"
impact: critical
Retention: 10 years (regulatory requirement)
Ingest Pipeline¶
Fraud Scores Job¶
# From domains/risk_compliance/ingest/ingest_job.py
class RiskComplianceIngestJob:
DOMAIN = "risk_compliance"
FRAUD_TOPIC = "risk-scores-raw"
FRAUD_TABLE = f"{DOMAIN}.fraud_scores"
KYC_TOPIC = "kyc-verdicts-raw"
KYC_TABLE = f"{DOMAIN}.kyc_verdicts"
def run_fraud_scores(self):
df = self.spark.readStream.format("kafka") \
.option("kafka.bootstrap.servers", self.kafka_brokers) \
.option("subscribe", self.FRAUD_TOPIC) \
.load()
schema_str = '''{
"transaction_id": "string",
"account_id": "string",
"fraud_score": "double",
"risk_level": "string",
"reasons": "array<string>",
"evaluated_at": "long"
}'''
parsed_df = df.select(
from_json(col("value").cast("string"), schema_of_json(schema_str))
.alias("data")
).select("data.*")
# Use MERGE for high-accuracy requirement
# (fraud scores may need correction if evidence emerges)
query = parsed_df.writeStream \
.format("iceberg") \
.mode("append") \
.option("checkpointLocation", f"/tmp/checkpoint/{self.DOMAIN}/fraud") \
.toTable(self.FRAUD_TABLE)
query.awaitTermination()
Freshness Monitoring¶
fraud_scores:
freshness_sla: 10 minutes
target_lag: "max 10 minutes from transaction evaluation"
# Alert if freshness > 15 min (50% over SLA)
alert_threshold: 15 minutes
# Metrics
- kafka_lag_seconds (should be < 60 sec)
- spark_batch_duration (should be < 300 sec)
- iceberg_snapshot_age (should be < 600 sec)
Governance & Compliance¶
Retention Policy (Critical)¶
Fraud Scores: 10 years
├── Rationale: AML/Anti-Money Laundering compliance (FATF requirement)
├── SARs filed (Suspicious Activity Reports): Archive 10 years
└── Risk: Non-compliance = civil penalties ($100K-$1M+)
KYC Verdicts: 10 years
├── Rationale: KYC record-keeping (FinCEN requirement)
├── Enhanced KYC: Retain especially carefully
└── Risk: Non-compliance = license revocation
Automatic deletion: None (10-year retention is enforced)
Approval Workflows¶
User: "I want to query fraud_scores"
(Design intent. Implemented today: the OPA allow and approval decision and the pending or approved status. Not implemented: owner review, the timeout, and any unmasking after approval.)
OPA Policy Evaluation:
├── User role: analyst
├── Data classification: sensitive (fraud_score in risk-scores.yaml)
├── Auto-approve? YES (analyst may read sensitive data)
├── Route to: Risk Compliance team (owner)
├── SLA: 2 hours
Decision:
├── Owner reviews: Purpose? Risk level?
├── Accept: Grant access with masking
├── Reject: Deny request + log reason
└── Timeout (> 2 hours): Auto-grant with max masking
Logging:
├── Request ID, timestamp, user, decision
├── Audit trail: Retained 10 years
Masking Strategy¶
-- Analyst queries fraud_scores (OPA masks automatically)
SELECT fraud_score, risk_level FROM risk_compliance.fraud_scores
WHERE evaluated_at > now() - INTERVAL 7 days;
-- Fraud analyst: fraud_score is full_hash-masked by policy. No approval-based
-- unmasking is implemented yet, so the policy gives no unmasked view for this role.
-- External analyst sees (with masking)
fraud_score | risk_level
─────────────────────────
REDACTED | CRITICAL
REDACTED | MEDIUM*
REDACTED | LOW*
(*) "MEDIUM" and "LOW" shown as "NON_CRITICAL" (coarse-grained)
Time-Travel for AML Audits¶
Scenario: SAR (Suspicious Activity Report) Investigation¶
-- 2026-05-15: Account flagged during compliance audit
-- Need to reconstruct state on 2026-04-20
SELECT fraud_score, risk_level, reasons, evaluated_at
FROM risk_compliance.fraud_scores
FOR SYSTEM_TIME AS OF '2026-04-20 23:59:59'
WHERE account_id = 'flagged_account_123'
ORDER BY evaluated_at;
-- Historical snapshot shows whether:
-- 1. Account was already flagged on 2026-04-20 (pre-existing)
-- 2. Flag appeared later (new activity)
-- 3. Risk level escalated over time
-- Report findings to FinCEN with audit trail
Querying Examples¶
Risk Dashboard¶
-- Daily risk summary
SELECT
CURRENT_DATE as report_date,
COUNT(*) as total_transactions,
SUM(CASE WHEN risk_level = 'CRITICAL' THEN 1 ELSE 0 END) as critical_count,
SUM(CASE WHEN risk_level = 'HIGH' THEN 1 ELSE 0 END) as high_count,
ROUND(100.0 * SUM(CASE WHEN risk_level IN ('CRITICAL', 'HIGH') THEN 1 ELSE 0 END) / COUNT(*), 2) as pct_high_risk
FROM risk_compliance.fraud_scores
WHERE evaluated_at >= UNIX_TIMESTAMP(CURRENT_DATE);
KYC Expiry Check¶
-- Customers whose KYC expires in next 30 days
SELECT
account_id,
customer_id,
kyc_status,
FROM_UNIXTIME(expires_at) as expiry_date,
DATEDIFF(FROM_UNIXTIME(expires_at), CURRENT_DATE) as days_until_expiry
FROM risk_compliance.kyc_verdicts
WHERE kyc_status = 'APPROVED'
AND expires_at < UNIX_TIMESTAMP(CURRENT_DATE + INTERVAL 30 days)
ORDER BY expires_at ASC;
Scaling & Performance¶
Kafka topics:
├── risk-scores-raw: 5 partitions (medium volume)
├── kyc-verdicts-raw: 2 partitions (low volume)
└── Replication: 3
Spark jobs:
├── fraud_scores executor: 2 (mid-volume)
├── kyc_verdicts executor: 1 (low volume)
└── Batch window: 10 minutes
Iceberg optimization:
├── fraud_scores: Partition by [year, month, day]
├── kyc_verdicts: Partition by [year, month]
└── Compaction: Monthly (keep file count < 100)
Compliance Checklist¶
- ✅ 10-year retention enforced (not optional)
- ✅ Approval workflows for sensitive data access
- ✅ Audit trails preserved (all queries logged)
- ✅ Time-travel enabled (for SAR investigations)
- ✅ Masking rules applied (PII/restricted data)
- ✅ Snapshot history preserved (no deletion)
- ✅ Monitoring & alerting on freshness SLAs
Next¶
- Counterparties Domain — Master data for merchants, banks
- Market Data Domain — Real-time pricing feeds
- Governance Guide — Deeper OPA policy examples