LexQLexQ
Back to patterns
InsuranceEligibilityOperationsTrustBeginner

Screening Insurance Eligibility by Age and Region with BLOCK Rules

Age, state, and coverage screens move out of scattered conditionals into BLOCK rules, so every decline carries its reason.

Sanghyun Park·July 20, 202612 min read15 min

The problem

Insurers sell filed products. A term life product is approved state by state, its minimum and maximum issue ages are part of the filing, and the coverage cap for older applicants comes from the underwriting guideline. None of these values belongs to engineering — product and compliance own them, and they change on filing schedules: a new state approval arrives, an age band widens, a cap tightens.

In code, each value is a constant and the screen is a branch in the application service. Then the screen gets copied. The quote page keeps its own state list, so a visitor learns whether the product is sold in their state before quoting. An agent-channel API ships a year later with the values that were current that quarter. The same screening now exists in three places, and a filing change has to land in all three at once.

It does not. A review finds an accepted application from a state where the product was never approved — the agent channel was holding last quarter's list. Two questions follow, and the code answers neither. Sales asks: why was this applicant declined? Compliance asks: which screening was in force on March 3, and which applications did it decline?

The question this pattern answers: how does a team keep exactly one screening policy in force across every application path, and answer months later which application was declined, by which screen, and why.

The naive approach

The first version puts the screening values next to the check, as constants in the application service.

public class ApplicationScreeningService {

    // Screening values — owned by product and compliance.
    private static final int MIN_ISSUE_AGE = 19;
    private static final int MAX_ISSUE_AGE = 60;
    private static final Set<String> APPROVED_STATES =
            Set.of("CA", "TX", "NY", "WA", "IL");
    private static final int SENIOR_AGE_BAND = 50;
    private static final BigDecimal SENIOR_COVERAGE_CAP = new BigDecimal("500000");
    // The quote page keeps its own copy of APPROVED_STATES for the
    // availability check. The agent-channel API was added later, with
    // the list that was current at the time.

    public void screen(Application app) {
        if (app.age() < MIN_ISSUE_AGE) {
            throw new IneligibleException("Below minimum issue age");
        }
        if (app.age() > MAX_ISSUE_AGE) {
            throw new IneligibleException("Above maximum issue age");
        }
        if (!APPROVED_STATES.contains(app.residenceState())) {
            throw new IneligibleException("Product not approved in state");
        }
        if (app.age() >= SENIOR_AGE_BAND
                && app.requestedCoverage().compareTo(SENIOR_COVERAGE_CAP) > 0) {
            throw new IneligibleException("Coverage above cap for age band");
        }
        underwriting.enqueue(app);
    }
}

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

  • The screening lives in more than one place. The service has it, the quote page has the state list for the availability check, the agent channel has the values from the quarter it launched. A filing change is now a synchronized deploy across three codebases, and a miss is silent — one path quietly sells where the product is not approved, or declines where it now is.
  • Changing eligibility is a release. Regulators approve filings with effective dates; constants ship on release trains. The screening an applicant actually gets depends on which deploys have gone out, and the history of the policy is the git history of three repositories.
  • A decline leaves no queryable record. The exception message lands in a log line. "Every application the state screen declined in June" is a log-archaeology project, and "the screening in force on March 3" is git blame across three repos.

Defining the pattern

The fix is to make the screening decision a single call. Every application path — the service, the quote availability check, the agent channel — asks the same policy group, and the screening values exist exactly once. It is the same single-enforcement-point shape as KYC-tiered transfer limits, applied to a different regulated decision.

In LexQ terms, this maps to three concepts.

  • Fact: the input the engine reads. applicant_age, residence_state, requested_coverage_usd.
  • Rule: one rule per screen — a condition and a BLOCK action carrying a reason string.
  • The default is allow. A BLOCK action runs only when its rule matches. An application that matches no screen proceeds to underwriting. The block is the exception, and the exception is the thing that gets written down.

