Modelling who can get what.
Eligibility is derived, never stored: keep rules and conditions with provenance on every one, evaluate them deterministically, and return a four-value verdict where unknown is a real state rather than a soft no. This is the model behind RateAPI’s eligibility API, and the reasoning applies whether you use ours or build your own.
Three questions people conflate
Almost every eligibility bug traces back to one of these being answered by a model built for another.
- Access
- Can this person obtain this product at all? Membership, geography, product-level constraints. This is what an eligibility model should answer.
- Underwriting
- Will the lender approve them, on what terms? Needs credit data and belongs to the lender. Different system, different authority.
- Suitability
- Should they take it? A recommendation question, downstream of both, and a regulated one in many contexts.
The schema
-- Rules and conditions, with provenance as a first-class citizen.CREATE TABLE eligibility_rules ( rule_id BIGSERIAL PRIMARY KEY, institution_id TEXT NOT NULL, kind TEXT NOT NULL, -- geography | employer | association | military | family | open description TEXT NOT NULL, -- human-readable, rendered to users -- Provenance. Not metadata: without it a verdict cannot be explained, -- corrected, or aged out, which are the three things you will need most. source_id BIGINT NOT NULL REFERENCES eligibility_sources(source_id), evidence_quote TEXT NOT NULL, -- the sentence that justified the rule observed_at TIMESTAMPTZ NOT NULL, confidence REAL NOT NULL, -- Supersede, never overwrite: last year's verdict must stay explainable. superseded_at TIMESTAMPTZ, superseded_by_rule_id BIGINT); CREATE TABLE eligibility_conditions ( condition_id BIGSERIAL PRIMARY KEY, rule_id BIGINT NOT NULL REFERENCES eligibility_rules(rule_id), kind TEXT NOT NULL, -- Canonical keys, never free text: 'county:NC:mecklenburg'. This is what -- turns a fuzzy geographic question into an exact index hit. geo_level TEXT, geo_value TEXT, org_id TEXT, params_json JSONB); -- The inverse index is the whole performance story: it turns "which of 4,000-- institutions might apply to this person" into a handful of key lookups.CREATE INDEX ON eligibility_conditions (geo_value) WHERE geo_value IS NOT NULL;CREATE INDEX ON eligibility_conditions (org_id) WHERE org_id IS NOT NULL;Evaluation: narrow, then evaluate properly
// Narrow, then evaluate. The order matters and the second half is the subtle part.function findEligible(person: Facts): Verdict[] { // 1. NARROW — cheap index hits from the person's facts. const candidates = new Set([ ...byGeo(person.geoKeys), ...byOrg(person.orgIds), ...openToAnyone(), ]); // 2. EVALUATE — against each institution's FULL rule set. // // The trap: an institution surfaced by ONE matching condition is not // thereby eligible. Its rule may require that condition AND another the // person fails. Scoring on the condition that surfaced the candidate is // the single most common way an eligibility engine becomes confidently // wrong, because it only ever over-reports. return [...candidates].map((id) => evaluate(allRulesFor(id), person));}The verdict vocabulary
Four values. The fourth is the one that keeps the system honest.
- eligible
- A rule matched the facts you have.
- conditionally_eligible
- One step away — and the model must be able to NAME the step and its cost, or the value is unusable in a product.
- possibly_eligible
- A rule plausibly applies but the facts do not confirm it. Drives your next question to the user.
- unknown
- Insufficient rule data. NEVER treat as ineligible: coverage gaps should shrink an answer’s confidence, never its recall.
Frequently asked
Because you cannot explain it, cannot correct it, and cannot tell a stale answer from a fresh one. Eligibility is derived, not stored: keep the rules and evaluate them. A cached verdict without its rule is a number you will eventually be unable to defend to a user or a regulator.
Eligibility is "can this person access this product at all" — membership, geography, and the product’s own access constraints. Underwriting is "will this lender approve this person on these terms", which needs credit data and belongs to the lender. Keep them in separate models; conflating them produces a system that implies approvals it has no authority to make.
No. LLMs are excellent at EXTRACTING a rule from a page of prose and terrible as the thing that decides. Extraction is a fuzzy problem with a human-checkable output; decision must be deterministic, reproducible and explainable. Use models to populate the rule graph, then let deterministic code evaluate it.
Supersede, never overwrite. Give every rule observed_at and superseded_at, and write the new one rather than editing the old. A verdict issued six months ago should still be explainable with the rule that produced it, which is impossible if you destroyed it.