← All white papers

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.