Programme-as-configuration,
not programme-as-code.
The thesis: eligibility rules should be configuration, not code. This is a working test of that idea — built to see where it holds and where the assumptions start to show. The engine is real. The gaps between demo and production are, too.
- 1. Funder changes an eligibility rule
- 2. Jira ticket raised for IT team
- 3. Developer implements rule change
- 4. Pull request + code review
- 5. QA testing across affected schemes
- 6. Change-control board sign-off
- 7. Production deployment
- 1. Scheme Manager authors config in portal
- 2. Validator runs instantly (14 checks)
- 3. Dry-run against test applicants
- 4. Approver reviews + approves
- 5. Engine activates — LIVE
Four design choices
All operators are explicit
16 operators, enumerated in code. equals, less_than_or_equal,
in_list, geo_within_radius_km. No free-text, no eval(), no injection surface.
The allow-list is the security boundary.
Every decision is traceable
Every evaluation produces a reasoning_chain — an ordered log of every rule
checked, its input, and its outcome. Whether that constitutes a DUA Act 2025 audit trail
is a legal question, not an engineering one.
Lifecycle is governed
DRAFT → ACTIVE requires an ApprovalRecord. No scheme goes live without explicit sign-off.
Change risk is tiered: eligibility rules and award limits are HIGH risk; metadata changes are LOW.
The engine prepares, humans decide
The engine cannot activate itself. An approver with a name and a rationale is required. This isn't bureaucracy — it's the HITL boundary baked into the data model, not bolted on as process.
grants_platform/brokering/ — 12 Python modules, 181 tests passing,
24 named properties verified by Hypothesis property-based testing.
The JS evaluation engine in this page is a faithful reimplementation of the same logic.
When you run the local Python server (see below), this page calls the real Python evaluator.
# Terminal 1 — start the engine server cd demos/grants-platform pip install flask python demo/scheme_engine_server.py # Terminal 2 — serve the demo page cd /path/to/ticketyboo.dev python -m http.server 8080 # Open in browser http://localhost:8080/site/demos/grants-platform/scheme-engine.html
Reading the operators? There's a plain-English version of this story →
- energy_supplier equals "EDF"
- annual_income less_than_or_equal 14,000
- energy_debt greater_than 0
- age greater_than_or_equal 65
- postcode_prefix in_list ["PE","CO","IP","NR","CB","NN","MK"]
- previous_award_within_months previous_award_within_months 12
SchemeConfig( scheme_id="EDF-CSF-2026", scheme_name="EDF Customer Support Fund 2026", version=3, tenant_id="charis", status=SchemeStatus.ACTIVE, eligibility_rules=[ Rule(field="energy_supplier", operator=RuleOperator.EQUALS, value="EDF"), Rule(field="annual_income", operator=RuleOperator.LESS_THAN_OR_EQUAL, value=14000), Rule(field="energy_debt", operator=RuleOperator.GREATER_THAN, value=0), Rule(field="age", operator=RuleOperator.GREATER_THAN_OR_EQUAL, value=65), Rule(field="postcode_prefix", operator=RuleOperator.IN_LIST, value=["PE","CO","IP","NR","CB","NN","MK"]), ], exclusion_rules=[ Rule(field="previous_award_within_months", operator=RuleOperator.PREVIOUS_AWARD_WITHIN_MONTHS, value=12), ], required_fields=["energy_supplier", "annual_income", "energy_debt"], award_range=AwardRange(min_gbp=Decimal("100"), max_gbp=Decimal("2000")), cross_referral_enabled=True, )
# ApplicantBag = dict[str, Any] bag: ApplicantBag = { "energy_supplier": "EDF", "annual_income": 9744, "energy_debt": 847.32, "age": 74, "postcode_prefix": "PE", "previous_award_within_months": None, } result = RuleEvaluator().evaluate(config, bag) # EvaluationResult(outcome=EligibilityOutcome.ELIGIBLE, ...)
# rule_evaluator.py — evaluate() reasoning.append( f"BEGIN evaluation: scheme='{config.scheme_id}' v{config.version}" ) for rule in config.eligibility_rules: passed = _dispatch(rule, applicant) # operator allow-list only reasoning.append( f"RULE [{rule.field} {rule.operator.value} {rule.value}]" f" value={applicant.get(rule.field)} → {'PASS' if passed else 'FAIL'}" ) # Full list stored in EvaluationResult.reasoning_chain
- meter_type equals "prepayment"
- fuel_poverty_flag equals true
- annual_income less_than_or_equal 18,000
- referral_month in_list [11, 12, 1, 2, 3]
Dry-run fires automatically when the scheme is activated.
In this demo: three test applicants. In production, this would be the entire household dataset — every person Charis holds data on, evaluated the moment a new scheme goes live. People who qualify don't need to find the scheme and apply. The system already knows who fits.
# ApprovalRecord required for DRAFT → ACTIVE approval = ApprovalRecord( approved_by="Sarah Okonkwo", rationale="Ofgem programme briefing ref ORG/2026/WW", risk_tier=ChangeRiskTier.HIGH, approved_at=datetime.now(timezone.utc), ) lifecycle = SchemeLifecycleManager() lifecycle.transition(config, SchemeStatus.ACTIVE, approval) # Dry-run works on DRAFT or ACTIVE dry = DryRunEvaluator() summary = dry.batch_evaluate(config, [bag1, bag2, bag3]) # DryRunSummary(eligible=1, ineligible=1, indeterminate=1)