Vertep

Credit decision

A lender lets a model underwrite consumer loans inside a chart that holds the policy. Three applications are decided, then an auditor opens the same journal with no decider bound and reads what was offered, who chose and why. A scripted decider stands in for the model, so the example needs no network.

Files: examples/credit-decision/loan.scxml, examples/credit-decision/main.rs, examples/credit-decision/expected-output.txt. All commands run from the repository root.

The chart

Three rules hold however the underwriter behaves. A debt-to-income ratio over 50% is declined by rule, before any decider is asked. approve is offered only while it is enabled: ratio at most 43% and amount at most 50,000. An answer that was not offered is refused and the case goes to a person.

<?xml version="1.0" encoding="UTF-8"?>
<!--
  A consumer-loan decision with a model underwriter, a hard policy limit and a human officer, on
  the CEL datamodel.

  Three things are true however the underwriter behaves:

  - An application whose debt-to-income ratio is over 50 % is declined by rule, before any
    decider is asked (`screening`).
  - `approve` is offered only while it is enabled: the ratio must be at most 43 % and the amount
    at most 50 000. A model cannot approve what policy forbids, because the chart never offers it.
  - A decider that answers with an event it was not offered is refused, and the case goes to a
    person (`error.decide.invalid`).

  Illustrates the deciders capability and the decision-record story in site/articles.
-->
<scxml xmlns="http://www.w3.org/2005/07/scxml" xmlns:vt="https://w3id.org/vertep/vt#"
       version="1.0" datamodel="cel" name="loan-decision" initial="received">
  <datamodel>
    <data id="amount" expr="0"/>
    <data id="dti" expr="0"/>
  </datamodel>

  <state id="received">
    <transition event="apply" target="screening">
      <assign location="amount" expr="ev.data.amount"/>
      <assign location="dti" expr="ev.data.dti"/>
    </transition>
  </state>

  <!-- Hard rule: no decider is consulted. -->
  <state id="screening">
    <transition cond="ctx.dti > 50" target="declined"/>
    <transition target="underwriting"/>
  </state>

  <state id="underwriting" vt:decide="underwriter" vt:candidates="approve decline refer">
    <transition event="approve" cond="ctx.dti &lt;= 43 &amp;&amp; ctx.amount &lt;= 50000" target="approved"/>
    <transition event="decline" target="declined"/>
    <transition event="refer" target="officer_review"/>
    <transition event="error.decide.invalid" target="officer_review"/>
  </state>

  <state id="officer_review" vt:decide="officer" vt:candidates="approve decline">
    <transition event="approve" target="approved"/>
    <transition event="decline" target="declined"/>
  </state>

  <state id="approved"/>
  <state id="declined"/>
</scxml>

The program

The first AgentHost decides three applications. The second, the auditor's, opens the same journal with no decider bound and replays it.

//! Credit decision: a regulated decision that can be explained from its record.
//!
//! A lender uses a model to underwrite consumer loans. The chart (`loan.scxml`) holds the
//! policy: a hard debt-to-income limit, an approval ceiling the model cannot talk its way past,
//! and a human officer for the cases it refers. A scripted decider stands in for the model so
//! the example needs no network and prints the same thing on every run.
//!
//! Three applications are decided, then a second host opens the same journal with **no decider
//! bound** and answers the question a regulator or an applicant asks afterwards: what was the
//! process in, what was offered, who chose, and why? Nothing is reconstructed from logs: the
//! journal is the process.
//!
//! Illustrates the `deciders` capability (Enabled Event Offer, Decisions Are Journaled Inputs,
//! Decider Safety).
//!
//! Run with `cargo run -p vertep-examples --bin credit-decision`.

use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};

use vertep::agent::{
    AgentHost, AgentInput, Annotations, Decider, DeciderKind, Decision, DecisionOutcome,
    DecisionRequest, MemoryJournal,
};
use vertep::prelude::{Event, VirtualClock};

/// The chart, next to this file.
const LOAN: &str = include_str!("loan.scxml");
/// 2026-11-11T00:00:00Z, the virtual clock's start.
const T0: i64 = 1_794_355_200_000;

type Error = Box<dyn std::error::Error>;

/// The applications: id, amount, debt-to-income percent.
const APPLICATIONS: [(&str, u32, u32); 3] = [
    ("https://example.org/loans/1001", 20_000, 31),
    ("https://example.org/loans/1002", 80_000, 38),
    ("https://example.org/loans/1003", 15_000, 62),
];

/// A deterministic stand-in for a model or a person: answers from a script, in order, and says
/// what it was offered.
struct Scripted {
    role: &'static str,
    answers: Mutex<VecDeque<(&'static str, &'static str)>>,
}

impl Decider for Scripted {
    fn kind(&self) -> DeciderKind {
        DeciderKind::Custom
    }

