Adjudicating Credit Applications with a Priority-Ordered Mutex Group
A pattern that routes each credit application to one of approve, decline, or refer, and records which rule produced the decision.
The problem
A lender decides each application three ways. Approve, and the line opens. Decline, and it does not. Refer, and a human underwriter looks closer. The decision reads a few signals — a credit score, annual income, a debt-to-income ratio — and the thresholds that turn those signals into an outcome are policy. Risk owns them, revisits them, and changes them on risk's schedule, not engineering's.
This is the decision after the gate. Enforcing KYC-tiered transfer limits answered a binary question: may this transfer proceed. Adjudication answers a different one — of several possible outcomes, which one does this application receive, and on what grounds.
In code, each threshold is a constant and the outcome is a branch. The trouble is that the signals overlap. A strong score and a high debt ratio can both be true of the same applicant, and the two point at opposite outcomes. The branch that runs first wins, and which branch runs first is an accident of the order the method happens to be written in. Put the approve check above the debt-ratio check and an applicant with a 760 score and a 55% debt ratio is approved before the ceiling that should have stopped them is ever evaluated. Nobody decided that. It is the order, standing in for a policy no one wrote down.
There is a second cost, and it lands later. When an application is declined, the applicant is owed a reason, and the lender has to be able to produce, months on, which rule decided the case and why the alternatives did not. A decline reconstructed by hand from a log line is a reason you cannot stand behind.
The question this pattern answers: how does a team route every application to exactly one of approve, decline, or refer, and answer months later which rule decided a given case, and why the others lost.
The naive approach
The first version puts the thresholds next to the checks, as constants in the decision service.
public class CreditDecisionService {
// Thresholds — owned by risk.
private static final int SCORE_FLOOR = 580;
private static final int SCORE_PRIME = 720;
private static final BigDecimal DTI_CEILING = new BigDecimal("0.50");
private static final BigDecimal MIN_INCOME = new BigDecimal("40000");
public Decision adjudicate(Application app) {
int score = app.getCreditScore();
BigDecimal dti = app.getDtiRatio();
BigDecimal income = app.getAnnualIncomeUsd();
// The order of these checks is the policy.
// But the order was never decided — it is just how the method got typed.
if (score >= SCORE_PRIME && income.compareTo(MIN_INCOME) >= 0) {
return Decision.APPROVE;
}
if (dti.compareTo(DTI_CEILING) > 0) {
return Decision.DECLINE;
}
if (score < SCORE_FLOOR) {
return Decision.DECLINE;
}
return Decision.REFER;
}
}
It works on the applications the author pictured. Three problems follow from the structure of the method, not from the numbers in it.
- The order of the checks is the policy, and the policy is invisible. With the approve branch first, an applicant scoring 760 with a 55% debt ratio returns
APPROVEand the debt-ratio ceiling is never reached. Move the ceiling check up and the same applicant declines. The outcome flipped, no test turned red, and the change that flipped it was a reordering nobody recorded as a decision. - Changing a threshold is a release. Risk sets effective dates; constants ship on deploy trains. The threshold a given applicant was measured against is whatever was compiled that week, and the history of the threshold is the git history of the service.
- A decision leaves no queryable record. "Why did this application decline" is a log line, if it was logged at all. "Every application the debt-ratio ceiling stopped in May" is a log-archaeology project. An adverse-action reason that has to be reconstructed by hand is a reason the lender cannot trust.
Defining the pattern
The fix is structural. Model each outcome as its own rule, put every rule that assigns a decision into one EXCLUSIVE mutex group, and let rule priority pick the single winner when more than one matches.
Adjudication maps onto three LexQ concepts.
- Fact: the applicant's signals as the engine receives them.
creditScore,annualIncomeUsd,dtiRatio. - Rule: one rule per outcome. Each has a condition and a
SET_FACTaction that writes the decision. The decision is a category, not an arithmetic result, so the action sets a string rather than mutating a number. - Mutex Group: the field that turns a list of outcomes into a competition with exactly one winner.
This is the structural difference from the KYC gate. There, an account holds exactly one verification level, so the conditions partition the input and at most one rule can match — no mutex is needed. Here the signals overlap: a knockout on debt ratio and an approval on score can both be true of the same applicant. Two rules match, they reach opposite decisions, and something has to choose one and record what happened to the other. That something is the mutex group. For the mechanics of mutex groups on their own, see resolving VIP tier discount stacking; this pattern applies them to a three-way decision.
Every decision rule carries the same mutexGroup key, credit-decision, with mutexMode set to EXCLUSIVE — only the winning rule's action runs. mutexStrategy is HIGHEST_PRIORITY, so among the matching rules the one with the smallest priority number wins.
The engine assigns priority automatically as 1..N in creation order within a version. Reorder (drag in the console) is the only way to change it, and the numbering runs as one sequence across the whole version, so a rule's number describes its place in the version rather than its rank inside the group. The decline rules are created first, so they sit at the top and carry the smallest priority. When a knockout and an approval both match, the knockout wins — which is the correct underwriting logic: a debt ratio over the ceiling disqualifies regardless of how strong the score is.
The highest-priority rule is the debt-ratio knockout.
{
"name": "Decline — DTI above 0.50",
"condition": {
"type": "GROUP",
"operator": "AND",
"children": [
{
"type": "SINGLE",
"field": "dtiRatio",
"operator": "GREATER_THAN",
"value": 0.50,
"valueType": "NUMBER"
}
]
},
"actions": [
{
"type": "SET_FACT",
"parameters": { "targetVar": "creditDecision", "value": "DECLINED" }
},
{
"type": "SET_FACT",
"parameters": { "targetVar": "decisionReason", "value": "Debt-to-income ratio above 0.50" }
}
],
"mutexGroup": "credit-decision",
"mutexMode": "EXCLUSIVE",
"mutexStrategy": "HIGHEST_PRIORITY",
"mutexLimit": 1,
"isEnabled": true
}
The score-floor decline is the same shape with a single creditScore LESS_THAN 580 condition, writing DECLINED and the reason Score below 580. It sits second.
The approve rule reads two signals at once.
{
"name": "Approve — prime",
"condition": {
"type": "GROUP",
"operator": "AND",
"children": [
{
"type": "SINGLE",
"field": "creditScore",
"operator": "GREATER_THAN_OR_EQUAL",
"value": 720,
"valueType": "NUMBER"
},
{
"type": "SINGLE",
"field": "annualIncomeUsd",
"operator": "GREATER_THAN_OR_EQUAL",
"value": 40000,
"valueType": "NUMBER"
}
]
},
"actions": [
{
"type": "SET_FACT",
"parameters": { "targetVar": "creditDecision", "value": "APPROVED" }
},
{
"type": "SET_FACT",
"parameters": { "targetVar": "decisionReason", "value": "Score 720+ with income 40000+" }
}
],
"mutexGroup": "credit-decision",
"mutexMode": "EXCLUSIVE",
"mutexStrategy": "HIGHEST_PRIORITY",
"mutexLimit": 1,
"isEnabled": true
}
The refer rule is last, and it is the one that keeps the score bands gap-free. It matches the near-prime band and the one case the approve rule leaves open: a prime score with income below the floor. Its condition is an OR of two AND-groups.
{
"type": "GROUP",
"operator": "OR",
"children": [
{
"type": "GROUP",
"operator": "AND",
"children": [
{
"type": "SINGLE",
"field": "creditScore",
"operator": "GREATER_THAN_OR_EQUAL",
"value": 580,
"valueType": "NUMBER"
},
{
"type": "SINGLE",
"field": "creditScore",
"operator": "LESS_THAN",
"value": 720,
"valueType": "NUMBER"
}
]
},
{
"type": "GROUP",
"operator": "AND",
"children": [
{
"type": "SINGLE",
"field": "creditScore",
"operator": "GREATER_THAN_OR_EQUAL",
"value": 720,
"valueType": "NUMBER"
},
{
"type": "SINGLE",
"field": "annualIncomeUsd",
"operator": "LESS_THAN",
"value": 40000,
"valueType": "NUMBER"
}
]
}
]
}
With these four rules, every applicant whose facts are present matches at least one rule. A debt ratio over the ceiling matches the knockout; below it, a sub-floor score matches the score decline, the near-prime band and the thin-income prime case match refer, and a prime score with sufficient income matches approve. The constraint "exactly one decision per application" is now stated in a field, mutexMode, not implied by the order four if-blocks happen to sit in.
The thresholds are now data. Changing the income floor is editing one value in a draft version, and "what was the prime-score threshold on May 14" is answered by version history, not git blame.
Impact Simulation strategy
Risk lowers the debt-ratio ceiling from 0.50 to 0.45. Before that value reaches a live application, the change has a measurable blast radius: the share of real applications whose decision flips, and the direction each one flips. Duplicate the live version, edit the one threshold, and the result is a candidate — the Target Version, in the console's terms — that carries no traffic.
This question is count-shaped, not sum-shaped, so the run needs no metric fact — that field is optional. includeRuleStats is enough. It reports each rule's match rate, and the difference between the candidate's match rates and the baseline's is exactly the decision mix moving: how many more applications the tightened knockout declines, and which rules they were drawn away from.
lexq analytics simulation start --json '{
"policyVersionId": "<candidate-version-id>",
"dataset": {
"type": "HISTORICAL",
"source": "EXECUTION_LOGS",
"from": "2026-05-01",
"to": "2026-05-31"
},
"options": {
"baselinePolicyVersionId": "<baseline-version-id>",
"includeRuleStats": true,
"maxRecords": 50000
}
}'
Two things have to hold before this ships. The knockout's match rate moves by candidate minus baseline, and that shift has to sit inside the range risk agreed to absorb; the number is added decline volume, and every one of those declines is an adverse-action notice the lender has to send. The rules the edit left alone have to hold their match rates, because the change claimed to move one threshold and the per-rule statistics are what verify the claim. The run replays logged applications and writes nothing back.
If no production traffic exists yet, upload a representative dataset instead — applications spanning every band, with scores and ratios clustered around each threshold so the flips are guaranteed to be in the run — and run the same comparison.
Decision Trace output
Run the case where the signals collide: a 760 score, 120,000 income, and a 0.55 debt ratio, with Dry Run.
{
"result": "SUCCESS",
"data": {
"inputFacts": {
"creditScore": 760,
"annualIncomeUsd": 120000,
"dtiRatio": 0.55
},
"mutatedFacts": {},
"generatedVariables": {
"creditDecision": "DECLINED",
"decisionReason": "Debt-to-income ratio above 0.50"
},
"executionTraces": [ ... ],
"decisionTraces": [
{
"ruleName": "Decline — DTI above 0.50",
"status": "SELECTED",
"reasonCode": "FINAL_WINNER",
"reasonDetail": null
},
{
"ruleName": "Decline — score below 580",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
},
{
"ruleName": "Approve — prime",
"status": "BLOCKED",
"reasonCode": "MUTEX_PRIORITY_LOST",
"reasonDetail": "Winner=[Decline — DTI above 0.50], Strategy=HIGHEST_PRIORITY"
},
{
"ruleName": "Refer — manual review",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
}
]
}
}
generatedVariables holds the decision the application reads: creditDecision is DECLINED, and decisionReason carries the string the rule wrote. mutatedFacts is empty — that map records facts that arrived in the input and had their value changed, and SET_FACT writes a new fact rather than mutating an existing one, so the decision lands in generatedVariables instead. It is the same place the KYC gate's isBlocked arrives, and for the same reason. No __delta appears: that key is generated only for a numeric fact a rule moved, and a categorical decision moves no number.
The trace is where the decision is explained. The approve rule matched — the 760 score and 120,000 income satisfy its condition — and then lost the mutex competition to the higher-priority knockout, recorded as BLOCKED / MUTEX_PRIORITY_LOST with the winner named in reasonDetail. That recorded loss is the adverse-action reason: not "the score was ignored" but "the score qualified, and a debt ratio over the ceiling outranked it." The SELECTED rule's name is the reason the lender hands the applicant; the full ordering (what won, what lost, and on what grounds) is the answer the lender keeps for the audit. Which condition each rule was evaluated against appears in the Execution Trace table in the Dry Run view above.
Run the same applicant with the debt ratio at 0.30 instead, and the collision is gone.
{
"result": "SUCCESS",
"data": {
"inputFacts": {
"creditScore": 760,
"annualIncomeUsd": 120000,
"dtiRatio": 0.30
},
"mutatedFacts": {},
"generatedVariables": {
"creditDecision": "APPROVED",
"decisionReason": "Score 720+ with income 40000+"
},
"executionTraces": [ ... ],
"decisionTraces": [
{
"ruleName": "Decline — DTI above 0.50",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
},
{
"ruleName": "Decline — score below 580",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
},
{
"ruleName": "Approve — prime",
"status": "SELECTED",
"reasonCode": "FINAL_WINNER",
"reasonDetail": null
},
{
"ruleName": "Refer — manual review",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
}
]
}
}
With the knockout no longer matching, the approve rule is the only rule that fires. One SELECTED, three NO_MATCH, and creditDecision reads APPROVED. The same trace structure carries a clean approval and a contested decline; only which rule holds SELECTED changes.
Edge cases
The pattern is the single decision point, not the specific thresholds. Several cases sitting next to it need a deliberate answer.
- An applicant exactly on a threshold.
GREATER_THANlets a debt ratio of exactly 0.50 pass the knockout — "ceiling" read as the highest ratio still allowed. If the policy reads "decline at and above 0.50," the operator isGREATER_THAN_OR_EQUAL. The same choice sits on the 720 score boundary. Decide once, write it into the operator, and the recorded match expression shows which reading is live. - A missing fact. A payload without
dtiRatiodoes not adjudicate quietly. The engine throws, namesdtiRatio, and puts nothing in its place. An errored execution is an application that was never adjudicated, and the caller has to leave it there rather than resolve it as a silent decline or a silent approval. - The reason an applicant is owed. Every decision writes
decisionReason, and theSELECTEDrule's name records which rule produced the outcome. The two are separable on purpose: the rule name is the internal record, anddecisionReasonis the string phrased for the applicant. Both are captured per decision, so an adverse-action notice is a field read, not a reconstruction. - A decision that depends on history. This pattern decides one application from a snapshot of facts. A rule that depends on prior behavior — a score trending down across pulls, or a third application this month — is a different problem. The engine keeps no state between executions, so the running count or the trend has to arrive as an input fact, maintained on your side along with its time window. Different pattern, different failure modes, outside this one's scope.
- An application that matches no decision rule. Refer is the explicit "send this to a human" outcome for the near-prime band and the thin-income prime case — it is a decision, not a fallthrough. If the bands are ever edited into a gap, an application landing in that gap matches nothing. The engine does not error. It returns a successful execution in which every rule carries a
NO_MATCHdecision trace andcreditDecisionis absent fromgeneratedVariables. The trace is honest about what happened, but nothing is approved, declined, or referred, so the caller has to treat a missingcreditDecisionas not adjudicated rather than as any outcome. To make a gap announce itself instead, add a catch-all rule that writes the refer outcome, created last so it carries the largest priority number. It has to join the samecredit-decisiongroup with the samemutexModeandmutexStrategy. A catch-all left outside the group never competes: it matches every application and overwrites whatever the group decided, so an applicant knocked out on debt ratio comes back as a refer.
Production rollout
With both simulation checks clear, Deploy puts the new ceiling in front of live applications. The snapshot of the four rules is hashed and integrity-verified at deploy time, and the record names who published which version and when.
A threshold change ships to all applications at once, not through a gradual traffic split. An A/B test would route some applications through the candidate and the rest through the previous thresholds, so two applicants with the same score, income, and debt ratio could receive different decisions depending only on which bucket they fell in. A discount tolerates a partial rollout; a lending decision does not, because deciding identical facts two different ways is the failure, not the safeguard. The confidence to deploy comes from the Impact Simulation, not from a canary. What deploy adds is live confirmation — watch the live decision mix per rule against the simulated match rates.
Two observations call for an immediate rollback:
- The tightened ceiling knocks out live applications at a rate the simulation did not project. The live applicant population differs from the historical window, and the blast radius that was signed off no longer holds.
- Decisions appear in bands the simulation reported untouched. Scores, incomes, and debt ratios arriving now are not distributed like the applications the candidate was measured on.
When comparing two versions on the same applications is genuinely needed, that is an Impact Simulation with baselinePolicyVersionId set, not an A/B test: both versions are measured over the same historical window, and no live decision is split. Rolling back restores the previous version, putting the 0.50 ceiling back in force, and the rollback itself gets a deployment record, so the reversal is as traceable as the change it undid. Once a version is live, the per-rule statistics are the standing answer to "how many applications declined on debt ratio in May" — a query over execution history scoped to a rule and a version, not a log-archaeology project. And "the prime-score threshold in force on May 14" is the version history plus the deployment record. If adjudication slows after the deploy, the question is which of the four rules absorbed the time. Isolating a slow rule on a hot path reads the per-rule profile.
See how LexQ works for yourself in the playground.
Ready to move decisions out of your deploy pipeline?
Try LexQ free — no credit card required.
Start Free