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.
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 defect sits in the structure, and it surfaces three ways.
- 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 blameacross 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.
The screening policy rests on three LexQ concepts.
- Fact: the applicant's issue age, state of residence, and requested coverage, as the engine receives them.
applicantAge,residenceState,requestedCoverageUsd. - Rule: one rule per screen — a condition and a
BLOCKaction carrying areasonstring. - The default is allow. A
BLOCKaction fires only when an application trips that screen. 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": "applicantAge",
"operator": "LESS_THAN",
"value": 19,
"valueType": "NUMBER"
}
]
},
"actions": [
{
"type": "BLOCK",
"parameters": {
"reason": "applicantAge 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": "residenceState",
"operator": "NOT_IN",
"value": ["CA", "TX", "NY", "WA", "IL"],
"valueType": "LIST_STRING"
}
]
},
"actions": [
{
"type": "BLOCK",
"parameters": {
"reason": "residenceState 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, applicantAge GREATER_THAN_OR_EQUAL 50 and requestedCoverageUsd GREATER_THAN 500000, and blocks with its own reason string. Four rules hold one screening policy.
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. Cloning the live version and editing that list yields a candidate, the Target Version in the console's terms, that no application reaches yet.
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 readings of the rule statistics decide whether the wider list ships. 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. The run reads June's applications and writes nothing back to them.
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": {
"inputFacts": {
"applicantAge": 34,
"residenceState": "FL",
"requestedCoverageUsd": 250000
},
"mutatedFacts": {},
"generatedVariables": {
"isBlocked": true,
"blockReason": "residenceState 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
}
]
}
}
A decline rewrites nothing, so mutatedFacts comes back empty. The decline arrives in generatedVariables: isBlocked is true, and blockReason carries the matched screen's reason string. This application matched one screen; when several match at once, the edge cases below say which reason survives. Both keys exist only when a BLOCK action ran; when nothing blocks, they are absent rather than false. The application contract follows directly: when isBlocked is true, route to a decline or to an availability message mapped from the reason; otherwise proceed to underwriting. Sales gets its answer from the reason string. Compliance reads the trace instead, which names the screen that fired, the version in force, the inputs, and the time of the decision.
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 isBlocked is the pass.
Edge cases
What carries over to another filed product is the single screening point; the bounds above belong to this filing. The cases below are the ones where reusing the shape forces an explicit call.
- An applicant exactly at the bound.
GREATER_THANlets a 60-year-old apply — "maximum issue age" read as the oldest age that may apply. If the filing reads "under 60," the operator isGREATER_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. An application payload that omits
residenceStateis not screened at all. The engine fails the execution and names the fact it did not receive, and no state is assumed in its place. An errored execution is neither a pass nor a decline, so the caller holds the application instead of sending it on to underwriting. - 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. Every matching screen is
SELECTEDwithFINAL_WINNERand every one of their actions runs, butgeneratedVariablescarries a singleblockReason, and the last matching screen in priority order overwrites the earlier ones. The 63-year-old above is declined with the state reason, not the age reason, so a team that needs a specific reason surfaced has to order the screens for it rather than assume the first match wins. - 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
The seven-state version reaches production through Deploy once both readings hold. Deploy seals the four screens as a hashed snapshot and verifies its integrity. The deployment record names the version, the person who shipped it, and the time.
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 blast radius measured against June's applications is what justifies the deploy. Confirmation on real traffic is what deploy adds: the block rate per screen, read against the simulated match rates.
Two observations in live screening force an immediate rollback:
- The state screen's live block rate diverges from the match rate the simulation reported. Applications arriving this week are not June's applications, and the newly admitted share product and compliance approved does not describe them.
- Block rates move in screens the simulation reported untouched. The facts arriving in production do not look like the dataset.
Rolling back restores the five-state version and writes its own deployment record, so the reversal is as auditable as the release. 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. If screening latency climbs after the deploy, the open question is which of the four screens spent the time. The per-screen breakdown comes from isolating a slow rule on a hot path.
Ready to move decisions out of your deploy pipeline?
Try LexQ free — no credit card required.
Start Free