LexQLexQ
Back to patterns
FintechpaymentsOperationsTrustBeginner

Enforcing KYC-Tiered Transfer Limits with BLOCK Rules

A pattern that moves per-KYC-level transfer limits out of scattered service constants into BLOCK rules, so every blocked transfer carries its reason.

Sanghyun Park·June 12, 202611 min read15 min

The problem

Most money-movement teams enforce transfer limits that depend on identity verification. An account that has not completed verification can move a little. A verified account can move more. An account under enhanced due diligence can move the most. The limits themselves are policy — compliance sets them, reviews them, and changes them on compliance's schedule, not engineering's.

In code, each limit is a constant and the check is a branch in the transfer service. Then the check gets copied. The mobile team duplicates the numbers for client-side pre-validation, so users see the error before submitting. A partner API ships a year later with the values that were current that quarter. The same limit now exists in three places, and a compliance change has to land in all three at once.

It does not. A review finds an account at the lowest verification level that moved $4,800 in a single transfer — one path was checking a stale constant. Two questions follow, and the code answers neither. Support asks: why was this customer's transfer blocked? Compliance asks: which limit was in force on May 14, and show every transfer it stopped.

The question this pattern answers: how does a team keep exactly one limit per verification level in force across every transfer path, and answer months later which transfer was blocked, by which limit, and why.

The naive approach

The first version puts the limits next to the check, as constants in the transfer service.

public class TransferService {

    // Per-KYC limits — owned by compliance.
    private static final BigDecimal UNVERIFIED_LIMIT = new BigDecimal("1000");
    private static final BigDecimal VERIFIED_LIMIT   = new BigDecimal("10000");
    private static final BigDecimal ENHANCED_LIMIT   = new BigDecimal("50000");
    // The mobile BFF keeps its own copy of these numbers for client-side
    // pre-validation. The partner API was added later, with the values
    // that were current at the time.

    public void execute(TransferRequest request, Account account) {
        BigDecimal amount = request.getAmount();

        switch (account.getKycLevel()) {
            case UNVERIFIED -> require(amount, UNVERIFIED_LIMIT);
            case VERIFIED   -> require(amount, VERIFIED_LIMIT);
            case ENHANCED   -> require(amount, ENHANCED_LIMIT);
            // No default branch. A KYC level this switch does not know
            // falls through, and the transfer proceeds unchecked.
        }

        ledger.post(request);
    }

    private void require(BigDecimal amount, BigDecimal limit) {
        if (amount.compareTo(limit) > 0) {
            throw new TransferLimitExceededException("Transfer limit exceeded");
        }
    }
}

It works at small scale, and each copy of it was correct the day it shipped. The flaw is structural, and it shows up in three places.

  • The limit lives in more than one place. The service has it, the mobile BFF has it for pre-validation, the partner API has it from the quarter it launched. A compliance change is now a synchronized deploy across three codebases, and a miss is silent — nothing fails, one path is merely wrong.
  • Changing a limit is a release. Compliance sets effective dates; constants ship on release trains. The limit a customer experiences depends on which deploy went out, and the history of the limit is the git history of three repositories.
  • A block leaves no queryable record. The exception message lands in a log line. "Every transfer the UNVERIFIED limit stopped in May" is a log-archaeology project, and "the limit in force on May 14" is git blame across three repos.

Defining the pattern

The fix is to make the limit decision a single call. Every transfer path — the service, the mobile pre-validation, the partner API — asks the same policy group, and the limits exist exactly once.

In LexQ terms, this maps to three concepts.

  • Fact: the input the engine reads. kyc_level, transfer_amount_usd.
  • Rule: one rule per verification level — a condition and a BLOCK action carrying a reason string.
  • The default is allow. A BLOCK action runs only when its rule matches. A transfer that matches no rule proceeds. The block is the exception, and the exception is the thing that gets written down.

There is no mutex group here. An account has exactly one verification level, so the conditions partition the input — at most one limit rule can match a given transfer. Nothing competes.

{
  "name": "Block: UNVERIFIED over 1,000",
  "condition": {
    "type": "GROUP",
    "operator": "AND",
    "children": [
      {
        "type": "SINGLE",
        "field": "kyc_level",
        "operator": "EQUALS",
        "value": "UNVERIFIED",
        "valueType": "STRING"
      },
      {
        "type": "SINGLE",
        "field": "transfer_amount_usd",
        "operator": "GREATER_THAN",
        "value": 1000,
        "valueType": "NUMBER"
      }
    ]
  },
  "actions": [
    {
      "type": "BLOCK",
      "parameters": {
        "reason": "transfer_amount_usd exceeds UNVERIFIED limit 1000"
      }
    }
  ],
  "isEnabled": true
}

The VERIFIED and ENHANCED rules are identical in shape, each with its own threshold. A fourth rule earns its place by handling input the first three do not recognize.

{
  "name": "Block: unknown KYC level (fail closed)",
  "condition": {
    "type": "GROUP",
    "operator": "AND",
    "children": [
      {
        "type": "SINGLE",
        "field": "kyc_level",
        "operator": "NOT_IN",
        "value": ["UNVERIFIED", "VERIFIED", "ENHANCED"],
        "valueType": "LIST_STRING"
      }
    ]
  },
  "actions": [
    {
      "type": "BLOCK",
      "parameters": {
        "reason": "kyc_level outside known levels"
      }
    }
  ],
  "isEnabled": true
}

Default-allow is what lets an under-limit transfer pass without a rule for it — and the same default turns an unrecognized kyc_level into a silent pass. This rule converts that accident into a decision: an unknown level blocks, with a reason. The naive switch made the opposite choice without anyone making it.

