Every decision system has a rules layer, whether anyone designed it or not. Sometimes it’s a clean module. More often it’s if statements scattered across three services, a SQL WHERE clause in a batch job, and a spreadsheet the operations team edits by hand. That rules layer decides what’s legal, what’s allowed and who’s eligible. It’s the part regulators ask about first.
This lesson is about making that layer explicit, maintainable and testable.
What belongs in rules
Recall the division of labour from Lesson 3. Rules handle what is allowed:
- Legal and regulatory requirements: minimum age, jurisdictions where a product can’t be offered, mandatory cooling-off periods.
- Contractual and policy limits: one discount per customer per year, no offers to accounts in collections.
- Eligibility for actions: account-manager calls only for annual plans above $2,000.
- Safety stops: block if the customer has an open fraud investigation.
Rules should not decide what is best among allowed actions. When rule sets start approximating risk with thresholds on dozens of variables (“if tenure < 6 and logins < 3 and plan = monthly and region in (…)…”), they’re doing a model’s job badly. That logic belongs in a calibrated model; rules should constrain its output.
Decision tables
A decision table is the most readable format for rules. Each row is a rule; input columns hold conditions; output columns hold results. Business owners can read and review them directly.
| In collections | Discounts last 12m | Plan & annual value | → Eligible offers | Reason |
|---|---|---|---|---|
| true | any | any | none | R01 collections hold |
| false | ≥ 1 | any | none, free_upgrade | R02 discount cap reached |
| false | 0 | annual and > 2000 | all | R03 full eligibility |
| false | 0 | anything else | all except call | R04 no call tier |
The hit policy says what happens when several rows match. The DMN standard defines them:
- UNIQUE: no overlaps allowed; any input matches at most one row. Overlap is an error.
- FIRST: rows are evaluated in order; the first match wins. Easy, but order becomes hidden logic.
- PRIORITY: multiple matches allowed; the output with the highest priority wins.
- ANY: overlaps allowed only if they produce the same output.
- COLLECT: return all matching outputs (optionally aggregated: sum, min, max, count).
- RULE ORDER / OUTPUT ORDER: return all matches, sorted by rule order or output priority.
Prefer UNIQUE for policy tables. It forces you to make rows mutually exclusive, which surfaces ambiguous cases during design rather than in production. The table above is UNIQUE: R01 could have been written as a FIRST-policy override, but repeating “in collections = false” on the later rows makes every row readable on its own, with no hidden dependence on order.
DMN: the standard
DMN (Decision Model and Notation) is an OMG standard for modeling decisions. It has three parts worth knowing:
- Decision requirements diagrams (DRDs) show how a decision breaks down: which sub-decisions feed it, what input data each needs, and which knowledge sources (policies, regulations) govern them.
- Decision tables with the hit policies above.
- FEEL (Friendly Enough Expression Language) for conditions and calculations, designed to be readable by analysts:
annual value > 2000,date(application date) - date(birth date) >= duration("P18Y").
flowchart BT
CD["Customer data"] --> EL["Offer eligibility\n(decision table)"]
PH["Policy: discount cap\n(knowledge source)"] -.-> EL
CD --> SC["Uplift scores\n(model)"]
EL --> OS["Offer selection\n(optimization)"]
SC --> OS
BU["Budget"] --> OS
style CD fill:#EEF0F7,stroke:#6366F1,color:#0F172A
style BU fill:#EEF0F7,stroke:#6366F1,color:#0F172A
style PH fill:#fff7ed,stroke:#f59e0b,color:#0F172A
style EL fill:#fff7ed,stroke:#f59e0b,color:#0F172A
style SC fill:#f0fdf9,stroke:#0D9488,color:#0F172A
style OS fill:#f0fdf9,stroke:#0D9488,color:#0F172A
DMN engines (Camunda, Drools, and several open-source and commercial alternatives) execute these models directly, so the diagram analysts review is the logic that runs. Even if you never adopt an engine, drawing the DRD is a useful exercise: it shows exactly where models, rules and optimization meet.
Rules as data, in code
You don’t need an engine to get most of the benefit. Store tables as data, evaluate them with a small interpreter, and return reason codes:
from dataclasses import dataclass
from typing import Callable
@dataclass(frozen=True)
class Rule:
id: str
when: Callable[[dict], bool]
allowed: frozenset[str]
reason: str
ALL = frozenset({"none", "discount_10pct_3mo", "discount_25pct_3mo",
"free_upgrade_1mo", "account_manager_call"})
NO_CALL = ALL - {"account_manager_call"}
ELIGIBILITY_V7 = [ # hit policy: UNIQUE (checked in tests)
Rule("R01", lambda c: c["in_collections"], frozenset({"none"}), "collections hold"),
Rule("R02", lambda c: not c["in_collections"] and c["discounts_12m"] >= 1,
frozenset({"none", "free_upgrade_1mo"}), "discount cap reached"),
Rule("R03", lambda c: not c["in_collections"] and c["discounts_12m"] == 0
and c["plan"] == "annual" and c["annual_value"] > 2000, ALL, "full eligibility"),
Rule("R04", lambda c: not c["in_collections"] and c["discounts_12m"] == 0
and not (c["plan"] == "annual" and c["annual_value"] > 2000), NO_CALL, "no call tier"),
]
def eligible_actions(customer: dict, table=ELIGIBILITY_V7):
hits = [r for r in table if r.when(customer)]
if len(hits) != 1:
# UNIQUE violated or a gap: fail closed, never guess.
raise ValueError(f"{len(hits)} rules matched {customer.get('id')}")
return hits[0].allowed, hits[0].id, hits[0].reason
Note the fail-closed behaviour. A gap (no rule matches) or an overlap (several match) is a bug, and the safe response for an eligibility table is to refuse and alert, not to fall through to a default that might allow something it shouldn’t.
Testing rules
Rules are code and need tests, but the tests look different:
- Table-driven cases written with the business owner: one test per row, plus boundary cases (annual value exactly 2000, exactly 1 prior discount).
- Completeness and overlap checks: generate inputs across the domain of each variable (including boundaries and missing values) and assert exactly one rule matches. For small tables you can enumerate; for larger ones, property-based testing (Hypothesis) finds gaps quickly.
- Regression on real traffic: before releasing a new table version, run it against last week’s decisions and diff the outputs. Every changed decision should be explainable by the intended change.
Governing rules
Rules change for business reasons, often urgently (“legal says stop offering X in region Y today”). Treat them like configuration with strong controls:
- Versioned and immutable. Every decision logs the table version that produced it. Never edit a version in place.
- Owned. Each table has a business owner who approves changes and an engineering owner who deploys them.
- Effective dates. Rules often apply from a date. Store
valid_fromandvalid_toinstead of deploying at midnight. - Reason codes everywhere. Every rule outcome has a stable code. They feed explanations, adverse action notices (Lesson 22) and analysis of which rules cost the most (add them as constraints in Lesson 7’s model and read the shadow price).
- Periodic pruning. Each quarter, list rules that never fired, rules that always fire together, and rules whose reason nobody remembers. Rule sets only grow unless someone actively shrinks them.
Rules are deterministic and auditable. The next layer is neither: language models. The next lesson covers where they fit inside a decision system and how to keep them from undermining everything above.