Screening Insurance Eligibility by Age and Region with BLOCK Rules
A filing change lands in one version instead of three codebases, and a declined application names the screen that stopped it.
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 quarterly review turns up 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?
A filing moves and every path has to move with it. One screening policy, in force everywhere applications arrive, and a record that names which screen declined a given application and why.
The naive approach
Every screening value starts as a constant in the application service, right where the check runs.
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);
}
}
The code is fine while one path exists, 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
Pull the screening decision into one 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 only the exception carries a reason.
A tier limit partitions its input, so rules cannot overlap. The screens here are independent, age and state and coverage each guarding 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.
A product still in filing has no traffic to replay. 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
Dry Run reproduces the case from the review: a 34-year-old in FL requesting $250,000, against the version in force before the new filing.
{
"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,
...
}
]
}
}
mutatedFacts comes back empty because the screens never rewrite an input, and that map shows only rewritten ones. The decline lands in generatedVariables instead, as two reserved keys the application never carried: 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. Note what these rules are not: they answer whether an application may be taken at all, and 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. 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.
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 that product and compliance approved no longer describes 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 seven-state version 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. Sizing a hot-path rule rewrite breaks that time down per screen.
See how LexQ works for yourself in the playground.