A tier limit partitions its input, so rules cannot overlap. The screens here are independent — age, state, and coverage each guard their own factor — and more than one can match the same application. The edge cases section returns to what that means for the recorded reason.

The minimum-age rule is the pattern at its smallest: one condition, one action.

{
  "name": "Block: age below minimum issue age",
  "condition": {
    "type": "GROUP",
    "operator": "AND",
    "children": [
      {
        "type": "SINGLE",
        "field": "applicant_age",
        "operator": "LESS_THAN",
        "value": 19,
        "valueType": "NUMBER"
      }
    ]
  },
  "actions": [
    {
      "type": "BLOCK",
      "parameters": {
        "reason": "applicant_age below minimum issue age 19"
      }
    }
  ],
  "isEnabled": true
}

The maximum-age rule — Block: age above maximum issue age — mirrors it with GREATER_THAN and 60. The state rule carries the filing list itself.

{
  "name": "Block: state not approved",
  "condition": {
    "type": "GROUP",
    "operator": "AND",
    "children": [
      {
        "type": "SINGLE",
        "field": "residence_state",
        "operator": "NOT_IN",
        "value": ["CA", "TX", "NY", "WA", "IL"],
        "valueType": "LIST_STRING"
      }
    ]
  },
  "actions": [
    {
      "type": "BLOCK",
      "parameters": {
        "reason": "residence_state outside approved states"
      }
    }
  ],
  "isEnabled": true
}

NOT_IN makes this rule fail closed by construction: a state the list does not name — a new territory code, a typo, a value no one anticipated — is blocked with a reason instead of passing unchecked. The coverage rule — Block: coverage above senior cap — joins two conditions with AND, applicant_age GREATER_THAN_OR_EQUAL 50 and requested_coverage_usd GREATER_THAN 500000, and blocks with its own reason string. Four rules, one screening policy.

Four BLOCK rules: two age bounds, the approved-state list, and the senior coverage cap

The screening is now data. A new state approval is one edit to one list in a draft version, and "the screening in force on March 3" is answered by version history, not git blame.

Impact Simulation strategy

The filing for two new states comes through — the product is now approved in FL and GA. The change is one edit: add both codes to the state rule's list. Duplicate the live version, make the edit, and the result is a candidate — the Target Version, in the console's terms — that carries no traffic.

Before the wider list reaches a live application, the change has a measurable blast radius: the share of real applications it would newly admit. The question is about counts, so the metric fact stays empty — includeRuleStats is enough. The state rule's match rate under the candidate, subtracted from the baseline's, is exactly the share of last month's applications the state screen would no longer stop. Where a tighter transfer limit measures added friction, a wider filing list measures recovered demand — the same run, read from the other side. (For the full mechanics of a baseline-versus-candidate run, see testing a rule change before deploy.)

lexq analytics simulation start --json '{
  "policyVersionId": "<candidate-version-id>",
  "dataset": {
    "type": "HISTORICAL",
    "source": "EXECUTION_LOGS",
    "from": "2026-06-01",
    "to": "2026-06-30"
  },
  "options": {
    "baselinePolicyVersionId": "<baseline-version-id>",
    "includeRuleStats": true,
    "maxRecords": 50000
  }
}'

Two conditions decide whether to ship. First, the state rule's match-rate drop must line up with the FL and GA share the funnel already reports — a larger drop means the list edit did more than it claimed. Second, the age and coverage rules' match rates must not move at all; the edit claimed to touch one list, 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 seven-state candidate against the five-state baseline

If no production traffic exists yet, upload a representative dataset instead — applications spanning every screen, with ages and coverage amounts clustered around each bound — and run the same comparison.

Decision Trace output

Run the case from the review: a 34-year-old in FL requesting $250,000, against the version in force before the new filing, with Dry Run.

