LexQLexQ
Back to patterns
FintechApprovalOperationsTrustIntermediate

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.

Sanghyun Park·June 24, 202615 min read15 min

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. The flaw is structural, and it shows up in three places.

  • 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 APPROVE and 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.

In LexQ terms, this maps to three concepts.

  • Fact: the input the engine reads. credit_score, annual_income_usd, dti_ratio.
  • Rule: one rule per outcome. Each has a condition and a SET_FACT action 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.

priority is not a value set when a rule is created. It is an order assigned automatically as 1..N within a version, changed only by reorder (drag in the console), and it is a single sequence across the whole version, not a 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": "dti_ratio",
        "operator": "GREATER_THAN",
        "value": 0.50,
        "valueType": "NUMBER"
      }
    ]
  },
  "actions": [
    {
      "type": "SET_FACT",
      "parameters": { "key": "credit_decision", "value": "DECLINED" }
    },
    {
      "type": "SET_FACT",
      "parameters": { "key": "decision_reason", "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 credit_score 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": "credit_score",
        "operator": "GREATER_THAN_OR_EQUAL",
        "value": 720,
        "valueType": "NUMBER"
      },
      {
        "type": "SINGLE",
        "field": "annual_income_usd",
        "operator": "GREATER_THAN_OR_EQUAL",
        "value": 40000,
        "valueType": "NUMBER"
      }
    ]
  },
  "actions": [
    {
      "type": "SET_FACT",
      "parameters": { "key": "credit_decision", "value": "APPROVED" }
    },
    {
      "type": "SET_FACT",
      "parameters": { "key": "decision_reason", "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": "credit_score",
          "operator": "GREATER_THAN_OR_EQUAL",
          "value": 580,
          "valueType": "NUMBER"
        },
        {
          "type": "SINGLE",
          "field": "credit_score",
          "operator": "LESS_THAN",
          "value": 720,
          "valueType": "NUMBER"
        }
      ]
    },
    {
      "type": "GROUP",
      "operator": "AND",
      "children": [
        {
          "type": "SINGLE",
          "field": "credit_score",
          "operator": "GREATER_THAN_OR_EQUAL",
          "value": 720,
          "valueType": "NUMBER"
        },
        {
          "type": "SINGLE",
          "field": "annual_income_usd",
          "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.

Four rules in one EXCLUSIVE mutex group — the two decline rules ordered above approve and refer

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 conditions decide whether to ship. First, the shift in the decline rule's match rate — candidate minus baseline — must sit inside the range risk agreed to absorb; that number is the additional decline volume and the additional adverse-action notices it generates. Second, the rules the edit did not touch must hold their match rates; the change claimed to move one threshold, and the per-rule statistics verify that nothing else moved with it. During a simulation, external calls are mocked; the run reads historical data and writes nothing.

Impact Simulation comparing the candidate's decision mix against the baseline

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": {
    "traceId": "a7f3c0e1-...",
    "inputFacts": {
      "credit_score": 760,
      "annual_income_usd": 120000,
      "dti_ratio": 0.55
    },
    "mutatedFacts": {},
    "generatedVariables": {
      "credit_decision": "DECLINED",
      "decision_reason": "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
      }
    ]
  }
}

Dry Run of the declined application — the approve rule matched but lost the mutex on priority

generatedVariables holds the decision the application reads: credit_decision is DECLINED, and decision_reason 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 is_blocked 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": {
    "traceId": "b2e9d4a0-...",
    "inputFacts": {
      "credit_score": 760,
      "annual_income_usd": 120000,
      "dti_ratio": 0.30
    },
    "mutatedFacts": {},
    "generatedVariables": {
      "credit_decision": "APPROVED",
      "decision_reason": "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
      }
    ]
  }
}

Dry Run of the approved application — the approve rule selected, no competitor matched

With the knockout no longer matching, the approve rule is the only rule that fires. One SELECTED, three NO_MATCH, and credit_decision 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. A few adjacent cases call for a deliberate choice.

  • An applicant exactly on a threshold. GREATER_THAN lets 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 is GREATER_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 dti_ratio does not adjudicate quietly. The engine throws and names the missing fact rather than substituting a default. The caller must treat an errored execution as not adjudicated — never as a silent decline or a silent approval. The engine refuses to guess, and the application must not guess on its behalf.
  • The reason an applicant is owed. Every decision writes decision_reason, and the SELECTED rule's name records which rule produced the outcome. The two are separable on purpose: the rule name is the internal record, and decision_reason is 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 — needs an accumulating fact (INCREMENT_FACT) and a time window, which is a different pattern with 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 and the engine errors rather than defaulting to an outcome no one chose. A true gap fails loudly to a human; it does not quietly approve or decline.

Production rollout

A candidate that clears both simulation conditions goes to production with Deploy. The rule snapshot at deploy time is sealed with a hash and integrity-verified, and the record captures who deployed 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.

Either of two signals means roll back immediately:

  • The changed rule's live match rate drifts from the simulated match rate. 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. The facts arriving in production do not look like the dataset the candidate was measured against.
Deployment detail for the adjudication version

When comparing two scorings is genuinely needed, the A/B Test runs in shadow: score every application through both versions, act on one, and read the difference without splitting live decisions. That keeps the comparison while every applicant still receives the one version's decision. A rollback returns the policy group to the previous version and leaves a deployment record, so the rollback itself stays in the audit trail. 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.


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