    fn decide(
        &self,
        request: DecisionRequest,
    ) -> Pin<Box<dyn Future<Output = DecisionOutcome> + Send>> {
        let offered: Vec<&str> = request
            .candidates
            .iter()
            .map(|c| c.event.as_str())
            .collect();
        let answer = self.answers.lock().expect("lock").pop_front();
        let outcome = match answer {
            Some((event, why)) => {
                println!(
                    "  {} asked in `{}`: offered {offered:?} -> {event}",
                    self.role, request.state
                );
                let mut decision = Decision::new(event, vertep::prelude::Value::Undefined);
                decision.annotations = Annotations {
                    rationale: Some(why.to_owned()),
                    ..Annotations::default()
                };
                DecisionOutcome::Decision(decision)
            }
            None => DecisionOutcome::refuse("the script is exhausted"),
        };
        Box::pin(async move { outcome })
    }
}

fn main() -> Result<(), Error> {
    let journal = Arc::new(MemoryJournal::new());

    // ---- decide three applications ----------------------------------------------------------
    println!("deciding three applications:");
    let underwriter = Scripted {
        role: "underwriter",
        answers: Mutex::new(VecDeque::from([
            ("approve", "stable income, low ratio, small amount"),
            ("approve", "strong credit history"),
        ])),
    };
    let officer = Scripted {
        role: "officer    ",
        answers: Mutex::new(VecDeque::from([(
            "approve",
            "verified employment by phone; collateral accepted",
        )])),
    };
    let mut host = AgentHost::builder(Arc::new(VirtualClock::new(T0)))
        .journal(journal.clone())
        .decider("underwriter", Arc::new(underwriter))
        .decider("officer", Arc::new(officer))
        .build();
    let version = host.register(chart()?)?;
    for (id, amount, dti) in APPLICATIONS {
        println!("{id}  amount {amount}, ratio {dti}%");
        host.spawn(id, &version)?;
        host.send(id, apply(amount, dti));
        host.run_until_idle();
        println!("  -> {:?}", host.state(id)?.configuration);
    }

    // ---- explain, from the journal alone ----------------------------------------------------
    println!();
    println!("an auditor opens the same journal; no decider is bound:");
    let mut audit = AgentHost::builder(Arc::new(VirtualClock::new(T0)))
        .journal(journal)
        .build();
    audit.register(chart()?)?;
    for (id, _, _) in APPLICATIONS {
        let state = audit.state(id)?;
        println!("{id}  final {:?}", state.configuration);
        for entry in audit.journal(id) {
            let detail = match &entry.input {
                AgentInput::Start { .. } => String::new(),
                AgentInput::Event { event, .. } => event.name.clone(),
                AgentInput::Decision {
                    event, annotations, ..
                } => format!(
                    "{event}, rationale {:?}",
                    annotations.rationale.as_deref().unwrap_or("")
                ),
                other => format!("{other:?}"),
            };
            let line = format!("  {} {:<9} {detail}", entry.position, entry.input.kind());
            println!("{}", line.trim_end());
        }
        for position in 2..=state.position {
            println!(
                "  as of entry {position}: {:?}",
                audit.state_at(id, position)?.configuration
            );
        }
    }
    println!(
        "entries replayed: {}, decider calls: {}",
        audit.stats().replayed,
        audit.stats().decider_calls.values().sum::<usize>()
    );
    Ok(())
}

/// Reads the chart. Charts naming a datamodel other than `null` need `AnyDatamodel`.
fn chart() -> Result<vertep::prelude::Chart, Error> {
    let opts = vertep::scxml::ReadOptions::new().with_datamodels(&vertep::scxml::AnyDatamodel);
    Ok(vertep::scxml::read(LOAN, &opts)?.chart)
}

/// An `apply` event with the application's figures.
fn apply(amount: u32, dti: u32) -> Event {
    Event {
        name: "apply".into(),
        data: vertep::agent::json::from_json(&serde_json::json!({ "amount": amount, "dti": dti })),
        ..Event::default()
    }
}

Run it

cargo run -p vertep-examples --bin credit-decision
deciding three applications:
https://example.org/loans/1001  amount 20000, ratio 31%
  underwriter asked in `underwriting`: offered ["approve", "decline", "refer"] -> approve
  -> ["approved"]
https://example.org/loans/1002  amount 80000, ratio 38%
  underwriter asked in `underwriting`: offered ["decline", "refer"] -> approve
  officer     asked in `officer_review`: offered ["approve", "decline"] -> approve
  -> ["approved"]
https://example.org/loans/1003  amount 15000, ratio 62%
  -> ["declined"]

an auditor opens the same journal; no decider is bound:
https://example.org/loans/1001  final ["approved"]
  1 start
  2 event     apply
  3 decision  approve, rationale "stable income, low ratio, small amount"
  as of entry 2: ["underwriting"]
  as of entry 3: ["approved"]
https://example.org/loans/1002  final ["approved"]
  1 start
  2 event     apply
  3 decision  approve, rationale "strong credit history"
  4 decision  approve, rationale "verified employment by phone; collateral accepted"
  as of entry 2: ["underwriting"]
  as of entry 3: ["officer_review"]
  as of entry 4: ["approved"]
https://example.org/loans/1003  final ["declined"]
  1 start
  2 event     apply
  as of entry 2: ["declined"]
entries replayed: 9, decider calls: 0

This example shows how a record can explain a decision. It is not a compliance product: what a regulator requires of a lender is a legal question, and the articles Explainable by construction and Three regulated decisions say what the record does and does not cover.