Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/risk-engine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Explainable risk engine

Risk evaluation is deterministic and versioned in `lib/risk/engine.ts`. Each
signal stores the exact rule version, plain-language explanation, event time,
evaluation time and evidence references. The deduplication key is stable across
live evaluation and replay.

Rules may only use documented platform behaviour. Protected attributes must
never be added to event attributes or rule configuration. Suppressions require
a reason and expiry. Persisted signals are immutable; a changed rule must use a
new version.

Historical replay must provide an inclusive time window and a maximum of 10,000
events per run. Store the last event ID externally to resume subsequent pages,
and persist signals with a unique index on `dedupeKey`.

Case queues should group open signals by subject and category, assign the
strictest severity, and use `calculateReviewDeadline` for the review SLA.
Normal queue responses should return evidence references, not underlying KYC or
payment documents. Decisions, assignments, notes and suppression changes must
also be written to the existing audit log.
33 changes: 33 additions & 0 deletions lib/risk/engine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { strict as assert } from "node:assert"
import { calculateReviewDeadline, evaluateRiskEvent, replayRiskEvents, type RiskEvent } from "./engine"

const event: RiskEvent = {
id: "evt-1",
type: "payment.failed",
subjectId: "user-1",
occurredAt: new Date("2026-07-20T10:00:00Z"),
attributes: { failedAttempts: 3 },
}

const signals = evaluateRiskEvent(event, undefined, [], new Date("2026-07-20T10:01:00Z"))
assert.equal(signals.length, 1)
assert.equal(signals[0].ruleCode, "PAYMENT_FAILURE_BURST")
assert.match(signals[0].explanation, /threshold/)
assert.equal(evaluateRiskEvent({ ...event, attributes: { failedAttempts: 2 } }).length, 0)
assert.equal(
evaluateRiskEvent(event, undefined, [{ ruleCode: signals[0].ruleCode, reason: "Known test", expiresAt: new Date("2099-01-01") }]).length,
0
)
assert.equal(calculateReviewDeadline("critical", event.occurredAt).toISOString(), "2026-07-20T11:00:00.000Z")

async function* history() {
yield event
yield event
}
const keys = new Set<string>()
const replay = await replayRiskEvents(history(), async (signal) => {

Check failure on line 28 in lib/risk/engine.test.ts

View workflow job for this annotation

GitHub Actions / Pull request checks

Top-level 'await' expressions are only allowed when the 'module' option is set to 'es2022', 'esnext', 'system', 'node16', 'node18', 'node20', 'nodenext', or 'preserve', and the 'target' option is set to 'es2017' or higher.
if (keys.has(signal.dedupeKey)) return "duplicate"
keys.add(signal.dedupeKey)
return "created"
}, { from: new Date("2026-07-20"), to: new Date("2026-07-21"), limit: 10 })
assert.deepEqual(replay, { scanned: 2, created: 1 })
147 changes: 147 additions & 0 deletions lib/risk/engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
export type RiskSeverity = "low" | "medium" | "high" | "critical"
export type RiskEvent = {
id: string
type: string
subjectId: string
occurredAt: Date
attributes: Record<string, unknown>
}
export type RiskRule = {
code: string
version: number
category: string
severity: RiskSeverity
enabled: boolean
eventTypes: string[]
effectiveFrom: Date
effectiveUntil?: Date
cooldownMs?: number
evaluate: (event: RiskEvent) => { matched: boolean; explanation: string; evidence: string[] }
}
export type RiskSignal = {
dedupeKey: string
eventId: string
subjectId: string
ruleCode: string
ruleVersion: number
category: string
severity: RiskSeverity
explanation: string
evidence: string[]
eventTime: Date
evaluatedAt: Date
}

export type Suppression = {
ruleCode: string
subjectId?: string
reason: string
expiresAt: Date
}

