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 <= 43 && ctx.amount <= 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
- Loan 1001 was approved by the underwriter, who was offered all three moves.
- On loan 1002 the amount is over the ceiling, so
approvewas never offered: the underwriter answered it anyway, the chart refused the answer, and an officer decided. The journal keeps the refused answer with its rationale. - Loan 1003 was declined by rule. The journal has no decision entry because nobody was asked.
- The audit replays nine entries with zero decider calls, and
state_atshows the process as of every entry.
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.