Four BLOCK rules, one per KYC level plus the fail-closed catch-all

The limit is now data. Changing it is editing one value in a draft version, and "what was the limit on May 14" is answered by version history, not git blame.

Impact Simulation strategy

Compliance lowers the VERIFIED limit from $10,000 to $5,000. Before that value reaches a live transfer, the change has a measurable blast radius: the share of real transfers the new limit would have stopped. 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 at all — the field is optional. includeRuleStats is enough: it reports each rule's match rate, and the VERIFIED rule's match rate under the candidate is exactly the share of last month's transfers the new limit would have blocked. That number is the support-ticket volume and the customer friction, measured before deploy.

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 newly blocked share — the VERIFIED rule's match rate under the candidate minus the baseline's — must sit inside the range operations agreed to absorb. Second, the other levels' match rates must not move at all; the edit claimed to touch one threshold, and the rule statistics verify that claim. During a simulation, external calls are mocked; the run reads historical data and writes nothing.

Impact Simulation comparing the 5,000 candidate against the 10,000 baseline

If no production traffic exists yet, upload a representative dataset instead — transfers spanning every level, with amounts clustered around each threshold — and run the same comparison.

Decision Trace output

Run the case from the compliance review: a $4,800 transfer from an UNVERIFIED account, with Dry Run.

{
  "result": "SUCCESS",
  "data": {
    "traceId": "c41a8f2e-...",
    "inputFacts": {
      "kyc_level": "UNVERIFIED",
      "transfer_amount_usd": 4800.00
    },
    "mutatedFacts": {},
    "generatedVariables": {
      "is_blocked": true,
      "block_reason": "transfer_amount_usd exceeds UNVERIFIED limit 1000"
    },
    "executionTraces": [ ... ],
    "decisionTraces": [
      {
        "ruleName": "Block: UNVERIFIED over 1,000",
        "status": "SELECTED",
        "reasonCode": "FINAL_WINNER",
        "reasonDetail": null
      },
      {
        "ruleName": "Block: VERIFIED over 10,000",
        "status": "NO_MATCH",
        "reasonCode": "CONDITION_MISMATCH",
        "reasonDetail": null
      },
      {
        "ruleName": "Block: ENHANCED over 50,000",
        "status": "NO_MATCH",
        "reasonCode": "CONDITION_MISMATCH",
        "reasonDetail": null
      },
      {
        "ruleName": "Block: unknown KYC level (fail closed)",
        "status": "NO_MATCH",
        "reasonCode": "CONDITION_MISMATCH",
        "reasonDetail": null
      }
    ]
  }
}
Dry Run of the blocked 4,800 UNVERIFIED transfer

mutatedFacts is empty — a BLOCK changes no fact. The block arrives in generatedVariables: is_blocked is true, and block_reason carries the matched rule's reason string. Both keys are system-generated, and they exist only when a BLOCK action ran; when nothing blocks, the keys are absent rather than false. The application contract follows directly: reject when is_blocked is true, and surface block_reason (or a message mapped from it). Which expression the rule was evaluated against appears in the Execution Trace table in the Dry Run view above. Support's answer is the reason string; compliance's answer is the trace — the rule, the version, the inputs, the timestamp.

Run a $900 transfer from a VERIFIED account and every rule reads NO_MATCH with CONDITION_MISMATCH. Both maps come back empty, and the application proceeds. The absence of is_blocked is the approval.

Dry Run of a 900 VERIFIED transfer matching no rule

Edge cases

The pattern is the single enforcement point, not the specific limits. A few adjacent cases call for a deliberate choice.

  • A transfer exactly at the limit. GREATER_THAN lets a $1,000.00 transfer pass at UNVERIFIED — "limit" read as the highest allowed amount. If the policy reads "blocks at and above," the operator is GREATER_THAN_OR_EQUAL. Decide once, write it into the rule, and the recorded match expression shows which reading is live.
  • An unknown verification level. An app release starts sending kyc_level as PENDING. Without the catch-all, that transfer matches nothing and proceeds — fail open by accident. The NOT_IN rule turns the same input into a recorded block. Fail closed is itself a policy decision; the rule makes it visible instead of implied.
  • A missing fact. A payload without kyc_level does not pass quietly. The engine throws an error naming the missing fact and never substitutes a default. The caller must treat an errored execution as not approved — the engine refuses to guess, and the application must not guess on its behalf.
  • Cumulative limits. This pattern bounds a single transaction. A daily or rolling total needs an accumulating fact (INCREMENT_FACT) and a time window, which is a different pattern with different failure modes — outside this one's scope.
  • Multiple currencies. The rules compare one numeric fact in one unit. Convert upstream and send one currency; the _usd suffix in the fact key is that contract made visible. A fact that mixes currencies makes every comparison meaningless.

Production rollout

A candidate that clears both 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 limit change ships to all traffic at once, not through a gradual traffic split. An A/B test would route some transfers through the candidate and the rest through the previous limit, so two accounts at the same verification level — moving the same amount — would receive different decisions. A discount tolerates a partial rollout; a compliance limit does not, because inconsistent enforcement 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 block rate per rule against the simulated match rates.

Either of two signals means roll back immediately:

  • The changed rule's live block rate drifts from the simulated match rate. The production population differs from the historical window, and the blast radius that was signed off no longer holds.
  • Blocks appear in levels the simulation reported untouched. The kyc_level fact arriving in production does not look like the dataset.
Deployment detail for the candidate version

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 the candidate serves all traffic, the per-rule statistics are the standing answer to compliance's question: each level's stop count is a query over execution history, scoped to a rule and a version — not a log-archaeology project. And "the limit 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