Financial Services
ADL v0.3 for Financial Services
BSA/AML SAR workflows, trade-suitability and execution audit trails, and ECOA-compliant lending ADL domains — implementing the cited regulations, built to the ADL v0.3 specification.
Financial services is where the regulator writes much of your data model for you: BSA/AML suspicious-activity timelines, FINRA suitability, SEC execution audit trails, ECOA adverse-action notices, and stress-testing governance all impose specific, enforceable structure. This paper works four such domains in the Application Definition Language (ADL) as complete, runnable definitions reconciled against the cited regulations: trade lifecycle, AML case management, loan underwriting, and enterprise stress testing.
The recurring theme is that compliance controls are separation-of-duties rules, computed deadlines, and immutable audit events: declarative properties ADL expresses directly and the Kalos runtime enforces, rather than logic each application re-implements by hand.
Domain 1: Trade Lifecycle (trading)
domain:
name: trading
version: "1.0.0"
description: >
Order lifecycle with per-execution records (SEC Rule 613 consolidated audit
trail), structural suitability enforcement, and best-execution monitoring.
Fills arrive as Execution records via integration; order fill state is
aggregated, never overwritten.
channels:
email:
provider: smtp
host: ${SMTP_HOST}
port: ${SMTP_PORT:587}
from: "trading-compliance@example.com"
objects:
Timestamp:
createdAt: { type: datetime, autoNow: create }
updatedAt: { type: datetime, autoNow: update }
enums:
OrderStatus:
values:
pending: { label: "Pending", color: "#94a3b8" }
validated: { label: "Validated", color: "#3b82f6" }
approved: { label: "Approved", color: "#8b5cf6" }
routed: { label: "Routed", color: "#f59e0b" }
partially_filled: { label: "Partially Filled", color: "#f97316" }
filled: { label: "Filled", color: "#10b981" }
settled: { label: "Settled", color: "#059669", final: true }
cancelled: { label: "Cancelled", color: "#64748b", final: true }
rejected: { label: "Rejected", color: "#ef4444", final: true }
OrderSide:
values: [buy, sell]
OrderType:
values: [market, limit, stop, stop_limit]
AssetClass:
values:
equity: { label: "Equity" }
fixed_income: { label: "Fixed Income" }
derivative: { label: "Derivative" }
fx: { label: "Foreign Exchange" }
commodity: { label: "Commodity" }
resources:
Order:
object:
id: { type: uuid, primaryKey: true }
orderId: { type: string, required: true, unique: true, immutable: true, maxLength: 40 }
clientId: { type: uuid, required: true, immutable: true }
clientName: { type: string, required: true, maxLength: 120 }
accountId: { type: string, required: true, maxLength: 40 }
side: { $ref: '#/enums/OrderSide', required: true }
assetClass: { $ref: '#/enums/AssetClass', required: true }
instrument: { type: string, required: true, maxLength: 40 }
isin: { type: string, maxLength: 12 }
quantity: { type: float, required: true, min: 0.000001 }
limitPrice: { type: money }
orderType: { $ref: '#/enums/OrderType', required: true }
status: { $ref: '#/enums/OrderStatus', default: pending }
venue: { type: string, maxLength: 40 }
commission: { type: money }
fees: { type: money }
# traderId matches the owner-resolution convention via createdById below;
# kept separate so the SoD guard reads unambiguously.
traderId: { type: string, required: true, immutable: true, maxLength: 100 }
traderName: { type: string, required: true, maxLength: 120 }
createdById: { type: string, required: true, immutable: true, maxLength: 100 }
complianceOfficerId: { type: string, maxLength: 100 }
suitabilityConfirmed: { type: boolean, default: false }
bestExecutionScore: { type: float, min: 0, max: 1 }
settlementDate: { type: date }
settledAt: { type: datetime }
counterpartyId: { type: string, maxLength: 40 }
counterpartyName: { type: string, maxLength: 120 }
regulatoryReportId: { type: string, maxLength: 60 }
rejectionReason: { type: text }
notes: { type: text }
$merge: { $ref: '#/objects/Timestamp' }
relationships:
executions: { type: hasMany, target: Execution, foreignKey: orderId }
indexes:
- { fields: [status] }
- { fields: [clientId] }
- { fields: [traderId] }
- { fields: [instrument] }
- { fields: [settlementDate] }
computed:
# Aggregated from Execution records — never overwritten by a fill.
filledQuantity:
type: float
formula: "sum(executions.quantity)"
stored: true
updateOn: [executions.create, executions.update, executions.delete]
executedNotional:
type: money
formula: "sum(executions.notional)"
stored: true
updateOn: [executions.create, executions.update, executions.delete]
remainingQty:
type: float
formula: "quantity - filledQuantity"
stored: true
updateOn: [filledQuantity]
avgExecutionPrice:
type: money
formula: "if(filledQuantity > 0, executedNotional / filledQuantity, null)"
stored: true
updateOn: [executedNotional, filledQuantity]
totalCost:
type: money
formula: "executedNotional + if(commission == null, 0, commission) + if(fees == null, 0, fees)"
stored: true
updateOn: [executedNotional, commission, fees]
stateMachine:
field: status
initial: pending
states:
pending:
transitions:
validate:
to: validated
guards: [owner, trader, admin]
cancel:
to: cancelled
guards: [owner, trader, admin]
reject:
to: rejected
guards: [complianceOfficer, admin]
requiredFields: [rejectionReason]
validated:
transitions:
approve:
to: approved
# SoD: the approver cannot be the ordering trader. FINRA 2111
# suitability approval is not self-service. No admin bypass.
guard: >
(hasRole('complianceOfficer') || hasRole('seniorTrader'))
&& field('traderId') != userId()
sets:
complianceOfficerId: "$user.id"
reject:
to: rejected
guards: [complianceOfficer, admin]
requiredFields: [rejectionReason]
approved:
transitions:
route:
to: routed
guards: [trader, executionDesk, admin]
requiredFields: [venue]
cancel:
to: cancelled
guards: [trader, complianceOfficer, admin]
routed:
transitions:
markPartiallyFilled:
to: partially_filled
guards: [executionDesk, admin] # normally driven by the execution feed
markFilled:
to: filled
guards: [executionDesk, admin]
cancel:
to: cancelled
guards: [trader, complianceOfficer, admin]
partially_filled:
transitions:
markFilled:
to: filled
guards: [executionDesk, admin]
cancel:
to: cancelled
guards: [trader, complianceOfficer, admin]
filled:
transitions:
settle:
to: settled
guards: [operations, admin]
requiredFields: [settlementDate, counterpartyId]
sets:
settledAt: "now()"
settled:
final: true
cancelled:
final: true
rejected:
final: true
validation:
# The value must be TRUE, not merely non-null — requiredFields on a
# boolean is satisfied by `false`.
suitabilityMustBeConfirmed:
rule: "status == 'pending' || status == 'validated' || status == 'cancelled' || status == 'rejected' || suitabilityConfirmed == true"
message: "An order cannot be approved or executed without confirmed suitability (FINRA 2111)"
noOverfill:
rule: "filledQuantity == null || filledQuantity <= quantity"
message: "Filled quantity cannot exceed order quantity"
limitOrdersNeedPrice:
rule: "orderType != 'limit' || limitPrice != null"
message: "Limit orders require a limit price"
features:
audit: true
search: true
audit:
exclude: [updatedAt]
# 17a-4 posture: settled/cancelled/rejected orders are locked for
# everyone. Amendments and trade breaks are NEW records referencing the
# original — never edits to the settled record.
permissions:
list: [authenticated]
get: [authenticated]
create: [trader, seniorTrader, admin]
update: [trader, seniorTrader, executionDesk, complianceOfficer, operations, admin]
delete: [admin]
conditional:
update:
settled: []
cancelled: []
rejected: []
fields:
suitabilityConfirmed: { write: [complianceOfficer, admin] }
bestExecutionScore: { write: [complianceOfficer, admin] }
regulatoryReportId: { write: [operations, complianceOfficer, admin] }
complianceOfficerId: { write: [admin] } # normally set only via the approve transition
history:
events:
orderPlaced:
on: create
capture: [orderId, clientName, side, instrument, quantity, limitPrice, orderType, traderName]
label: "{side} {quantity} {instrument} — Order {orderId}"
detail: "Client: {clientName}. Type: {orderType}. Trader: {traderName}"
icon: lucide:file-plus
severity: info
validated:
on: transition
transition: validate
label: "Order validated"
icon: lucide:check
severity: info
complianceApproved:
on: transition
transition: approve
capture: [suitabilityConfirmed, complianceOfficerId]
label: "Compliance approved — suitability confirmed"
icon: lucide:shield-check
severity: success
complianceRejected:
on: transition
transition: reject
capture: [rejectionReason]
label: "Compliance REJECTED"
detail: "{rejectionReason}"
icon: lucide:shield-x
severity: error
routedToVenue:
on: transition
transition: route
capture: [venue]
label: "Routed to {venue}"
icon: lucide:send
severity: info
settled:
on: transition
transition: settle
capture: [settlementDate, counterpartyName, regulatoryReportId]
label: "Settled — {counterpartyName}"
detail: "Settlement date: {settlementDate}. Regulatory report: {regulatoryReportId}"
icon: lucide:landmark
severity: success
cancelled:
on: transition
transition: cancel
label: "Order cancelled"
icon: lucide:x-circle
severity: warning
bestExecutionReview:
# Legitimate fieldChange: the score is written by compliance/TCA.
on: fieldChange
field: bestExecutionScore
condition: "bestExecutionScore < 0.7"
capture: [bestExecutionScore, venue, avgExecutionPrice]
label: "BEST EXECUTION CONCERN — score {bestExecutionScore}"
detail: "Venue: {venue}, Avg price: {avgExecutionPrice}. Review required per MiFID II Article 27."
icon: lucide:alert-triangle
severity: error
roles: [complianceOfficer, seniorTrader, admin]
largeLimitOrder:
# Limit orders are checked at entry; a null limitPrice (market
# order) is not flagged by this rule.
on: create
condition: "limitPrice != null && quantity * limitPrice > 1000000"
capture: [instrument, quantity, limitPrice, clientName]
label: "LARGE ORDER — {instrument} {quantity} @ {limitPrice}"
detail: "Client: {clientName}. Manual compliance review may be required."
icon: lucide:alert-octagon
severity: warning
roles: [complianceOfficer, seniorTrader, admin]
largeExecution:
# ...and ALL orders (market included) check on executed notional.
on: fieldChange
field: executedNotional
condition: "executedNotional > 1000000"
capture: [instrument, executedNotional, clientName]
label: "LARGE EXECUTION — {instrument} notional {executedNotional}"
icon: lucide:alert-octagon
severity: warning
roles: [complianceOfficer, seniorTrader, admin]
Execution:
description: >
A single fill against an order — the per-execution record SEC Rule 613
actually requires. Fills never overwrite order fields; order fill state
is aggregated from these records.
object:
id: { type: uuid, primaryKey: true }
executionId: { type: string, required: true, unique: true, immutable: true, maxLength: 60 }
orderId: { type: uuid, required: true, immutable: true }
quantity: { type: float, required: true, min: 0.000001 }
price: { type: money, required: true, min: 0 }
venue: { type: string, required: true, maxLength: 40 }
executedAt: { type: datetime, required: true }
contraBroker: { type: string, maxLength: 60 }
$merge: { $ref: '#/objects/Timestamp' }
relationships:
order: { type: belongsTo, target: Order, foreignKey: orderId, onDelete: restrict }
indexes:
- { fields: [orderId] }
- { fields: [executedAt] }
- { fields: [venue] }
computed:
notional:
type: money
formula: "quantity * price"
stored: true
updateOn: [quantity, price]
features:
audit: true
history:
events:
fillReceived:
on: create
capture: [executionId, quantity, price, venue, executedAt]
label: "Fill — {quantity} @ {price} on {venue}"
icon: lucide:bar-chart-2
severity: info
permissions:
list: [authenticated]
get: [authenticated]
create: [executionDesk, admin] # and the execution-feed integration
update: [admin] # fills are corrected by cancel/rebook, not edit
delete: [admin]
# Fills arrive from the venue/OMS feed — not from a "system" role in guards.
integrations:
executionFeed:
type: webhook
path: /webhooks/execution-reports
auth: { type: hmac_signature, secret: "${EXEC_FEED_SECRET}", header: X-Feed-Signature, algorithm: sha256 }
events:
executionReport:
filter: { field: msg_type, value: fill }
mapping:
executionId: $payload.exec_id
orderId: $payload.client_order_id
quantity: $payload.last_qty
price: $payload.last_px
venue: $payload.venue
executedAt: "$parseDate($payload.transact_time, 'yyyy-MM-dd HH:mm:ss')"
action: create
resource: Execution
input: $mapped
history: { event: fillReceived, label: "Fill received from venue feed" }
Domain 2: AML Case Management (aml)
domain:
name: aml
version: "1.0.0"
description: >
BSA/AML case lifecycle. The SAR filing deadline is COMPUTED from an
immutable detection date (31 CFR 1020.320: 30 calendar days) and enforced
by a tiered schedule — not a fieldChange event that never fires. SAR
existence is confidential: SAR events and fields are visible only to the
BSA officer (tipping-off, 1020.320(e)).
channels:
email:
provider: smtp
host: ${SMTP_HOST}
port: ${SMTP_PORT:587}
from: "bsa-noreply@example.com"
objects:
Timestamp:
createdAt: { type: datetime, autoNow: create }
updatedAt: { type: datetime, autoNow: update }
enums:
AlertSeverity:
values:
low: { label: "Low", color: "#94a3b8" }
medium: { label: "Medium", color: "#f59e0b" }
high: { label: "High", color: "#f97316" }
critical: { label: "Critical", color: "#ef4444" }
CaseStatus:
values:
new: { label: "New Alert", color: "#3b82f6" }
triaged: { label: "Triaged", color: "#8b5cf6" }
under_investigation: { label: "Under Investigation", color: "#f59e0b" }
escalated: { label: "Escalated to BSA Officer", color: "#f97316" }
sar_pending: { label: "SAR Pending", color: "#ef4444" }
sar_filed: { label: "SAR Filed", color: "#10b981" }
closed_no_action: { label: "Closed — No Action", color: "#64748b", final: true }
closed_sar: { label: "Closed — SAR Filed", color: "#059669", final: true }
resources:
Case:
object:
id: { type: uuid, primaryKey: true }
caseNumber: { type: string, required: true, unique: true, immutable: true, maxLength: 40 }
alertId: { type: string, required: true, immutable: true, maxLength: 60 }
alertSource: { type: string, required: true, maxLength: 80 }
alertSeverity: { $ref: '#/enums/AlertSeverity' }
status: { $ref: '#/enums/CaseStatus', default: new }
# The clock starts here. Immutable — the deadline derives from it.
detectionDate: { type: date, required: true, immutable: true }
customerId: { type: string, required: true, maxLength: 60 }
customerName: { type: string, required: true, maxLength: 120 }
customerRiskRating: { type: string, maxLength: 20 }
transactionAmount: { type: money }
transactionDate: { type: datetime }
transactionType: { type: string, maxLength: 60 }
suspiciousIndicators: { type: json }
assignedAnalystId: { type: string, maxLength: 100 }
assignedAnalystName: { type: string, maxLength: 120 }
bsaOfficerId: { type: string, maxLength: 100 }
bsaOfficerName: { type: string, maxLength: 120 }
investigationNotes: { type: text }
sarFilingDate: { type: date }
sarConfirmationNumber: { type: string, maxLength: 40 }
disposition: { type: text }
riskAssessment: { type: text }
closedAt: { type: datetime }
$merge: { $ref: '#/objects/Timestamp' }
indexes:
- { fields: [status] }
- { fields: [customerId] }
- { fields: [alertSeverity] }
- { fields: [assignedAnalystId] }
computed:
# 31 CFR 1020.320: 30 calendar days from initial detection. (The 60-day
# no-subject extension is a BSA-officer determination — model it as an
# explicit extension record if needed, not a default.)
sarFilingDeadline:
type: date
formula: "dateAdd(detectionDate, 30, 'days')"
stored: true
updateOn: [detectionDate]
filedLate:
type: boolean
formula: "sarFilingDate != null && sarFilingDate > sarFilingDeadline"
stored: true
updateOn: [sarFilingDate, sarFilingDeadline]
stateMachine:
field: status
initial: new
states:
new:
transitions:
triage:
to: triaged
guards: [analyst, seniorAnalyst, admin]
requiredFields: [assignedAnalystId]
triaged:
transitions:
investigate:
to: under_investigation
guards: [owner, seniorAnalyst, admin]
closeNoAction:
to: closed_no_action
# SoD, and now consistent with the prose claim: only a senior
# analyst who is NOT the assigned investigator may no-action a
# case. The BSA officer's role is SAR determinations, not
# pre-escalation closures.
guard: "hasRole('seniorAnalyst') && field('assignedAnalystId') != userId()"
requiredFields: [disposition]
under_investigation:
transitions:
escalate:
to: escalated
guards: [owner, seniorAnalyst, admin]
requiredFields: [riskAssessment]
closeNoAction:
to: closed_no_action
guard: "hasRole('seniorAnalyst') && field('assignedAnalystId') != userId()"
requiredFields: [disposition]
escalated:
transitions:
prepareSar:
to: sar_pending
guards: [bsaOfficer, admin]
sets:
bsaOfficerId: "$user.id"
bsaOfficerName: "$user.username"
closeNoAction:
to: closed_no_action
guards: [bsaOfficer, admin]
requiredFields: [disposition]
sar_pending:
transitions:
fileSar:
to: sar_filed
guards: [bsaOfficer, admin]
requiredFields: [sarFilingDate, sarConfirmationNumber]
sar_filed:
transitions:
closeSar:
to: closed_sar
guards: [bsaOfficer, admin]
sets:
closedAt: "now()"
closed_no_action:
final: true
closed_sar:
final: true
features:
audit: true
search: true
history:
events:
alertReceived:
on: create
capture: [caseNumber, alertSource, alertSeverity, customerName, transactionAmount, transactionType, detectionDate]
label: "Alert — {alertSeverity} from {alertSource}"
detail: "Customer: {customerName}. Transaction: {transactionType} {transactionAmount}. Detected: {detectionDate}"
icon: lucide:bell
severity: warning
triaged:
on: transition
transition: triage
capture: [assignedAnalystName, alertSeverity]
label: "Triaged — assigned to {assignedAnalystName}"
icon: lucide:user-plus
severity: info
investigationStarted:
on: transition
transition: investigate
label: "Investigation started"
icon: lucide:search
severity: info
escalatedToBsa:
on: transition
transition: escalate
capture: [riskAssessment]
label: "Escalated to BSA Officer"
detail: "Risk assessment: {riskAssessment}"
icon: lucide:arrow-up-circle
severity: error
# SAR-related events are visible ONLY to the BSA officer — internal
# disclosure of a SAR's existence is itself a violation (1020.320(e)).
sarPrepared:
on: transition
transition: prepareSar
capture: [sarFilingDeadline]
label: "SAR preparation initiated"
detail: "Filing deadline: {sarFilingDeadline}"
icon: lucide:file-warning
severity: error
roles: [bsaOfficer, admin]
sarFiled:
on: transition
transition: fileSar
capture: [sarFilingDate, sarConfirmationNumber, filedLate]
label: "SAR filed — confirmation #{sarConfirmationNumber}"
detail: "Filed: {sarFilingDate}. Late: {filedLate}"
icon: lucide:file-check
severity: success
roles: [bsaOfficer, admin]
closedNoAction:
on: transition
transition: closeNoAction
capture: [disposition]
label: "Closed — no suspicious activity"
detail: "{disposition}"
icon: lucide:check-circle
severity: info
closedSar:
on: transition
transition: closeSar
label: "Case closed"
icon: lucide:archive
severity: success
roles: [bsaOfficer, admin]
severityEscalated:
on: fieldChange
field: alertSeverity
condition: "alertSeverity == 'critical'"
capture: [alertSeverity, customerName, caseNumber]
label: "Alert severity escalated to CRITICAL — {caseNumber}"
icon: lucide:alert-octagon
severity: error
investigationNoteAdded:
on: fieldChange
field: investigationNotes
label: "Investigation note updated"
icon: lucide:edit
severity: info
roles: [analyst, seniorAnalyst, bsaOfficer, admin]
permissions:
list: [analyst, seniorAnalyst, bsaOfficer, admin]
get: [analyst, seniorAnalyst, bsaOfficer, admin]
create: [admin] # cases arrive via the monitoring integration
update: [analyst, seniorAnalyst, bsaOfficer, admin]
delete: [admin]
fields:
# SAR fields: read AND write restricted to the BSA function.
sarFilingDate: { read: [bsaOfficer, admin], write: [bsaOfficer, admin] }
sarConfirmationNumber: { read: [bsaOfficer, admin], write: [bsaOfficer, admin] }
bsaOfficerId: { write: [admin] } # set via the prepareSar transition
disposition: { write: [seniorAnalyst, bsaOfficer, admin] }
riskAssessment: { write: [seniorAnalyst, bsaOfficer, admin] }
alertSeverity: { write: [seniorAnalyst, bsaOfficer, admin] }
# THE FIX: deadline enforcement is time-driven and lives in a schedule.
schedules:
sarDeadline:
description: "Escalate open cases as the 30-day SAR clock runs down"
type: relative
source:
resource: Case
filter: "status != 'sar_filed' && status != 'closed_no_action' && status != 'closed_sar'"
relativeTo: sarFilingDeadline
tiers:
- daysUntil: 10
action: notify
tag: t10
channels: [email]
recipients: { roles: [seniorAnalyst, bsaOfficer] }
template:
subject: "SAR clock: 10 days — {caseNumber}"
body: "Customer: {customerName}. Deadline: {sarFilingDeadline}. Status: case still open."
- daysUntil: 5
action: notify
tag: t5
channels: [email]
recipients: { roles: [bsaOfficer] }
template:
subject: "SAR FILING DEADLINE IN 5 DAYS — {caseNumber}"
body: "Deadline: {sarFilingDeadline}. Case must be resolved or filed."
- daysUntil: 0
action: notify
tag: due
channels: [email]
recipients: { roles: [bsaOfficer, admin] }
template:
subject: "SAR DEADLINE TODAY — {caseNumber}"
body: "The 30-day window from detection ({detectionDate}) ends today."
# Alerts arrive from transaction monitoring — not from a "system" role.
integrations:
transactionMonitoring:
type: webhook
path: /webhooks/aml-alerts
auth: { type: hmac_signature, secret: "${AML_FEED_SECRET}", header: X-Alert-Signature, algorithm: sha256 }
events:
alertGenerated:
filter: { field: event_type, value: aml_alert }
mapping:
caseNumber: $payload.case_number
alertId: $payload.alert_id
alertSource: $payload.source
alertSeverity: $payload.severity
detectionDate: "$parseDate($payload.detected_at, 'yyyy-MM-dd')"
customerId: $payload.customer_id
customerName: $payload.customer_name
transactionAmount: $payload.amount
transactionType: $payload.txn_type
suspiciousIndicators: $payload.indicators
action: create
resource: Case
input: $mapped
history: { event: alertReceived, label: "Alert received from monitoring" }
Domain 3: Loan Underwriting (lending)
domain:
name: lending
version: "1.0.0"
description: >
Loan lifecycle with structural segregation of duties (the decision-maker is
not the originator; the funder is not the decision-maker; no admin bypass
on credit decisions) and Reg B adverse-action notice tracking with a
computed deadline and schedule.
channels:
email:
provider: smtp
host: ${SMTP_HOST}
port: ${SMTP_PORT:587}
from: "lending-compliance@example.com"
objects:
Timestamp:
createdAt: { type: datetime, autoNow: create }
updatedAt: { type: datetime, autoNow: update }
enums:
LoanStatus:
values:
application: { label: "Application", color: "#3b82f6" }
document_review: { label: "Document Review", color: "#8b5cf6" }
credit_analysis: { label: "Credit Analysis", color: "#f59e0b" }
underwriting: { label: "Underwriting", color: "#f97316" }
conditionally_approved: { label: "Conditionally Approved", color: "#84cc16" }
approved: { label: "Approved", color: "#10b981" }
funded: { label: "Funded", color: "#059669", final: true }
denied: { label: "Denied", color: "#ef4444", final: true }
withdrawn: { label: "Withdrawn", color: "#64748b", final: true }
LoanType:
values: [conventional, fha, va, usda, commercial, sba]
resources:
Application:
object:
id: { type: uuid, primaryKey: true }
applicationNumber: { type: string, required: true, unique: true, immutable: true, maxLength: 40 }
borrowerName: { type: string, required: true, maxLength: 120 }
borrowerId: { type: uuid, required: true, immutable: true }
loanType: { $ref: '#/enums/LoanType', required: true }
requestedAmount: { type: money, required: true, min: 0.01 }
approvedAmount: { type: money }
interestRate: { type: percentage }
termMonths: { type: integer, min: 1 }
status: { $ref: '#/enums/LoanStatus', default: application }
creditScore: { type: integer, min: 300, max: 850 }
dti: { type: percentage }
ltv: { type: percentage }
collateralValue: { type: money }
collateralType: { type: string, maxLength: 60 }
# createdById establishes the owner-resolution convention.
createdById: { type: string, required: true, immutable: true, maxLength: 100 }
loanOfficerName: { type: string, required: true, maxLength: 120 }
underwriterId: { type: string, maxLength: 100 }
underwriterName: { type: string, maxLength: 120 }
underwritingDecision: { type: text }
conditions: { type: json }
denialReason: { type: text }
denialCodes: { type: json }
deniedAt: { type: datetime }
adverseActionSentDate: { type: date }
hmdaReportable: { type: boolean, default: false }
fundedDate: { type: date }
fundedAmount: { type: money }
fundedAt: { type: datetime }
$merge: { $ref: '#/objects/Timestamp' }
indexes:
- { fields: [status] }
- { fields: [borrowerId] }
- { fields: [createdById] }
- { fields: [loanType] }
computed:
riskRating:
type: string
formula: >
if(creditScore >= 740 && dti <= 36 && ltv <= 80, 'low',
if(creditScore >= 680 && dti <= 43 && ltv <= 90, 'medium',
if(creditScore >= 620, 'high', 'very_high')))
stored: true
updateOn: [creditScore, dti, ltv]
# Reg B: adverse-action notice within 30 days of the adverse action.
adverseActionDeadline:
type: date
formula: "if(deniedAt != null, dateAdd(deniedAt, 30, 'days'), null)"
stored: true
updateOn: [deniedAt]
stateMachine:
field: status
initial: application
states:
application:
transitions:
submitForReview:
to: document_review
guards: [owner, loanOfficer, admin]
withdraw:
to: withdrawn
guards: [owner, loanOfficer, admin]
document_review:
transitions:
completeDocReview:
to: credit_analysis
guards: [loanProcessor, admin]
requestAdditionalDocs:
to: application
guards: [loanProcessor, admin]
credit_analysis:
transitions:
completeCreditAnalysis:
to: underwriting
guards: [creditAnalyst, admin]
requiredFields: [creditScore, dti, ltv]
underwriting:
transitions:
conditionallyApprove:
to: conditionally_approved
guard: >
(hasRole('underwriter') || hasRole('seniorUnderwriter'))
&& field('createdById') != userId()
requiredFields: [underwritingDecision, conditions]
sets:
underwriterId: "$user.id"
underwriterName: "$user.username"
approve:
to: approved
# SoD: the underwriter is not the originating loan officer.
# No admin bypass on credit decisions.
guard: >
(hasRole('underwriter') || hasRole('seniorUnderwriter'))
&& field('createdById') != userId()
requiredFields: [underwritingDecision, approvedAmount, interestRate, termMonths]
sets:
underwriterId: "$user.id"
underwriterName: "$user.username"
deny:
to: denied
guard: >
(hasRole('underwriter') || hasRole('seniorUnderwriter'))
&& field('createdById') != userId()
requiredFields: [denialReason, denialCodes]
sets:
underwriterId: "$user.id"
underwriterName: "$user.username"
deniedAt: "now()"
conditionally_approved:
transitions:
clearConditions:
to: approved
guards: [underwriter, seniorUnderwriter, admin]
requiredFields: [approvedAmount, interestRate, termMonths]
deny:
to: denied
guard: >
(hasRole('underwriter') || hasRole('seniorUnderwriter'))
&& field('createdById') != userId()
requiredFields: [denialReason, denialCodes]
sets:
deniedAt: "now()"
approved:
transitions:
fund:
to: funded
# SoD: the funder is neither the originator nor the underwriter.
guard: >
hasRole('closingManager')
&& field('createdById') != userId()
&& field('underwriterId') != userId()
requiredFields: [fundedAmount, fundedDate]
sets:
fundedAt: "now()"
funded:
final: true
denied:
final: true
withdrawn:
final: true
validation:
fundedWithinApproval:
rule: "fundedAmount == null || approvedAmount == null || fundedAmount <= approvedAmount"
message: "Funded amount cannot exceed the approved amount"
deniedNeedsCodes:
rule: "status != 'denied' || denialCodes != null"
message: "A denial must record adverse-action reason codes (Reg B)"
features:
audit: true
search: true
history:
events:
applicationReceived:
on: create
capture: [applicationNumber, borrowerName, requestedAmount, loanType, loanOfficerName]
label: "Application {applicationNumber} — {requestedAmount}"
detail: "Borrower: {borrowerName}. Type: {loanType}. Officer: {loanOfficerName}"
icon: lucide:file-plus
severity: info
documentsReviewed:
on: transition
transition: completeDocReview
label: "Document review complete"
icon: lucide:file-check
severity: info
creditAnalysisComplete:
on: transition
transition: completeCreditAnalysis
capture: [creditScore, dti, ltv, riskRating]
label: "Credit analysis complete — score {creditScore}, risk {riskRating}"
detail: "DTI: {dti}%, LTV: {ltv}%"
icon: lucide:bar-chart
severity: info
conditionallyApproved:
on: transition
transition: conditionallyApprove
capture: [underwriterName, underwritingDecision, conditions]
label: "Conditionally approved by {underwriterName}"
detail: "{underwritingDecision}"
icon: lucide:check
severity: info
approved:
on: transition
transition: approve
capture: [underwriterName, approvedAmount, interestRate, termMonths, underwritingDecision]
label: "APPROVED — {approvedAmount} at {interestRate}% for {termMonths} months"
detail: "Underwriter: {underwriterName}. Decision: {underwritingDecision}"
icon: lucide:check-circle
severity: success
denied:
on: transition
transition: deny
capture: [underwriterName, denialReason, denialCodes]
label: "DENIED by {underwriterName}"
detail: "{denialReason}"
icon: lucide:x-circle
severity: error
funded:
on: transition
transition: fund
capture: [fundedAmount, fundedDate]
label: "Funded — {fundedAmount} on {fundedDate}"
icon: lucide:banknote
severity: success
withdrawn:
on: transition
transition: withdraw
label: "Application withdrawn"
icon: lucide:log-out
severity: warning
riskEscalation:
on: fieldChange
field: riskRating
condition: "riskRating == 'very_high'"
capture: [creditScore, dti, ltv, riskRating]
label: "RISK ESCALATION — very high risk"
detail: "Score: {creditScore}, DTI: {dti}%, LTV: {ltv}%"
icon: lucide:alert-octagon
severity: error
roles: [underwriter, seniorUnderwriter, riskManager, admin]
fairLendingFlag:
on: transition
transition: deny
condition: "hmdaReportable == true"
capture: [denialCodes, requestedAmount, adverseActionDeadline]
label: "HMDA-REPORTABLE DENIAL — fair lending review required"
detail: "Denial codes: {denialCodes}. Adverse-action notice due: {adverseActionDeadline}"
icon: lucide:scale
severity: error
roles: [complianceOfficer, seniorUnderwriter, admin]
adverseActionSent:
on: fieldChange
field: adverseActionSentDate
capture: [adverseActionSentDate, adverseActionDeadline]
label: "Adverse-action notice sent {adverseActionSentDate}"
icon: lucide:mail
severity: info
permissions:
list: [authenticated]
get: [authenticated]
create: [loanOfficer, admin]
update: [loanOfficer, loanProcessor, creditAnalyst, underwriter, seniorUnderwriter, closingManager, admin]
delete: [admin]
conditional:
update:
funded: []
denied: [complianceOfficer, admin] # only to record the adverse-action notice
withdrawn: []
fields:
creditScore: { write: [creditAnalyst, admin] }
dti: { write: [creditAnalyst, admin] }
ltv: { write: [creditAnalyst, underwriter, admin] }
underwritingDecision: { write: [underwriter, seniorUnderwriter, admin] }
approvedAmount: { write: [underwriter, seniorUnderwriter, admin] }
interestRate: { write: [underwriter, seniorUnderwriter, admin] }
denialReason: { write: [underwriter, seniorUnderwriter, admin] }
denialCodes: { write: [underwriter, seniorUnderwriter, admin] }
fundedAmount: { write: [closingManager, admin] }
underwriterId: { write: [admin] } # set via the decision transitions
adverseActionSentDate: { write: [complianceOfficer, loanProcessor, admin] }
schedules:
adverseActionNotice:
description: "Reg B: adverse-action notice must go out within 30 days of denial"
type: relative
source:
resource: Application
filter: "status == 'denied' && adverseActionSentDate == null"
relativeTo: adverseActionDeadline
tiers:
- daysUntil: 7
action: notify
tag: t7
channels: [email]
recipients: { roles: [complianceOfficer, loanProcessor] }
template:
subject: "Adverse-action notice due in 7 days — {applicationNumber}"
body: "Denial recorded {deniedAt}. Notice deadline: {adverseActionDeadline}."
- daysUntil: 0
action: notify
tag: due
channels: [email]
recipients: { roles: [complianceOfficer, admin] }
template:
subject: "ADVERSE-ACTION NOTICE DUE TODAY — {applicationNumber}"
body: "Reg B 30-day window ends today and no notice date is recorded."
Domain 4: Enterprise Stress Testing (stress)
domain:
name: stress
version: "1.0.0"
description: >
Stress scenario governance (DFAST/CCAR, SR 11-7 effective challenge).
Reviewer and approver identity is captured from the authenticated session,
the approver cannot be the model developer, and full pre-update snapshots
come from versioning.
objects:
Timestamp:
createdAt: { type: datetime, autoNow: create }
updatedAt: { type: datetime, autoNow: update }
enums:
ScenarioType:
values:
baseline: { label: "Baseline" }
adverse: { label: "Adverse" }
severely_adverse: { label: "Severely Adverse" }
idiosyncratic: { label: "Idiosyncratic" }
custom: { label: "Custom" }
ScenarioStatus:
values:
draft: { label: "Draft", color: "#94a3b8" }
submitted: { label: "Submitted", color: "#3b82f6" }
under_review: { label: "Under Review", color: "#8b5cf6" }
challenged: { label: "Challenged", color: "#f59e0b" }
approved: { label: "Approved", color: "#10b981" }
finalized: { label: "Finalized", color: "#059669", final: true }
resources:
Scenario:
mode: sheet
description: >
An economic stress scenario. Dynamic columns for scenario-specific
variables; reactive formulas for capital ratios.
object:
id: { type: uuid, primaryKey: true }
scenarioName: { type: string, required: true, maxLength: 120 }
scenarioType: { $ref: '#/enums/ScenarioType', required: true }
regulatoryExam: { type: string, maxLength: 60 }
status: { $ref: '#/enums/ScenarioStatus', default: draft }
quarter: { type: string, required: true, maxLength: 10 }
gdpGrowth: { type: percentage }
unemploymentRate: { type: percentage }
fedFundsRate: { type: percentage }
housingPriceIndex: { type: float }
equityMarketDecline: { type: percentage }
creditSpreadBps: { type: integer }
projectedLosses: { type: money }
projectedRevenue: { type: money }
projectedCapital: { type: money }
riskWeightedAssets: { type: money }
minimumCapitalRequirement: { type: money }
modelId: { type: string, maxLength: 40 }
modelVersion: { type: string, maxLength: 20 }
createdById: { type: string, required: true, immutable: true, maxLength: 100 }
reviewerId: { type: string, maxLength: 100 }
reviewerName: { type: string, maxLength: 120 }
approvedById: { type: string, maxLength: 100 }
approvedByName: { type: string, maxLength: 120 }
challengeReason: { type: text }
finalizedAt: { type: datetime }
$merge: { $ref: '#/objects/Timestamp' }
indexes:
- { fields: [status] }
- { fields: [quarter] }
- { fields: [scenarioType] }
computed:
cet1Ratio:
type: percentage
# Guard the zero denominator, not just null, to avoid divide-by-zero.
formula: "if(projectedCapital != null && riskWeightedAssets != null && riskWeightedAssets > 0, projectedCapital / riskWeightedAssets * 100, null)"
stored: true
reactive: true
capitalSurplus:
type: money
formula: "if(projectedCapital == null, 0, projectedCapital) - if(minimumCapitalRequirement == null, 0, minimumCapitalRequirement)"
stored: true
reactive: true
capitalAdequate:
type: boolean
formula: "capitalSurplus >= 0"
stored: true
reactive: true
dynamicFields:
enabled: true
allowTypes: [money, percentage, float, integer, string]
maxColumns: 100
permissions:
addColumn: [modelDeveloper, riskManager, admin]
deleteColumn: [admin]
stateMachine:
field: status
initial: draft
states:
draft:
transitions:
submit:
to: submitted
guards: [modelDeveloper, admin]
requiredFields: [gdpGrowth, unemploymentRate, projectedLosses, riskWeightedAssets]
submitted:
transitions:
review:
to: under_review
guards: [modelValidator, admin]
sets:
reviewerId: "$user.id"
reviewerName: "$user.username"
under_review:
transitions:
approve:
to: approved
# SR 11-7 effective challenge: the approver is not the model
# developer and not the validator. No admin bypass.
guard: >
(hasRole('riskManager') || hasRole('chiefRiskOfficer'))
&& field('createdById') != userId()
&& field('reviewerId') != userId()
sets:
approvedById: "$user.id"
approvedByName: "$user.username"
challenge:
to: challenged
guards: [modelValidator, riskManager, admin]
requiredFields: [challengeReason]
challenged:
transitions:
revise:
to: draft
guards: [modelDeveloper, admin]
approved:
transitions:
finalize:
to: finalized
guards: [chiefRiskOfficer, admin]
sets:
finalizedAt: "now()"
finalized:
final: true
features:
audit: true
versioning: true # full pre-update snapshots
history:
events:
scenarioCreated:
on: create
capture: [scenarioName, scenarioType, quarter]
label: "Scenario '{scenarioName}' created"
detail: "Type: {scenarioType}, Quarter: {quarter}"
icon: lucide:flask-conical
severity: info
submitted:
on: transition
transition: submit
capture: [gdpGrowth, unemploymentRate, projectedLosses, projectedCapital, cet1Ratio]
label: "Scenario submitted for review"
detail: "GDP: {gdpGrowth}%, Unemployment: {unemploymentRate}%, Losses: {projectedLosses}, CET1: {cet1Ratio}%"
icon: lucide:send
severity: info
approved:
on: transition
transition: approve
capture: [approvedByName, cet1Ratio, capitalAdequate]
label: "Approved by {approvedByName}"
detail: "CET1 ratio: {cet1Ratio}%. Capital adequate: {capitalAdequate}"
icon: lucide:check-circle
severity: success
challenged:
on: transition
transition: challenge
capture: [challengeReason]
label: "Model challenged"
detail: "{challengeReason}"
icon: lucide:alert-triangle
severity: warning
finalized:
on: transition
transition: finalize
capture: [scenarioName, cet1Ratio, projectedLosses, capitalSurplus]
label: "Scenario FINALIZED — CET1 {cet1Ratio}%"
detail: "Projected losses: {projectedLosses}. Capital surplus: {capitalSurplus}"
icon: lucide:lock
severity: success
capitalBreached:
on: fieldChange
field: capitalAdequate
condition: "capitalAdequate == false"
capture: [scenarioName, cet1Ratio, projectedCapital, minimumCapitalRequirement, capitalSurplus]
label: "CAPITAL ADEQUACY BREACH — {scenarioName}"
detail: "CET1: {cet1Ratio}%. Projected capital {projectedCapital} below minimum {minimumCapitalRequirement}. Deficit: {capitalSurplus}"
icon: lucide:alert-octagon
severity: error
parameterChanged:
on: fieldChange
field: "*"
label: "{fieldName} updated"
icon: lucide:edit
severity: info
permissions:
list: [authenticated]
get: [authenticated]
create: [modelDeveloper, riskManager, admin]
update: [modelDeveloper, modelValidator, riskManager, chiefRiskOfficer, admin]
delete: [admin]
conditional:
update:
finalized: [] # finalized scenarios are the exam record — locked
fields:
projectedLosses: { write: [modelDeveloper, admin] }
projectedRevenue: { write: [modelDeveloper, admin] }
projectedCapital: { write: [modelDeveloper, admin] }
reviewerId: { write: [admin] } # set via the review transition
approvedById: { write: [admin] } # set via the approve transition
Examiner queries
# Best execution (MiFID II Art. 27) — cross-record event stream, then a timeline
curl -s "$BASE/api/v1/trading/orders/history?event=bestExecutionReview&after=2026-01-01" \
-H "Authorization: Bearer $TOKEN"
curl -s "$BASE/api/v1/trading/orders?filter[orderId]=ORD-2026-8847" \
-H "Authorization: Bearer $TOKEN" | jq -r '.data.items[0].id'
curl -s "$BASE/api/v1/trading/orders/{id}/history" -H "Authorization: Bearer $TOKEN"
# Per-execution audit trail (Rule 613): the fills themselves
curl -s "$BASE/api/v1/trading/orders/{id}/executions" -H "Authorization: Bearer $TOKEN"
# SAR timeliness (BSA) — a filtered list on a stored computed field,
# not a reconstruction from warning events
curl -s "$BASE/api/v1/aml/cases?filter[filedLate]=true" -H "Authorization: Bearer $TOKEN"
# Evidence the deadline engine ran (scheduler log)
curl -s "$BASE/api/v1/aml/schedules/sarDeadline/log?since=2025-01-01" \
-H "Authorization: Bearer $TOKEN"
# Fair lending (ECOA/HMDA) — flagged denials plus notice timeliness
curl -s "$BASE/api/v1/lending/applications/history?event=fairLendingFlag&after=2025-01-01" \
-H "Authorization: Bearer $TOKEN"
curl -s "$BASE/api/v1/lending/applications?filter[status]=denied&sort=adverseActionDeadline" \
-H "Authorization: Bearer $TOKEN"
# Model governance (SR 11-7 / DFAST) — resolve the scenario, then its lifecycle
curl -s "$BASE/api/v1/stress/scenarios?filter[scenarioName]=DFAST-Q4-2025" \
-H "Authorization: Bearer $TOKEN" | jq -r '.data.items[0].id'
curl -s "$BASE/api/v1/stress/scenarios/{id}/history" -H "Authorization: Bearer $TOKEN"
curl -s "$BASE/api/v1/stress/scenarios/{id}/versions" -H "Authorization: Bearer $TOKEN"
Note on the SoD narrative for Section V: with these guards, the claims are now true as written: an analyst cannot no-action their own case, a BSA officer’s role begins at escalation, a trader cannot approve their own suitability, an underwriter cannot decide a loan they originated, and a funder cannot fund a loan they underwrote. Admin is deliberately absent from suitability approval, credit decisions, SAR-adjacent closures, and model approval.