Build a Rust Loan Review Assistant
Build a Rust AI workflow that validates loan data and routes reviews to humans.
Introduction
30 Second Summary
A loan application can look complete while still hiding missing information or unsupported assumptions. A reviewer needs a clear trail that shows what was checked before any credit decision.
In this project, you will build a command-line review assistant with Rust and Rig that prepares typed evidence packets for human reviewers. Deterministic checks block incomplete applications before a paid OpenAI API request.
What You'll Build
You'll run a synthetic application to see a typed JSON review packet appear in your terminal for a human underwriter.
By the end of this project, you'll have:
- A ready review scenario that returns data-quality findings with fictional policy evidence for a human underwriter.
- A local validation gate that reports Cloud model calls: 0 when an incomplete application stops before the provider request.
- An evidence allow-list that accepts only P-101, P-202, or P-303 before displaying the packet.
- Secret Mission: An adversarial test that proves a fabricated BANK-999 policy citation is rejected.
Are there any prerequisites?
You'll need a Windows computer with Rust installed through rustup and an OpenAI API account with separate API billing.
Follow-on projects build on this foundation with RAG, databases, HTTP serving, deployment, memory management, agent harnesses, and multi-agent orchestration.
Before We Start
Before any hands-on work, lock in this safety boundary: the Rust CLI reviews synthetic loan-application data for a human underwriter without ever approving or denying credit. The human underwriter remains responsible for every credit decision.
Set Up RustRover and OpenAI
The Rig version used in this project requires Rust 1.95.0 or newer. An older compiler blocks the project before the assistant can run.
In this step, you will prepare RustRover for development. You will also configure the OpenAI API credential that powers the first live review.
In this step, get ready to:
- Install RustRover with an active license.
- Confirm RustRover recognizes Rust 1.95.0 or newer.
- Enable OpenAI API billing and store your API key in the Windows environment.
Install and license RustRover
RustRover brings Rust editing tools and an integrated terminal into one workspace. JetBrains provides a free non-commercial license for learning or a paid license for commercial work.
Choose the tab that matches what Windows finds on your computer.
✔️ RustRover is installed
- Press the Windows key to open Windows search.
- Type RustRover into the search field.
- Press Enter to open RustRover.
- Select Help from the main menu.
- Select Register.
- Select Non-commercial use for unpaid learning work or Paid license for commercial work.
- Follow the registration prompts until RustRover shows an active license.
ⓧ RustRover is missing
- Open the official RustRover installation guide in your browser.
- Download the Windows Toolbox App .exe installer.
- Run the downloaded installer.
- Complete the Toolbox App installation wizard.
- Click the Toolbox App icon in the Windows notification area.
- Select RustRover from the available products.
- Click Install.
Toolbox App downloads RustRover and manages future IDE updates from the same place.
- Open RustRover through Windows search after the installation finishes.
- Select Help from the main menu.
- Select Register.
- Activate the free non-commercial license or your existing commercial license.
RustRover needs to recognize the toolchain already installed on your computer. Its Rust settings show the compiler version and Standard Library paths it plans to use.
- Press Ctrl+Alt+S in RustRover to open the IDE settings.
- Select Rust in the settings sidebar.
- Confirm the Toolchain version field contains a version instead of N/A.
- Confirm the Standard library field contains a detected path.
- Click OK to keep the detected settings.
That is the first setup milestone complete. RustRover is licensed and connected to your Rust installation.
Toolchain Not Detected?
- Return to the Rust settings page and click Set up toolchain if RustRover displays that option.
- Click Install Rustup only if RustRover cannot find the Rust installation already on your computer.
- Still stuck? Help me connect RustRover to my installed Rust toolchain.
Check and update the Rust compiler
The compiler version controls whether Cargo can build the pinned Rig dependency. You need Rust 1.95.0 or newer before creating the project.
- Open RustRover's integrated terminal from the tool-window bar.
Before you run the check, what compiler version do you expect RustRover's terminal to report?
- Check the compiler version by running this command:
rustc --version
What Does This Command Do?
The rustc --version command prints the compiler version available inside this terminal. The number after rustc determines which tab to follow.
✔️ I see version 1.95.0 or newer
Your compiler meets Rig's minimum supported Rust version. Keep RustRover open for the OpenAI setup.
ⓧ I see an older version
Your installed compiler needs an update before Cargo can build Rig 0.44.0.
- Close RustRover completely.
- Press the Windows key to open Windows search.
- Type PowerShell into the search field.
- Press Enter to open PowerShell.
- Update the installed Rust release channels by running this command:
rustup update
What Does This Command Do?
The rustup update command downloads updates for your installed Rust release channels. It also switches the managed toolchain to the updated compiler.
- Reopen RustRover through Windows search after the update finishes.
- Open RustRover's integrated terminal from the tool-window bar.
- Check the updated compiler version by running:
rustc --version
What Should I Check?
The repeated version check confirms that the reopened IDE inherited the updated toolchain. The printed compiler version must now be 1.95.0 or newer.
Rust Update Not Working?
- Confirm RustRover is fully closed before running the update so no IDE process holds the old environment.
- Confirm your Rust installation is managed by rustup if the update command is unavailable.
- Still stuck? Help me update my Windows Rust compiler to version 1.95.0 or newer.
Configure OpenAI billing and your API key
The live reviewer authenticates through an environment variable named OPENAI_API_KEY. Keeping the credential outside the Rust source prevents it from being written into the project.
API Billing Is Separate
Billing setup can feel sensitive. OpenAI API billing is separate from any ChatGPT subscription.
The short included runs are expected to cost roughly $0.01 to $0.05. Repeated live runs cost more.
- Open the official API billing overview in your browser.
- Sign in to the OpenAI API Platform.
- Select Add payment details.
- Complete the payment-information step.
- Confirm the billing overview shows a payment method or credit balance.
An API key is a live credential that authorizes billed requests. Its full value appears only when you create it, so keep it out of screenshots and project files.
- Open the official API keys page.
- Select + Create new secret key.
- Complete the key-creation dialog.
- Copy the new secret key when OpenAI reveals it.
- Store the copied key in a password manager or another secure credential store.
- Close the key-reveal dialog before continuing.
Windows must save the credential for future processes. The placeholder in the next command stands for the secret you just stored.
- Press the Windows key to open Windows search.
- Type PowerShell into the search field.
- Press Enter to open PowerShell.
- Replace your-api-key-here with the secret key in your secure credential store.
- Save the OpenAI credential in the Windows environment by running this command:
setx OPENAI_API_KEY "your-api-key-here"
What Does This Command Do?
The setx command saves OPENAI_API_KEY for future Windows processes. RustRover must restart before its integrated terminal can inherit the new value.
- Close RustRover completely.
- Press the Windows key to open Windows search.
- Type RustRover into the search field.
- Press Enter to reopen RustRover.
- Open RustRover's integrated terminal from the tool-window bar.
Before you check, do you expect the restarted terminal to find the environment variable?
- Check for the environment variable without printing its secret value by running:
Test-Path Env:OPENAI_API_KEY
What Does This Check Prove?
PowerShell treats environment variables as entries in its Env: provider. A result of True confirms that the restarted RustRover terminal can find the credential without exposing it.
You should see True. Your secret stays hidden during this check.
Environment Variable Missing?
- Confirm you closed every RustRover window after running setx.
- Run the setx command again if PowerShell reports that the environment-variable path is missing.
- Still stuck? Help me make OPENAI_API_KEY available inside RustRover without revealing its value.
One final check confirms that the same restarted terminal has both parts of the setup. What Rust version do you expect it to report now?
- Verify the compiler version in RustRover's terminal by running:
rustc --version
What Confirms Success?
The command prints the compiler available to the restarted RustRover process. Success means the version is 1.95.0 or newer.
Your terminal now reports Rust 1.95.0 or newer. It can also find OPENAI_API_KEY without displaying the secret.
Your Rust and OpenAI environment is ready for the first live workflow. Next, you will create the Cargo project and watch a free-form reviewer produce an unsupported loan review.
Build a Free-Form Reviewer
Your RustRover workspace can now compile current Rust code. Your OpenAI API access is also ready for the first live review.
A free-form Rig reviewer can produce convincing prose from synthetic application data. This run tests whether that prose provides traceable policy evidence or a stable structure.
In this step, get ready to:
- Create the Cargo binary project.
- Pin the project's Rust dependencies.
- Run a free-form Rig review.
Create the Cargo project
Cargo creates the package structure that RustRover recognizes as a runnable application. Keeping the folder on your Desktop also gives the project a predictable location.
- Return to RustRover's integrated terminal from the previous step.
- Move to your Desktop by running this command:
cd ~/Desktop
Why start on the Desktop?
PowerShell expands ~ to your home folder. This command places the new project inside its Desktop directory.
- Create the loan-review-assistant binary project by running this command:
cargo new loan-review-assistant --bin --vcs none
What does this command create?
- The --bin option creates a runnable Rust package.
- The --vcs none option skips version-control initialization.
- Cargo creates Cargo.toml plus the starter src/main.rs file.
You should see confirmation that Cargo created a binary package. The new project folder now contains the two starter files.
Project folder not created?
- Check that RustRover's terminal still recognizes the cargo command.
- Check whether an older loan-review-assistant folder already exists on your Desktop.
- Still stuck? Help me troubleshoot Cargo project creation in RustRover on Windows.
- Click File in RustRover's top menu.
- Click Open.
- Select the root Cargo.toml inside the Desktop's loan-review-assistant folder.
- Click Open to load the package as a RustRover project.
RustRover's Project tool window should list Cargo.toml plus the src folder. Your local project is ready for its dependency plan.
Add the project foundation
Exact dependency pins keep the same package versions in every learner's build. Rig 0.44.0 also requires the recent Rust toolchain you verified earlier.
- Double-click Cargo.toml in RustRover's Project tool window.
- Replace the entire file with this dependency plan:
[package]
name = "loan-review-assistant"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "=1.0.104"
rig = "=0.44.0"
schemars = "=1.2.2"
serde = { version = "=1.0.229", features = ["derive"] }
serde_json = "=1.0.152"
tokio = { version = "=1.53.2", features = ["macros", "rt-multi-thread"] }
[dev-dependencies]
rig = { version = "=0.44.0", features = ["test-utils"] }
What does this dependency plan do?
- The rig dependency supplies the agent builder plus the OpenAI provider integration.
- The tokio features supply the asynchronous runtime required by the live model request.
- The serde packages turn the synthetic application into JSON for the prompt.
- The development dependency keeps Rig's offline testing helpers available outside the normal runtime dependency.
- Save Cargo.toml by pressing Ctrl+S.
The first build can take a few minutes while Cargo downloads the pinned crates. A busy terminal during this build means the dependency setup is progressing.
- Return to the integrated terminal for loan-review-assistant.
- Build the dependency plan with the starter program by running:
cargo run
What does this first run prove?
Cargo resolves the exact dependency versions from Cargo.toml. It then compiles the unchanged starter program.
You should see the starter greeting after compilation finishes. This confirms that the dependency plan works before the reviewer code is added.
Dependency build failed?
- Check that every dependency version includes the leading equals sign shown in Cargo.toml.
- Check that rustc still reports version 1.95.0 or newer in this terminal.
- Still stuck? Help me troubleshoot the pinned Cargo dependency build.
✔️ Awesome, I've got everything!
Your pinned dependency plan builds successfully. Double-check that Cargo.toml is saved before continuing.
ⓧ I'd like to double check the full code
[package]
name = "loan-review-assistant"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "=1.0.104"
rig = "=0.44.0"
schemars = "=1.2.2"
serde = { version = "=1.0.229", features = ["derive"] }
serde_json = "=1.0.152"
tokio = { version = "=1.53.2", features = ["macros", "rt-multi-thread"] }
[dev-dependencies]
rig = { version = "=0.44.0", features = ["test-utils"] }
The reviewer needs synthetic data with the same shape as a loan application. Keeping the values fictional prevents real applicant information from entering this learning workflow.
- Expand the src folder in RustRover's Project tool window.
- Double-click main.rs.
- Paste this code above the existing starter main function:
use anyhow::{bail, Result};
use rig::prelude::*;
use rig::providers::openai::{self, OpenAI};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize)]
struct LoanApplication {
application_id: String,
annual_income_usd: u64,
monthly_debt_usd: u64,
bank_statement_months: u8,
identity_verified: bool,
requested_amount_usd: u64,
}
What does this foundation provide?
- The imports expose error handling plus the Rig agent APIs required by the live request.
- The LoanApplication fields hold the fictional facts that the reviewer receives.
- The Serialize derive allows the application value to become JSON.
- Save src/main.rs by pressing Ctrl+S.
- Check the new imports plus LoanApplication with the starter program by running:
cargo run
What should this checkpoint show?
Cargo should compile the data structure before running the starter program. Warnings about unused imports or fields are expected because the next code chunk uses them.
You should still see the starter greeting. The successful compilation proves that Rust accepts the application structure.
Seeing a Rust syntax error?
- Check that the new code sits above the existing starter main function.
- Check that every LoanApplication field ends with a comma.
- Still stuck? Help me fix the Rust imports or LoanApplication structure.
Run the free-form review
An asynchronous main function can wait for Rig's cloud request to finish. The response remains plain prose because this version supplies no output schema or fictional policy bundle.
- Delete the existing starter main function from src/main.rs.
- Paste this asynchronous replacement below LoanApplication.
#[tokio::main]
async fn main() -> Result<()> {
// Keep this learning run separate from real applicant information.
let application = LoanApplication {
application_id: "APP-1002".to_string(),
annual_income_usd: 96_000,
monthly_debt_usd: 1_800,
bank_statement_months: 3,
identity_verified: true,
requested_amount_usd: 25_000,
};
// Send the synthetic facts to one unconstrained prose reviewer.
let model = OpenAI::from_env()?.completion(openai::GPT_5_4_MINI);
let agent = AgentBuilder::new(model)
.preamble("You review synthetic loan applications for a human reviewer. Never approve or deny an application.")
.build();
let application_json = serde_json::to_string(&application)?;
let prompt = format!(
"Review this synthetic application and describe any lending concerns:\n{application_json}"
);
let response = agent.prompt(prompt).await?;
println!("{}", response.output());
Ok(())
}
What does this reviewer do?
- The application value contains only the synthetic ready scenario.
- OpenAI::from_env() reads the API key inherited by RustRover's terminal.
- AgentBuilder creates a Rig reviewer around GPT-5.4 Mini.
- The free-form prompt asks for prose without supplying policy identifiers or a response schema.
- Save src/main.rs by pressing Ctrl+S.
✔️ Awesome, I've got everything!
Your free-form reviewer is ready. Double-check that src/main.rs is saved before making the billed request.
ⓧ I'd like to double check the full code
use anyhow::{bail, Result};
use rig::prelude::*;
use rig::providers::openai::{self, OpenAI};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize)]
struct LoanApplication {
application_id: String,
annual_income_usd: u64,
monthly_debt_usd: u64,
bank_statement_months: u8,
identity_verified: bool,
requested_amount_usd: u64,
}
#[tokio::main]
async fn main() -> Result<()> {
// Keep this learning run separate from real applicant information.
let application = LoanApplication {
application_id: "APP-1002".to_string(),
annual_income_usd: 96_000,
monthly_debt_usd: 1_800,
bank_statement_months: 3,
identity_verified: true,
requested_amount_usd: 25_000,
};
// Send the synthetic facts to one unconstrained prose reviewer.
let model = OpenAI::from_env()?.completion(openai::GPT_5_4_MINI);
let agent = AgentBuilder::new(model)
.preamble("You review synthetic loan applications for a human reviewer. Never approve or deny an application.")
.build();
let application_json = serde_json::to_string(&application)?;
let prompt = format!(
"Review this synthetic application and describe any lending concerns:\n{application_json}"
);
let response = agent.prompt(prompt).await?;
println!("{}", response.output());
Ok(())
}
This next command makes one usage-billed OpenAI API request with synthetic data. Running it once is enough to expose the free-form review's limits.
Before you run the reviewer, do you think every lending-related statement will point to supplied policy evidence?
- Run the live reviewer by running:
cargo run
What did the free-form run reveal?
The terminal prints a prose loan review after the model responds. Its wording can change between runs because the model generates the response.
The prompt supplied no fictional policy identifiers. Any statement about affordability, debt burden, eligibility, or lending requirements therefore lacks traceable policy evidence.
You now have your first live Rig reviewer. More importantly, you have seen why fluent prose alone cannot support an auditable workflow.
Live review did not print?
- Check that RustRover was restarted after the OPENAI_API_KEY environment variable was saved.
- Check that separate API billing remains enabled for the OpenAI account.
- Still stuck? Help me troubleshoot the Rig OpenAI request from RustRover.
Your reviewer can now analyze a synthetic application, but the model still controls the shape and claims of its response. Next, you'll put deterministic Rust validation in front of the paid model call.
Add the Deterministic Review Gate
Your free-form Rig reviewer can produce a convincing response without traceable policy evidence. That makes it unsuitable for deciding whether an incomplete application should reach the paid model.
This step puts Rust in control of that decision. Deterministic validation checks the required fields before the workflow can construct an OpenAI API client.
In this step, get ready to:
- Define the application data model and review packet.
- Create ready and missing application scenarios.
- Route incomplete applications locally before any cloud call.
Model the application and review states
A controlled workflow needs fixed types for its inputs and outputs. These types make each review state explicit before any model response enters the workflow.
- Switch back to src/main.rs in RustRover.
- Replace the import group at the top of src/main.rs with this code:
use anyhow::{bail, Result};
use rig::prelude::*;
use rig::providers::openai::{self, OpenAI};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
What do these imports provide?
- The anyhow imports let the scenario selector return a clear error for an unknown scenario.
- The rig imports preserve the cloud reviewer from the previous step.
- The schemars and serde imports prepare the review types for schema generation and serialization.
- Save src/main.rs.
- Confirm RustRover recognizes each imported name without marking it as unresolved.
Seeing an unresolved import?
- Compare each crate name with the pinned dependency in Cargo.toml.
- Wait for RustRover to finish loading the Cargo project if dependency analysis is still running.
- Still stuck? Help me diagnose the unresolved Rust imports.
The application record holds only synthetic fields used by this project. The status enums restrict routing outcomes to named states that downstream code can match safely.
- Add these application and status types below the import group:
#[derive(Debug, Clone, Serialize)]
struct LoanApplication {
application_id: String,
annual_income_usd: u64,
monthly_debt_usd: u64,
bank_statement_months: u8,
identity_verified: bool,
requested_amount_usd: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
enum ReviewStatus {
ReadyForHumanReview,
NeedsMoreInformation,
SpecialistEscalation,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
enum HumanReviewRoute {
StandardUnderwriter,
OperationsSpecialist,
}
How do these types control the workflow?
- LoanApplication gives every scenario the same required fields.
- ReviewStatus represents the workflow state without relying on free-form wording.
- HumanReviewRoute names the human queue that receives the application.
- SCREAMING_SNAKE_CASE makes serialized enum values appear as stable labels such as NEEDS_MORE_INFORMATION.
- Save src/main.rs.
- Confirm RustRover recognizes LoanApplication as a struct.
- Confirm RustRover recognizes both review types as enums.
Are the derive names highlighted?
- Check that Serialize appears in the serde import.
- Check that Deserialize and JsonSchema use the same capitalization as the code above.
- Still stuck? Help me fix the derives on my Rust data types.
A ReviewPacket keeps findings, policy evidence, follow-up questions, and the human destination together. The same shape works for a local rejection at the gate and a later cloud-assisted review.
- Add the packet type below HumanReviewRoute.
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
struct ReviewPacket {
application_id: String,
review_status: ReviewStatus,
data_quality_findings: Vec<String>,
policy_evidence: Vec<String>,
follow_up_questions: Vec<String>,
human_review_route: HumanReviewRoute,
}
What does the packet capture?
- application_id connects the packet to its synthetic application.
- data_quality_findings records deterministic validation problems.
- policy_evidence holds the fictional policy references used by the review.
- human_review_route ensures every packet names a human destination.
- Save src/main.rs.
- Confirm RustRover recognizes all six fields inside ReviewPacket.
Is ReviewPacket showing field errors?
- Place ReviewPacket below both enum definitions so its field types already exist.
- Check each field name against the snippet above if RustRover reports an unknown identifier.
- Still stuck? Help me repair my ReviewPacket definition.
Create the synthetic review scenarios
Two fixed scenarios make the gate repeatable. The ready application contains the required evidence fields while the missing application deliberately fails both checks.
- Add the ready and missing scenario functions below ReviewPacket.
fn ready_application() -> LoanApplication {
LoanApplication {
application_id: "APP-1002".to_string(),
annual_income_usd: 96_000,
monthly_debt_usd: 1_800,
bank_statement_months: 3,
identity_verified: true,
requested_amount_usd: 25_000,
}
}
fn missing_application() -> LoanApplication {
LoanApplication {
application_id: "APP-1001".to_string(),
annual_income_usd: 72_000,
monthly_debt_usd: 2_100,
bank_statement_months: 1,
identity_verified: false,
requested_amount_usd: 18_000,
}
}
Why use fixed scenarios?
Fixed synthetic inputs make the gate predictable. Every run evaluates the same fields without exposing real applicant data.
- APP-1002 represents the complete path.
- APP-1001 has one statement month.
- identity_verified is false for the missing scenario.
- Save src/main.rs.
- Confirm the missing scenario shows APP-1001 in RustRover.
- Confirm the missing scenario shows one bank-statement month.
Are the scenario fields mismatched?
- Check that each function returns a complete LoanApplication value.
- Compare the field order with the LoanApplication definition.
- Still stuck? Help me fix my synthetic application functions.
The command-line scenario becomes a small controlled input. Any value outside the two supported names returns an error before review logic runs.
- Add the scenario selector below missing_application.
fn application_for(scenario: &str) -> Result<LoanApplication> {
match scenario {
"ready" => Ok(ready_application()),
"missing" => Ok(missing_application()),
_ => bail!("Use `ready` or `missing` as the scenario."),
}
}
How does scenario selection stay bounded?
The match expression accepts only ready or missing. The fallback branch stops unknown input with a clear error.
- Save src/main.rs.
- Confirm RustRover resolves both scenario function calls inside application_for.
Is bail unresolved?
- Check that the first import contains bail.
- Check that application_for returns Result<LoanApplication>.
- Still stuck? Help me fix my scenario selector.
Block incomplete applications locally
The gate checks objective completeness conditions that Rust can evaluate directly. Missing identity verification or fewer than three statement months produces findings before the paid review path is considered.
- Add the validation function below application_for.
fn validate_application(application: &LoanApplication) -> Vec<String> {
let mut findings = Vec::new();
if application.annual_income_usd == 0 {
findings.push("annual_income_usd must be greater than zero".to_string());
}
if application.requested_amount_usd == 0 {
findings.push("requested_amount_usd must be greater than zero".to_string());
}
if !application.identity_verified {
findings.push("identity verification is missing".to_string());
}
if application.bank_statement_months < 3 {
findings.push("fewer than three bank-statement months were supplied".to_string());
}
findings
}
What does the gate inspect?
- The first two checks reject zero-value financial fields.
- The identity check records missing verification.
- The statement check records fewer than three supplied months.
- An empty findings list means the application can continue to the ready path.
- Save src/main.rs.
- Confirm RustRover resolves every field read from application.
Is a validation field unresolved?
- Compare the field spelling with LoanApplication.
- Check that the function parameter borrows LoanApplication with &.
- Still stuck? Help me repair validate_application.
An incomplete case still needs a complete review packet. The local packet records the findings and sends the case to an operations specialist without asking a model to interpret missing data.
- Add the local packet function below validate_application.
fn local_incomplete_packet(
application: &LoanApplication,
findings: Vec<String>,
) -> ReviewPacket {
ReviewPacket {
application_id: application.application_id.clone(),
review_status: ReviewStatus::NeedsMoreInformation,
data_quality_findings: findings,
policy_evidence: vec![
"P-101 requires identity verification and three bank-statement months.".to_string(),
"P-202 routes incomplete data to an operations specialist.".to_string(),
],
follow_up_questions: vec![
"Can the applicant provide identity verification?".to_string(),
"Can the applicant provide the missing bank-statement months?".to_string(),
],
human_review_route: HumanReviewRoute::OperationsSpecialist,
}
}
Why build the packet locally?
- NeedsMoreInformation makes the failed completeness state explicit.
- The findings vector preserves the exact problems found by Rust.
- The follow-up questions ask only for the missing evidence.
- OperationsSpecialist keeps the next action with a human.
- Save src/main.rs.
- Confirm the packet assigns NeedsMoreInformation as its review status.
- Confirm the packet assigns OperationsSpecialist as its human route.
Does the packet fail to compile?
- Check that every ReviewPacket field receives a value.
- Check that the findings parameter uses Vec<String>.
- Still stuck? Help me fix local_incomplete_packet.
The entry point now validates before choosing a review path. A non-empty findings list reaches the local packet branch without constructing the cloud client.
- Find the existing main function at the bottom of src/main.rs.
- Replace that function with this version:
#[tokio::main]
async fn main() -> Result<()> {
let scenario = std::env::args()
.nth(1)
.unwrap_or_else(|| "ready".to_string());
let application = application_for(&scenario)?;
let findings = validate_application(&application);
let packet = if findings.is_empty() {
cloud_review(&application).await?
} else {
println!("Cloud model calls: 0");
local_incomplete_packet(&application, findings)
};
println!("{}", serde_json::to_string(&packet)?);
Ok(())
}
How does the gate protect the cloud path?
- The command-line argument selects the synthetic application.
- validate_application runs before either review branch.
- A non-empty findings list prints the zero-call counter.
- The final line serializes the selected packet as JSON.
- Save src/main.rs.
- Confirm RustRover resolves validate_application and local_incomplete_packet inside main.
Is main showing a type mismatch?
- Check that the local branch returns a ReviewPacket.
- Check that the existing ready review path returns the same packet type.
- Still stuck? Help me align the two review branches.
Before you run the missing scenario, do you think the program reaches the cloud reviewer or returns from the local gate?
- Test the deterministic missing-data path by running this command in RustRover's terminal:
cargo run -- missing
What does this command test?
The missing argument selects APP-1001. Rust finds both missing identity verification and insufficient statement history before the cloud branch can run.
You'll see Cloud model calls: 0 before the serialized packet. The packet shows NEEDS_MORE_INFORMATION with the route OPERATIONS_SPECIALIST.
Did the missing scenario fail?
- Check that the command ends with the missing scenario argument.
- Check that missing_application sets identity_verified to false.
- Check that bank_statement_months is set to 1.
- Still stuck? Help me trace why the missing scenario reaches the wrong branch.
That is the control boundary working. An incomplete application now produces a useful human-review packet without spending a model call.
Your dependency plan remains unchanged during this step. Use this reference if RustRover reports a dependency mismatch.
✔️ Awesome, I've got everything!
Your existing Cargo.toml still contains the pinned dependency plan.
ⓧ I'd like to double check the full code
Compare your complete Cargo.toml with this reference.
[package]
name = "loan-review-assistant"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "=1.0.104"
rig = "=0.44.0"
schemars = "=1.2.2"
serde = { version = "=1.0.229", features = ["derive"] }
serde_json = "=1.0.152"
tokio = { version = "=1.53.2", features = ["macros", "rt-multi-thread"] }
[dev-dependencies]
rig = { version = "=0.44.0", features = ["test-utils"] }
The local gate now owns completeness checks and human routing. Next, you'll make the ready path return a typed review packet with bounded model calls and allow-listed policy evidence.
Return a Typed Auditable Review
Your deterministic Rust gate now catches incomplete applications before they reach the cloud. The ready application still needs a controlled review path that downstream code can inspect.
In this step, you will use Rig to return a typed packet grounded in fictional policy. Rust will reject missing evidence or unknown policy identifiers before showing the packet to a human underwriter.
In this step, get ready to:
- Ground the reviewer in a fictional policy bundle.
- Reject empty or unsupported policy evidence.
- Run a bounded typed review for the ready application.
Ground the reviewer in fictional policy
A typed schema controls the shape of the response. A system prompt defines which actions remain outside the assistant's role. The fictional policy gives every supported statement a traceable identifier.
- In RustRover's Explorer sidebar, select src/main.rs.
- Place your cursor below the import block.
- Add the review boundaries by pasting this code:
const SYSTEM_PROMPT: &str = r#"
You are a loan operations review assistant for Northstar Lending, a fictional company.
Never approve, deny, price, rank, or recommend terms for an application.
Use only the supplied synthetic application and fictional policy.
Every policy statement must cite a supplied policy ID.
Do not infer protected characteristics or missing applicant attributes.
Return a review packet for a human reviewer.
"#;
const FICTIONAL_POLICY: &str = r#"
P-101: Before human underwriting, identity_verified must be true and bank_statement_months must be at least 3.
P-202: If validation finds missing or inconsistent application data, route the case to OPERATIONS_SPECIALIST and ask only for the missing information.
P-303: This assistant never approves, denies, prices, or ranks an application. A human underwriter makes every credit decision.
"#;
const POLICY_IDS: [&str; 3] = ["P-101", "P-202", "P-303"];
How do these boundaries work?
- The SYSTEM_PROMPT limits the assistant to review support. Credit decisions remain with a human.
- The FICTIONAL_POLICY supplies the only lending rules the model may cite.
- The POLICY_IDS array creates a fixed allow-list for evidence validation.
- Save src/main.rs.
- Confirm the local route still works by running this command:
cargo run -- missing
What should I see?
You should see Cloud model calls: 0 followed by a packet with NEEDS_MORE_INFORMATION. The route should remain OPERATIONS_SPECIALIST.
Seeing a compiler error?
- Check that each raw string starts with r#" and ends with "#.
- Check that POLICY_IDS contains exactly three quoted identifiers.
- Still stuck? Help me check the policy constants in my Rust file.
Reject unsupported policy evidence
A valid ReviewPacket can still carry fabricated evidence. The evidence gate fails closed when the list is empty or when an entry lacks an allowed policy identifier.
- In src/main.rs, find the closing brace of local_incomplete_packet.
- Add the evidence gate below that function by pasting this code:
fn ensure_policy_evidence(packet: &ReviewPacket) -> Result<()> {
if packet.policy_evidence.is_empty() {
bail!("The model returned no policy evidence.");
}
for evidence in &packet.policy_evidence {
if !POLICY_IDS.iter().any(|policy_id| evidence.contains(policy_id)) {
bail!("Unsupported policy evidence: {evidence}");
}
}
Ok(())
}
What does this gate protect?
- The empty-list check prevents a packet from passing without traceable evidence.
- The loop checks every evidence entry against POLICY_IDS.
- The final Ok(()) is reached only after every entry contains an allowed identifier.
- Save src/main.rs.
- Confirm the evidence gate compiles by running the local scenario:
cargo run -- missing
What does this check prove?
Rust compiles the new evidence gate before running the binary. You should still see Cloud model calls: 0 because incomplete applications never enter the cloud review path.
Evidence gate not compiling?
- Check that the function parameter uses &ReviewPacket.
- Check that the allow-list name is spelled POLICY_IDS.
- Still stuck? Help me debug my Rust evidence allow-list.
Build the bounded typed review path
The ready application can now reach the OpenAI API. Rig asks the provider for a ReviewPacket instead of accepting free-form prose.
The turn ceiling bounds the total model-call budget. The retry allowance gives the provider one chance to correct output that does not match the Rust schema.
- In src/main.rs, place your cursor below ensure_policy_evidence.
- Add the bounded cloud reviewer by pasting this code:
async fn cloud_review(application: &LoanApplication) -> Result<ReviewPacket> {
let model = OpenAI::from_env()?.completion(openai::GPT_5_4_MINI);
let agent = AgentBuilder::new(model).preamble(SYSTEM_PROMPT).build();
let application_json = serde_json::to_string(application)?;
let prompt = format!(
"Review this synthetic application.\n\nFictional policy:\n{FICTIONAL_POLICY}\n\nApplication:\n{application_json}"
);
let packet = agent
.prompt_typed::<ReviewPacket>(prompt)
.max_turns(2)
.retries(1)
.await?
.output;
ensure_policy_evidence(&packet)?;
Ok(packet)
}
How is the model call controlled?
- OpenAI::from_env() reads the configured API key from the environment.
- AgentBuilder applies SYSTEM_PROMPT to every review request.
- prompt_typed::<ReviewPacket> requires output that can be parsed into the Rust schema.
- max_turns(2) limits the full run to two model calls. retries(1) uses that budget for one schema-correction attempt when needed.
- ensure_policy_evidence rejects the packet before the caller can display unsupported evidence.
- Save src/main.rs.
- Confirm the asynchronous reviewer compiles by running the local scenario:
cargo run -- missing
Why use the missing scenario here?
Rust checks cloud_review during compilation. The missing scenario then proves the local gate still prevents a paid request.
Typed reviewer not compiling?
- Check that ReviewPacket derives both Deserialize and JsonSchema.
- Check that the OpenAI import includes both self and OpenAI.
- Still stuck? Help me debug my typed Rig reviewer.
The main function now chooses between two controlled paths. Complete applications reach cloud_review. Incomplete applications continue to use the local packet.
- Find the existing main function near the bottom of src/main.rs.
- Replace the entire function with this code:
#[tokio::main]
async fn main() -> Result<()> {
let scenario = std::env::args()
.nth(1)
.unwrap_or_else(|| "ready".to_string());
let application = application_for(&scenario)?;
let findings = validate_application(&application);
let packet = if findings.is_empty() {
cloud_review(&application).await?
} else {
println!("Cloud model calls: 0");
local_incomplete_packet(&application, findings)
};
println!("{}", serde_json::to_string(&packet)?);
Ok(())
}
How does the final workflow route cases?
- application_for selects the synthetic ready or missing application.
- validate_application checks the application before any provider client is constructed.
- cloud_review runs only when the findings list is empty.
- serde_json::to_string serializes the accepted packet for terminal output.
- Save src/main.rs.
✔️ Awesome, I've got everything!
Your policy constants, evidence gate, bounded cloud reviewer, and final routing function are in place.
ⓧ I'd like to double check the full code
- Compare your complete src/main.rs file with this reference:
use anyhow::{bail, Result};
use rig::prelude::*;
use rig::providers::openai::{self, OpenAI};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
const SYSTEM_PROMPT: &str = r#"
You are a loan operations review assistant for Northstar Lending, a fictional company.
Never approve, deny, price, rank, or recommend terms for an application.
Use only the supplied synthetic application and fictional policy.
Every policy statement must cite a supplied policy ID.
Do not infer protected characteristics or missing applicant attributes.
Return a review packet for a human reviewer.
"#;
const FICTIONAL_POLICY: &str = r#"
P-101: Before human underwriting, identity_verified must be true and bank_statement_months must be at least 3.
P-202: If validation finds missing or inconsistent application data, route the case to OPERATIONS_SPECIALIST and ask only for the missing information.
P-303: This assistant never approves, denies, prices, or ranks an application. A human underwriter makes every credit decision.
"#;
const POLICY_IDS: [&str; 3] = ["P-101", "P-202", "P-303"];
#[derive(Debug, Clone, Serialize)]
struct LoanApplication {
application_id: String,
annual_income_usd: u64,
monthly_debt_usd: u64,
bank_statement_months: u8,
identity_verified: bool,
requested_amount_usd: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
enum ReviewStatus {
ReadyForHumanReview,
NeedsMoreInformation,
SpecialistEscalation,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
enum HumanReviewRoute {
StandardUnderwriter,
OperationsSpecialist,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
struct ReviewPacket {
application_id: String,
review_status: ReviewStatus,
data_quality_findings: Vec<String>,
policy_evidence: Vec<String>,
follow_up_questions: Vec<String>,
human_review_route: HumanReviewRoute,
}
fn ready_application() -> LoanApplication {
LoanApplication {
application_id: "APP-1002".to_string(),
annual_income_usd: 96_000,
monthly_debt_usd: 1_800,
bank_statement_months: 3,
identity_verified: true,
requested_amount_usd: 25_000,
}
}
fn missing_application() -> LoanApplication {
LoanApplication {
application_id: "APP-1001".to_string(),
annual_income_usd: 72_000,
monthly_debt_usd: 2_100,
bank_statement_months: 1,
identity_verified: false,
requested_amount_usd: 18_000,
}
}
fn application_for(scenario: &str) -> Result<LoanApplication> {
match scenario {
"ready" => Ok(ready_application()),
"missing" => Ok(missing_application()),
_ => bail!("Use `ready` or `missing` as the scenario."),
}
}
fn validate_application(application: &LoanApplication) -> Vec<String> {
let mut findings = Vec::new();
if application.annual_income_usd == 0 {
findings.push("annual_income_usd must be greater than zero".to_string());
}
if application.requested_amount_usd == 0 {
findings.push("requested_amount_usd must be greater than zero".to_string());
}
if !application.identity_verified {
findings.push("identity verification is missing".to_string());
}
if application.bank_statement_months < 3 {
findings.push("fewer than three bank-statement months were supplied".to_string());
}
findings
}
fn local_incomplete_packet(
application: &LoanApplication,
findings: Vec<String>,
) -> ReviewPacket {
ReviewPacket {
application_id: application.application_id.clone(),
review_status: ReviewStatus::NeedsMoreInformation,
data_quality_findings: findings,
policy_evidence: vec![
"P-101 requires identity verification and three bank-statement months.".to_string(),
"P-202 routes incomplete data to an operations specialist.".to_string(),
],
follow_up_questions: vec![
"Can the applicant provide identity verification?".to_string(),
"Can the applicant provide the missing bank-statement months?".to_string(),
],
human_review_route: HumanReviewRoute::OperationsSpecialist,
}
}
fn ensure_policy_evidence(packet: &ReviewPacket) -> Result<()> {
if packet.policy_evidence.is_empty() {
bail!("The model returned no policy evidence.");
}
for evidence in &packet.policy_evidence {
if !POLICY_IDS.iter().any(|policy_id| evidence.contains(policy_id)) {
bail!("Unsupported policy evidence: {evidence}");
}
}
Ok(())
}
async fn cloud_review(application: &LoanApplication) -> Result<ReviewPacket> {
let model = OpenAI::from_env()?.completion(openai::GPT_5_4_MINI);
let agent = AgentBuilder::new(model).preamble(SYSTEM_PROMPT).build();
let application_json = serde_json::to_string(application)?;
let prompt = format!(
"Review this synthetic application.\n\nFictional policy:\n{FICTIONAL_POLICY}\n\nApplication:\n{application_json}"
);
let packet = agent
.prompt_typed::<ReviewPacket>(prompt)
.max_turns(2)
.retries(1)
.await?
.output;
ensure_policy_evidence(&packet)?;
Ok(packet)
}
#[tokio::main]
async fn main() -> Result<()> {
let scenario = std::env::args()
.nth(1)
.unwrap_or_else(|| "ready".to_string());
let application = application_for(&scenario)?;
let findings = validate_application(&application);
let packet = if findings.is_empty() {
cloud_review(&application).await?
} else {
println!("Cloud model calls: 0");
local_incomplete_packet(&application, findings)
};
println!("{}", serde_json::to_string(&packet)?);
Ok(())
}
This next check makes a paid OpenAI API request. The included run is small, but each repeat adds usage.
Before you run it, do you think every statement in the packet will carry one of the three fictional policy identifiers?
- Run the ready application through the typed reviewer by running this command:
cargo run -- ready
What should I see?
You should see a JSON packet for APP-1002 with READY_FOR_HUMAN_REVIEW and STANDARD_UNDERWRITER.
Every evidence entry should contain P-101, P-202, or P-303. The packet should contain review support without an approval, denial, price, rank, or credit recommendation.
Ready review not returning a packet?
- Confirm the RustRover terminal inherited OPENAI_API_KEY from the Windows environment.
- Confirm API billing is enabled separately from your ChatGPT subscription.
- Check that each returned evidence entry contains an identifier from POLICY_IDS.
- Still stuck? Help me diagnose my bounded typed review run.
You have turned an unstructured model response into a bounded packet with traceable fictional evidence. Next, you will test the guardrails offline without an API key or paid request.
Test the Guardrails Offline
Your Rust workflow now validates each application before choosing a review path. Validated applications receive typed packets with bounded model calls plus policy evidence checks.
Live responses vary between runs. They also incur paid OpenAI API usage. In this step, you will use a Rig mock to test the controls you own without sending a provider request.
In this step, get ready to:
- Confirm that mock support is limited to development dependencies.
- Test the deterministic validation gate with an incomplete application.
- Parse a typed review packet through a fixed offline response.
Confirm test-only mock support
Cargo can enable features only while it builds tests. This keeps offline mock support available to the test suite without changing the regular CLI dependency path.
- In the RustRover Project tool window, select Cargo.toml.
- Locate the development dependency section near the bottom of the file.
- Confirm that its Rig entry matches this reference:
[dev-dependencies]
rig = { version = "=0.44.0", features = ["test-utils"] }
Why Keep Mocks Test-Only?
The test-utils feature exposes deterministic mock models for tests. The regular dependency still builds the production path that uses the configured provider.
Test the local validation gate
The incomplete application follows a fully deterministic path through validate_application. A focused test proves that both missing identity verification plus insufficient bank statements are caught locally.
- In the Project tool window, select src/main.rs.
- Scroll below the closing brace of main().
- Create the test module by pasting this code below main():
#[cfg(test)]
mod tests {
use super::*;
use rig::test_utils::MockCompletionModel;
#[test]
fn incomplete_application_is_blocked_locally() {
let findings = validate_application(&missing_application());
assert!(findings.iter().any(|finding| finding.contains("identity")));
assert!(findings.iter().any(|finding| finding.contains("bank-statement")));
}
}
What Does This Test Prove?
- The tests module can access the private application helpers through use super::*.
- The incomplete_application_is_blocked_locally test passes the missing scenario directly to validate_application.
- The assertions require a finding about identity plus a finding about bank statements.
- Save src/main.rs.
- Run the current test suite in the RustRover terminal by running:
cargo test
What Should You See?
You should see incomplete_application_is_blocked_locally reported as passing. This result proves that the missing application triggers both deterministic findings.
Local Validation Test Not Passing?
- Check that the test module sits below the closing brace of main().
- Check that both assertion strings match the findings produced by validate_application.
- Still stuck? Help me debug the deterministic validation test.
Parse a typed packet offline
Typed parsing also needs a stable test input. MockCompletionModel::text(...) returns fixed JSON from memory so the test can exercise the Rig schema path without constructing an OpenAI client.
- In src/main.rs, locate the final closing brace of the tests module.
- Insert this asynchronous test directly above that closing brace:
#[tokio::test]
async fn typed_packet_is_parseable_offline() -> Result<()> {
let model = MockCompletionModel::text(
r#"{"application_id":"APP-1002","review_status":"READY_FOR_HUMAN_REVIEW","data_quality_findings":[],"policy_evidence":["P-101 documentation is present","P-303 requires a human decision"],"follow_up_questions":[],"human_review_route":"STANDARD_UNDERWRITER"}"#,
);
let agent = AgentBuilder::new(model).preamble(SYSTEM_PROMPT).build();
let packet = agent
.prompt_typed::<ReviewPacket>("Return the scripted review packet.")
.await?
.output;
assert_eq!(packet.review_status, ReviewStatus::ReadyForHumanReview);
assert_eq!(packet.human_review_route, HumanReviewRoute::StandardUnderwriter);
ensure_policy_evidence(&packet)?;
Ok(())
}
What Does the Mock Check?
- The fixed JSON uses application ID APP-1002 with status READY_FOR_HUMAN_REVIEW.
- The typed prompt parses that JSON into ReviewPacket.
- The assertions require the ready status plus the STANDARD_UNDERWRITER route.
- The final evidence check accepts only citations from the fictional allow-list.
- Save src/main.rs.
✔️ Awesome, I've got everything!
Your manifest keeps mock support in development dependencies. Your source file now contains both offline guardrail tests.
ⓧ I'd like to double check the full code
- Compare your Cargo.toml with this complete file:
[package]
name = "loan-review-assistant"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "=1.0.104"
rig = "=0.44.0"
schemars = "=1.2.2"
serde = { version = "=1.0.229", features = ["derive"] }
serde_json = "=1.0.152"
tokio = { version = "=1.53.2", features = ["macros", "rt-multi-thread"] }
[dev-dependencies]
rig = { version = "=0.44.0", features = ["test-utils"] }
What to Check in the Manifest
The regular dependency list contains the crates used by the CLI. The development dependency enables Rig's mock support only for tests.
- Compare your src/main.rs with this complete file:
use anyhow::{bail, Result};
use rig::prelude::*;
use rig::providers::openai::{self, OpenAI};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
const SYSTEM_PROMPT: &str = r#"
You are a loan operations review assistant for Northstar Lending, a fictional company.
Never approve, deny, price, rank, or recommend terms for an application.
Use only the supplied synthetic application and fictional policy.
Every policy statement must cite a supplied policy ID.
Do not infer protected characteristics or missing applicant attributes.
Return a review packet for a human reviewer.
"#;
const FICTIONAL_POLICY: &str = r#"
P-101: Before human underwriting, identity_verified must be true and bank_statement_months must be at least 3.
P-202: If validation finds missing or inconsistent application data, route the case to OPERATIONS_SPECIALIST and ask only for the missing information.
P-303: This assistant never approves, denies, prices, or ranks an application. A human underwriter makes every credit decision.
"#;
const POLICY_IDS: [&str; 3] = ["P-101", "P-202", "P-303"];
#[derive(Debug, Clone, Serialize)]
struct LoanApplication {
application_id: String,
annual_income_usd: u64,
monthly_debt_usd: u64,
bank_statement_months: u8,
identity_verified: bool,
requested_amount_usd: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
enum ReviewStatus {
ReadyForHumanReview,
NeedsMoreInformation,
SpecialistEscalation,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
enum HumanReviewRoute {
StandardUnderwriter,
OperationsSpecialist,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
struct ReviewPacket {
application_id: String,
review_status: ReviewStatus,
data_quality_findings: Vec<String>,
policy_evidence: Vec<String>,
follow_up_questions: Vec<String>,
human_review_route: HumanReviewRoute,
}
fn ready_application() -> LoanApplication {
LoanApplication {
application_id: "APP-1002".to_string(),
annual_income_usd: 96_000,
monthly_debt_usd: 1_800,
bank_statement_months: 3,
identity_verified: true,
requested_amount_usd: 25_000,
}
}
fn missing_application() -> LoanApplication {
LoanApplication {
application_id: "APP-1001".to_string(),
annual_income_usd: 72_000,
monthly_debt_usd: 2_100,
bank_statement_months: 1,
identity_verified: false,
requested_amount_usd: 18_000,
}
}
fn application_for(scenario: &str) -> Result<LoanApplication> {
match scenario {
"ready" => Ok(ready_application()),
"missing" => Ok(missing_application()),
_ => bail!("Use `ready` or `missing` as the scenario."),
}
}
fn validate_application(application: &LoanApplication) -> Vec<String> {
let mut findings = Vec::new();
if application.annual_income_usd == 0 {
findings.push("annual_income_usd must be greater than zero".to_string());
}
if application.requested_amount_usd == 0 {
findings.push("requested_amount_usd must be greater than zero".to_string());
}
if !application.identity_verified {
findings.push("identity verification is missing".to_string());
}
if application.bank_statement_months < 3 {
findings.push("fewer than three bank-statement months were supplied".to_string());
}
findings
}
fn local_incomplete_packet(
application: &LoanApplication,
findings: Vec<String>,
) -> ReviewPacket {
ReviewPacket {
application_id: application.application_id.clone(),
review_status: ReviewStatus::NeedsMoreInformation,
data_quality_findings: findings,
policy_evidence: vec![
"P-101 requires identity verification and three bank-statement months.".to_string(),
"P-202 routes incomplete data to an operations specialist.".to_string(),
],
follow_up_questions: vec![
"Can the applicant provide identity verification?".to_string(),
"Can the applicant provide the missing bank-statement months?".to_string(),
],
human_review_route: HumanReviewRoute::OperationsSpecialist,
}
}
fn ensure_policy_evidence(packet: &ReviewPacket) -> Result<()> {
if packet.policy_evidence.is_empty() {
bail!("The model returned no policy evidence.");
}
for evidence in &packet.policy_evidence {
if !POLICY_IDS.iter().any(|policy_id| evidence.contains(policy_id)) {
bail!("Unsupported policy evidence: {evidence}");
}
}
Ok(())
}
async fn cloud_review(application: &LoanApplication) -> Result<ReviewPacket> {
let model = OpenAI::from_env()?.completion(openai::GPT_5_4_MINI);
let agent = AgentBuilder::new(model).preamble(SYSTEM_PROMPT).build();
let application_json = serde_json::to_string(application)?;
let prompt = format!(
"Review this synthetic application.\n\nFictional policy:\n{FICTIONAL_POLICY}\n\nApplication:\n{application_json}"
);
let packet = agent
.prompt_typed::<ReviewPacket>(prompt)
.max_turns(2)
.retries(1)
.await?
.output;
ensure_policy_evidence(&packet)?;
Ok(packet)
}
#[tokio::main]
async fn main() -> Result<()> {
let scenario = std::env::args()
.nth(1)
.unwrap_or_else(|| "ready".to_string());
let application = application_for(&scenario)?;
let findings = validate_application(&application);
let packet = if findings.is_empty() {
cloud_review(&application).await?
} else {
println!("Cloud model calls: 0");
local_incomplete_packet(&application, findings)
};
println!("{}", serde_json::to_string(&packet)?);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rig::test_utils::MockCompletionModel;
#[test]
fn incomplete_application_is_blocked_locally() {
let findings = validate_application(&missing_application());
assert!(findings.iter().any(|finding| finding.contains("identity")));
assert!(findings.iter().any(|finding| finding.contains("bank-statement")));
}
#[tokio::test]
async fn typed_packet_is_parseable_offline() -> Result<()> {
let model = MockCompletionModel::text(
r#"{"application_id":"APP-1002","review_status":"READY_FOR_HUMAN_REVIEW","data_quality_findings":[],"policy_evidence":["P-101 documentation is present","P-303 requires a human decision"],"follow_up_questions":[],"human_review_route":"STANDARD_UNDERWRITER"}"#,
);
let agent = AgentBuilder::new(model).preamble(SYSTEM_PROMPT).build();
let packet = agent
.prompt_typed::<ReviewPacket>("Return the scripted review packet.")
.await?
.output;
assert_eq!(packet.review_status, ReviewStatus::ReadyForHumanReview);
assert_eq!(packet.human_review_route, HumanReviewRoute::StandardUnderwriter);
ensure_policy_evidence(&packet)?;
Ok(())
}
}
What to Check in the Source File
The file should end with one tests module. That module should contain both offline test functions.
Before you run the full suite, do you expect either test to read the API key or contact the provider?
- Run the complete offline test suite in the RustRover terminal by running:
cargo test
What Should You See?
You should see incomplete_application_is_blocked_locally plus typed_packet_is_parseable_offline reported as passing.
The tests complete without using OPENAI_API_KEY. No provider request is made because neither test calls cloud_review.
Full Test Suite Not Passing?
- Check that Cargo.toml enables test-utils in the development dependency section.
- Check that the fixed JSON contains every field required by ReviewPacket.
- Still stuck? Help me debug the offline guardrail suite.
That is the safety case backed by repeatable tests. Incomplete applications stay local. Typed packets can be checked without spending money or contacting the provider.
Secret mission
Reject a Fabricated Policy Citation
A typed packet can still carry a policy citation that looks credible but does not exist. Add an offline adversarial test that cites BANK-999 and prove your policy allow-list rejects it.
Clean Up Your Resources
Clean Up Your Resources
Your Rust project runs locally without recurring compute charges. Choose Keep, Pause, or Delete based on whether you want to make more usage-billed OpenAI API requests.
Cost warning
The loan-review-assistant folder is free to keep. The API key has no hourly charge.
Every live request creates usage-billed activity.
- Choose the Pause or Delete option if you do not plan to make more paid requests.
Resources you used:
- The local loan-review-assistant Cargo project.
- The OpenAI API key used by the live ready scenario.
Keep everything running
No action needed. Choose this if you plan to keep extending the review assistant.
- Retain the loan-review-assistant folder on your machine.
- Keep the current OpenAI API key active only while you need live ready runs.
- Review the OpenAI API usage dashboard to confirm your current spend before each additional live run.
Pause - I'll come back to this later
Pause live model access while preserving your local project for offline work.
- Stop running the live ready scenario.
- Find the current project key on the OpenAI dashboard's API key management page.
- Use the key's revoke control to revoke it.
- Confirm the page shows that the key can no longer make requests.
The paid path is now closed. Your missing scenario plus offline tests remain available without API access.
Delete - I don't want to use this again
Permanent deletion removes your local code. Revoking cloud access first prevents future requests from using the current key.
- Find the current project key on the OpenAI dashboard's API key management page.
- Use the key's revoke control to revoke it.
- Confirm the page shows that the key can no longer make requests.
Your key can no longer make billed requests. The local loan-review-assistant folder is the only listed resource left to remove.
- Close RustRover to release the project files.
- Use the Windows file manager to locate the loan-review-assistant folder created in Step 2.
- Select the loan-review-assistant folder.
- Delete the selected folder.
- Empty the Windows recycle location to remove the folder permanently.
- Return to the folder's former location to confirm loan-review-assistant no longer appears.
- Review the final usage total in the OpenAI API billing dashboard.
Nice Work!
Nice Work!
Mission accomplished! Your Rust workflow uses Rig to prepare auditable review packets for a human underwriter.
You've learned how to:
- Run a Rust CLI that prepares structured review packets for a human underwriter.
- Use a deterministic validation gate to route incomplete applications locally with zero cloud-model calls.
- Constrain Rig to produce a typed ReviewPacket under a two-turn ceiling with policy allow-list enforcement.
- Complete the Secret Mission by proving that BANK-999 fails the policy allow-list in an offline adversarial test.
Ready to quiz yourself?