export const INITIAL_RISK_RULES: RiskRule[] = [
countRule("PAYMENT_FAILURE_BURST", "payments", "high", "payment.failed", "failedAttempts", 3),
amountRule("UNUSUAL_WALLET_FUNDING", "wallet", "high", "wallet.funded", "amount", 1_000_000),
booleanRule("AMOUNT_IDENTITY_MISMATCH", "identity", "critical", "payment.requested", "identityMismatch"),
booleanRule("DUPLICATE_REFERENCE", "payments", "high", "payment.requested", "duplicateReference"),
countRule("RAPID_INVESTMENT_ATTEMPTS", "investment", "high", "investment.attempted", "attemptsInTenMinutes", 5),
amountRule("REPAYMENT_DETERIORATION", "repayment", "medium", "repayment.updated", "missedPayments", 2),
amountRule("STALE_KYC", "kyc", "medium", "kyc.checked", "ageDays", 365),
booleanRule("CONFLICTING_CONTRACT_STATE", "contract", "critical", "contract.updated", "stateConflict"),
countRule("ACCOUNT_LINK_CHURN", "account", "medium", "account.linked", "changesInDay", 3),
]

function booleanRule(code: string, category: string, severity: RiskSeverity, type: string, field: string): RiskRule {
return rule(code, category, severity, type, (event) => ({
matched: event.attributes[field] === true,
explanation: `${field} was reported by the ${type} workflow`,
evidence: [`event:${event.id}`, `attribute:${field}`],
}))
}

function amountRule(code: string, category: string, severity: RiskSeverity, type: string, field: string, threshold: number): RiskRule {
return rule(code, category, severity, type, (event) => {
const actual = Number(event.attributes[field] ?? 0)
return {
matched: Number.isFinite(actual) && actual >= threshold,
explanation: `${field} ${actual} met the documented threshold ${threshold}`,
evidence: [`event:${event.id}`, `${field}:${actual}`],
}
})
}

function countRule(...args: Parameters<typeof amountRule>): RiskRule {
return amountRule(...args)
}

function rule(
code: string,
category: string,
severity: RiskSeverity,
eventType: string,
evaluate: RiskRule["evaluate"]
): RiskRule {
return {
code, version: 1, category, severity, enabled: true,
eventTypes: [eventType], effectiveFrom: new Date("2026-01-01T00:00:00Z"),
cooldownMs: 60 * 60 * 1000, evaluate,
}
}

export function evaluateRiskEvent(
event: RiskEvent,
rules: RiskRule[] = INITIAL_RISK_RULES,
suppressions: Suppression[] = [],
now = new Date()
): RiskSignal[] {
return rules.flatMap((currentRule) => {
if (!currentRule.enabled || !currentRule.eventTypes.includes(event.type)) return []
if (currentRule.effectiveFrom > event.occurredAt || (currentRule.effectiveUntil && currentRule.effectiveUntil <= event.occurredAt)) return []
const suppressed = suppressions.some((item) =>
item.ruleCode === currentRule.code &&
(!item.subjectId || item.subjectId === event.subjectId) &&
item.expiresAt > now &&
item.reason.trim().length > 0
)
if (suppressed) return []
const result = currentRule.evaluate(event)
if (!result.matched) return []
return [{
dedupeKey: `${event.id}:${currentRule.code}:v${currentRule.version}`,
eventId: event.id,
subjectId: event.subjectId,
ruleCode: currentRule.code,
ruleVersion: currentRule.version,
category: currentRule.category,
severity: currentRule.severity,
explanation: result.explanation,
evidence: result.evidence,
eventTime: event.occurredAt,
evaluatedAt: now,
}]
})
}

export function calculateReviewDeadline(severity: RiskSeverity, openedAt: Date): Date {
const hours = { critical: 1, high: 4, medium: 24, low: 72 }[severity]
return new Date(openedAt.getTime() + hours * 60 * 60 * 1000)
}

export async function replayRiskEvents(
events: AsyncIterable<RiskEvent>,
persist: (signal: RiskSignal) => Promise<"created" | "duplicate">,
options: { from: Date; to: Date; limit: number; rules?: RiskRule[] }
) {
if (options.limit < 1 || options.limit > 10_000) throw new Error("replay limit must be between 1 and 10000")
let scanned = 0
let created = 0
for await (const event of events) {
if (scanned >= options.limit) break
if (event.occurredAt < options.from || event.occurredAt > options.to) continue
scanned += 1
for (const signal of evaluateRiskEvent(event, options.rules)) {
if (await persist(signal) === "created") created += 1
}
}
return { scanned, created }
}
Loading