{
  "result": "SUCCESS",
  "data": {
    "traceId": "7d2f91c4-...",
    "inputFacts": {
      "applicant_age": 34,
      "residence_state": "FL",
      "requested_coverage_usd": 250000
    },
    "mutatedFacts": {},
    "generatedVariables": {
      "is_blocked": true,
      "block_reason": "residence_state outside approved states"
    },
    "executionTraces": [ ... ],
    "decisionTraces": [
      {
        "ruleName": "Block: age below minimum issue age",
        "status": "NO_MATCH",
        "reasonCode": "CONDITION_MISMATCH",
        "reasonDetail": null
      },
      {
        "ruleName": "Block: age above maximum issue age",
        "status": "NO_MATCH",
        "reasonCode": "CONDITION_MISMATCH",
        "reasonDetail": null
      },
      {
        "ruleName": "Block: state not approved",
        "status": "SELECTED",
        "reasonCode": "FINAL_WINNER",
        "reasonDetail": null
      },
      {
        "ruleName": "Block: coverage above senior cap",
        "status": "NO_MATCH",
        "reasonCode": "CONDITION_MISMATCH",
        "reasonDetail": null
      }
    ]
  }
}
Dry Run of the FL application declined by the state screen

mutatedFacts is empty — a BLOCK changes no fact. The decline arrives in generatedVariables: is_blocked is true, and block_reason carries the matched screen's reason string. Both keys exist only when a BLOCK action ran; when nothing blocks, they are absent rather than false. The application contract follows directly: route to a decline — or an availability message mapped from the reason — when is_blocked is true, and proceed to underwriting otherwise. The reason string answers sales; the trace answers compliance — the rule, the version, the inputs, the timestamp.

Run a 34-year-old in TX at the same coverage 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 pass.

Dry Run of a TX application matching no screen

Edge cases

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

  • An applicant exactly at the bound. GREATER_THAN lets a 60-year-old apply — "maximum issue age" read as the oldest age that may apply. If the filing reads "under 60," the operator is GREATER_THAN_OR_EQUAL. Decide once, write it into the rule, and the recorded condition expression shows which reading is live.
  • What "age" means. Insurers compute issue age as age last birthday or age nearest birthday, and the two can differ for the same applicant on the same day. The engine compares one number; the convention that produced it is an upstream contract. Compute the issue age before the call and send the number — not the birthdate — so the rule never embeds date arithmetic it cannot explain.
  • A missing fact. A payload without residence_state 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 screened — the engine refuses to guess, and the application must not guess on its behalf.
  • Two screens matching at once. A 63-year-old in a non-approved state fails two screens, and the decision is a block either way. When the team needs one primary reason recorded in a fixed screening order — age before state before coverage — that is a priority-ordered mutex group, developed in adjudicating credit applications. This pattern keeps the screens independent; the decision trace records every screen's outcome regardless.
  • Screening is not underwriting. These rules answer whether an application may be taken at all. They do not rate risk, read health disclosures, or refer to a human. An approve-decline-refer routing is a different decision with different failure modes — outside this one's scope.

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.

An eligibility change ships to all traffic at once, not through a gradual traffic split. A split would route some applications through the wider list and the rest through the old one, so two applicants in the same newly approved state — applying the same day — would receive different decisions. A discount tolerates a partial rollout; a filed eligibility screen does not, because inconsistent screening is the failure, not the safeguard. The confidence to deploy comes from the Impact Simulation. What deploy adds is live confirmation — watch the live block rate per screen against the simulated match rates.

Either of two signals means roll back immediately:

  • The state 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.
  • Block rates move in screens the simulation reported untouched. The facts arriving in production do not look like the dataset.
Deployment detail for the seven-state 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 screen's decline count is a query over execution history, scoped to a rule and a version — not a log-archaeology project. And "the screening in force on March 3" is the version history plus the deployment record.

Ready to move decisions out of your deploy pipeline?

Try LexQ free — no credit card required.

Start Free