Connecting to Python engine…
Charis · Scheme Configuration Engine · Live Demo

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.

The thesis: Every grant scheme a welfare organisation runs today is encoded as software. Changing an eligibility rule means a Jira ticket, a pull request, a code review, a deployment — a 3-week delay for a policy change that took 30 minutes to agree in a meeting.
Before — Scheme as Code
  • 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
weeks
Typical time for a rule change to reach production
After — Scheme as Configuration
  • 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
days
Scheme authored → validated → approved → 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.

What's under this demo: 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.
Run the Python engine locally
# 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 →

Patricia Walsh — 74, Peterborough PE3, EDF customer. Heating has been off for six weeks. Her housing officer Mark refers her. Watch every eligibility rule evaluate — every rule input, operator, and outcome is visible in the reasoning chain.
Scheme Config v3
ACTIVE
EDF Customer Support Fund 2026
EDF-CSF-2026 · tenant: charis · award: £100–£2,000
  • 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
energy_supplier, annual_income, energy_debt
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,
)
Applicant Bag Patricia Walsh
# 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, ...)
Reasoning Chain
Press "Run evaluation" to start…
# 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
Author the Ofgem Winter Warm Fund 2026 from scratch. Validate, approve, activate, dry-run against three test applicants. If the Python server is running, these calls hit the real engine. If not, the JS fallback runs the same logic in-browser.
Scheme Configuration Form
Identity
✓ valid identifier
Award Range (GBP)
✓ valid award range
Eligibility Rules
  • 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]
✓ 4 eligibility rules, no exclusion overlap
Validation Result VALID
✓ All structural checks pass. Ready for approval.
Lifecycle & Approval
Scheme Lifecycle State Machine
Current status: DRAFT
✓ Step 1 — Validate
Validator runs 14 structural + business-rule checks.
⟳ Step 2 — Submit for Approval
DRAFT → ACTIVE requires an ApprovalRecord. Change risk tier: HIGH (eligibility rules + award range are HIGH risk fields).
◎ Step 3 — Approval
Production: writes ApprovalRecord + SchemeMetadata to DynamoDB via scheme_service.activate_version()
◎ Step 4 — ACTIVE
Scheme is live. Real applicant evaluations may proceed.
Dry-Run Results

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)