A supplier's arrears path: the logic that decides which customer in debt gets which letter tonight, and which must never get one. Eight exclusions, evaluated in a fixed order where the first match wins; four scripts routed by age; a tie-out that refuses to send anything if the columns don't add up. It is running below, in your browser, on a 2,000-account base you can make as unforgiving as you like.
A collections letter is the one piece of customer contact a company sends at scale to people who are, by definition, having a bad month. Most of them should get one; the routing for those is easy and boring. The whole difficulty is the rest: the accounts that must not get a letter tonight, whatever their age or balance says. A dispute that is still open. A payment plan that is being kept. A "no letters" preference that was recorded two years ago and honoured ever since. In the arrears case the discipline that held those lines was a reconciliation: every account in, every account out, exactly once, and no file to the mailhouse if the sum broke. The Eligibility Engine is that discipline written down as rules and run on a schedule — SEAL and WATCH of the Leak Ledger, pointed at harm rather than cash.
The engine is deliberately boring: no scoring model, no "propensity", no rule that depends on a rule further down. Each exclusion is a sentence a complaints handler can read and disagree with, the order is printed, and the two numbers a business could argue about — the balance under which a letter isn't worth sending, and the minimum gap between letters — are sliders, not constants. That is what makes the output trustworthy: when the engine says an account gets a formal notice, the trail says which seven rules looked at it first and why none of them fired.
| STEP | RULE | WHAT HAPPENS |
|---|---|---|
| E01 | Deceased or insolvent | Hold. Nothing goes out; the account leaves the path for the estates process. |
| E02 | On the vulnerability register | Specialist team, never a letter. The one branch that goes to people, not the mailhouse. |
| E03 | Open dispute | Hold until resolved. You do not chase a balance the customer is contesting. |
| E04 | Active payment plan, being kept | No letter. A broken plan is not excluded: it routes by age with a "plan broken" line added to the letter. |
| E05 | A payment in the last 14 days | Wait. The customer is moving; the next build will see whether the balance cleared. |
| E06 | A letter sent within the minimum gap (default 21 days) | Minimum gap. One letter at a time; the slider decides how long "a time" is. |
| E07 | Balance below the threshold (default £50) | Below threshold. Not worth the stamp or the phone call it triggers. |
| E08 | Contact preference "no letters" | Other channel. Honoured every night, not just the night it was recorded. |
| Routing | Under 7 days → not yet due · 7–21 → A first reminder · 22–45 → B second reminder · 46–90 → C formal notice if the balance is ≥ £100, else B again · 91+ → D final notice / handover | Only accounts no rule caught. Every account ends in exactly one place. |
| Tie-out | accounts = Σ exclusions + A + B + C + D + not yet due, and each account has one outcome | If it breaks, nothing goes to the mailhouse. It never should; the check runs anyway. |
# the exclusions, in order; the first match owns the account (engine.py) def hits(a, rules_on, threshold_p, gap): h = [] if rules_on["E01"] and a["deceased"]: h.append("E01") if rules_on["E02"] and a["vulnerable"]: h.append("E02") if rules_on["E03"] and a["dispute"]: h.append("E03") if rules_on["E04"] and a["plan"] and a["plan_kept"]: h.append("E04") if rules_on["E05"] and a["paid14"]: h.append("E05") if rules_on["E06"] and a["since_letter"] <= gap: h.append("E06") if rules_on["E07"] and a["pence"] < threshold_p: h.append("E07") if rules_on["E08"] and a["no_letters"]: h.append("E08") return h # h[0] fires; len(h) == 1 means it guarded alone def route(a): # only for accounts no rule caught d = a["days"] if d < 7: return "NOT_YET_DUE" if d <= 21: return "A" if d <= 45: return "B" if d <= 90: return "C" if a["pence"] >= FORMAL_MIN_P else "B" # a formal notice needs £100 return "D"
Run the build to see who the rules protected.
Two things to try. Press NO EXCLUSIONS and the prevented count becomes the letters that would have gone out: every account on the vulnerability register gets a reminder, and the estate of a customer who has died gets a final notice. That is what "the routing is fine" looks like without the gate in front of it. Then switch only E06 off and watch the sole-guard column in the table below: most of the accounts it protects are protected by nothing else — a minimum gap is not politeness, it is the rule that stops the same person getting three letters in a fortnight.
The first account each rule caught, then the first account sent to each script. Every account in the base has a line like this; a complaints handler can pull it by id.
| WHEN | WHAT HAPPENS | WHO SEES IT |
|---|---|---|
| 22:00 nightly | Pull the base as of close of business: balances, ages, the vulnerability register, disputes, plans, payments, letter history, preferences. Refuse to run if the register extract is older than 26 hours or any flag count moved more than 20% day-on-day. | A failed pull pages the owner. A stale register never becomes a letter to someone it should have protected. |
| 22:03 | Exclusions in order, then routing, then the tie-out: accounts = Σ exclusions + A + B + C + D + not yet due; every account has one outcome and a reason line. | The controller. A tie-out failure is the engine finding a bug in itself. |
| 22:04 | The no-publish rule. If the tie-out breaks, or a single account has two outcomes or none, no file goes to the mailhouse. Yesterday's letters do not go out twice; tonight's do not go out at all until a human has read the diff. | Operations gets a "no file tonight" message with the diff attached, never a silent gap. |
| 06:00 | The mailhouse file lands with a manifest: letters by script, the count the tie-out signed, the list of ids. The specialist team's queue lands separately, with the E02 accounts and nothing else. | The mailhouse prints; the specialist team calls. |
| Monthly | Two tables on one page: letters prevented by rule, with the sole-guard column, and complaints per thousand letters by script. A rule that guards alone is a rule nobody gets to switch off in a hurry. | Leadership — the WATCH stage of the Leak Ledger, pointed at harm. |
The most important letter is the one that never goes out. On the reference base the routing alone would post 1,943 letters tonight; the eight rules in front of it stop 954 of them, 116 to people on a register that exists precisely so they are not written to. Put the exclusions first, print the order, make the thresholds decisions rather than constants, and let the tie-out refuse to send — and zero escalations stops being a good month and becomes the design.
Letters, notices or nudges going out at scale? An eligibility engine is a two-to-four-week SEAL: your rules, in your order, with your thresholds as decisions — running every night with a tie-out that refuses to send the one letter you would have to apologise for.
Start with a TRACE → engine.py README results.json The case it came from →