Skip to content

Loan affordability ​

A personal-loan application decided by rules, a model and an underwriter together: the applicant answers a short flow, including what the loan is for in their own words; five rules check identity, repayments against income, the amount, repeat applications and, through a model, whether those words fit the purpose they chose; and borderline files go to an underwriter. Pack: examples/policies/loan-affordability/.

A pattern for expressing a lending & credit policy, not credit or compliance guidance: the thresholds, lists and fields are placeholders chosen to make the example run; a real policy comes from the business's own rules. The model reads no credit data, makes no credit decision and never declines an application: when it is not sure the words fit, it refers the file to an underwriter.

The decision it automates ​

Whether to offer the loan, refer it to an underwriter, or decline it, at the moment the application is submitted.

Data expected ​

  • The applicant as a subject (kind user) with identityVerified, the result of your identity check, written by your backend before it starts the run.
  • The flow's answers under submission: amount (USD), purpose (chosen from a list), purposeDetails (required text, 500 characters at most: what the loan is for, in the applicant's own words), employment, employerName, monthlyIncome (USD) and an optional pay stub (payStub).
  • loan.application events posted through POST /events for each application, so the velocity rule can count them.
  • An install named chatgpt (below).

Steps and rules ​

StepTypeWhy
collectwait_for_collectionThe loan-application flow; employed applicants are asked for their employer and a pay stub.
rulesevaluate_rulesThe loan-affordability rule set under max_severity: a failed block rule declines, a failed warn rule refers; its purpose rule asks the model.
routebranchrules.outcome == manual_review opens a case.
reviewcreate_caseloan_underwriting, 48-hour SLA.
decideemit_decisionFrom the rules, or the reviewer's decision.

The rule set loan-affordability (useCase: underwriting) uses max_severity: a failed block rule rejects, a failed warn rule sends the run to review, a rule that errors sends it to review too, and the weights add up to the score.

RuleTypeSeverity, weightFails when
loan_identity_verifiedcomparisonblock, 100subject.identityVerified is not true
loan_affordabilityexpressionwarn, 40amount / 36 is more than 35% of monthlyIncome
loan_amount_referralscore thresholdwarn, 20amount is above $25,000
loan_repeat_applicationsvelocitywarn, 20more than one loan.application event in 30 days
loan_purpose_consistentapp (chatgpt's decide)warn, 20the model is less than 80% sure the applicant's words describe the purpose chosen (answers.fits_purpose.value is false)

The model step ​

The model is asked from inside the rule set: loan_purpose_consistent is an app rule that calls chatgpt's decide with one yes-or-no question about one text, which its stateTemplate writes from the two answers the applicant gave (Asking for decisions):

text
A personal-loan applicant chose this purpose from a list: <<purpose>> (car: buying a car; home:
improving their home; consolidation: paying off existing debts; other: something else).
In their own words, the loan is for:
<<purposeDetails>>
QuestionTypeAsks
fits_purposeyes or no, threshold 0.8Do the applicant's words describe the purpose they chose from the list (<<purpose>>)?

Its criteria spell out both answers. Yes: "The words are about the chosen purpose; for other, they name a specific purpose of any kind." No: "The words are about a different purpose than the one chosen, or are too vague to tell." The rule reads answers.fits_purpose.value and fails on false (failWhen: false), so a file the model is not at least 80% sure about is referred, and since the rule is warn, its no can only refer: identity alone declines. A mismatch between the purpose chosen and the words is a question for the underwriter, not a finding.

The words are the applicant's own text, and an applicant can write anything in them: the question's instructions tell the model to read them as text and never follow instructions in them (Prompt injection), and the most crafted words win is one reason fewer on a file the other rules decide. Lending is regulated: whether applicants are told that a model reads their words is the lender's call.

What the vendor sees ​

Through the install, OpenAI receives the question with its criteria and the state: the purpose the applicant chose and their own words on it. Nothing else reaches it: not the name, the employer, the income, the amount or any credit data. The sample install answers decisions through OpenAI's Responses API, which it calls with storing off; an install switched to OpenAI's Decisions API (decideEngine: native) sends the same content under OpenAI's terms for that API (Data protection).

The chatgpt install ​

The pack declares the install its rule calls, "installs": { "chatgpt": "chatgpt" }: an install named chatgpt of the platform's chatgpt app, ChatGPT (OpenAI API). An install is never part of a pack. After the import, the console's import result installs chatgpt in one click, with a preset configuration (an OpenAI connection through the Responses API, the console's first suggested model, OpenAI's hosts approved) and a sample key in place of yours; store your OpenAI API key on the install's page (Configuration › Keys › openaiApiKey). Through the API, create it as Installing a model app shows, named chatgpt.

Until you replace the sample key, and whenever the model call fails (an outage that outlasts the retries, an exhausted quota, a refusal), the purpose rule errors instead of answering. A model rule does not fail the run: under max_severity a rule that errors refers the application, so every application the identity check does not decline opens a loan_underwriting case, with the error among its reasons. OpenAI's answer shows in the invocation record and under the run's app calls.

Every model app answers the same decide, so an install of claude, gemini, jev or openai-compatible named chatgpt serves the pack as well.

What a reviewer sees ​

A loan_underwriting case with the application, the optional pay stub and each rule's result. When the model did not read the words as the purpose chosen, the purpose rule's reason says so (app chatgpt.decide returned answers.fits_purpose.value=false), and Vendor checks lists the call. The underwriter's decision and note become the decision and its reasons.

How to adapt it ​

Read the term from the flow instead of assuming 36 months, add a call_app step for your credit bureau through an http-api install for its API, with rules over its answer (Adding a vendor step), and backtest a new affordability limit over past applications before publishing it; a backtest returns app rules as skipped, so it never asks the model again (Rules). A document check on the pay stub by a vendor sends it through an http-api install whose documents are on, with start when the vendor answers later (Documents).

Move the model's 0.8 in the question's threshold. Give the rule a fallback on another vendor's install (config.fallback, with the same question), so an OpenAI outage answers rather than refers (Falling back to another install).

Run it ​

bash
pnpm policy:import examples/policies/loan-affordability --publish

Then create the chatgpt install and store your key (above). The check runs each fixture against the model stub (scripts/ai-stub.mjs), whose answer a cue word in the applicant's words picks ([[stub:low]], [[stub:high]]):

  • expect.json: $12,000 for a car on an income of $900 a month, in words about paying off two credit cards. Repayments of $333 a month are above the $315 that 35% allows, so the affordability rule fails, and the model reads the words as another purpose (0.05), so the purpose rule fails too; a loan_underwriting case opens, the check approves it and the run completes with a manual approve.
  • expect-approved.json: $12,000 for a used pickup truck on an income of $3,200 a month. Every rule passes, the model reading the words as the car purpose chosen (0.95), and the run completes with an automated approve, without a case.

Released under the Apache-2.0 License.