1.245 Rule & Policy Evaluation Engines#
Rule engines measured on six discriminators: CEL, JSONLogic, OPA, Cedar, Casbin, Zen, DMN — against a database column and a 100-line evaluator, which usually win.
At a glance#
| Library | How it works | Best for |
|---|---|---|
| The database (CHECK, generated columns, triggers, RLS) | Rules as schema, evaluated by the database on every write | Any rule where writers that never call your code must still obey it |
| Roll your own / simpleeval / asteval | An allowlisted walk over a Python expression tree | Engineer-authored rules in one runtime — the default answer for most teams |
| CEL (spec) / cel-python / cel-expr-python | A small non-Turing-complete expression language; four official implementations | One expression that must run in more than one runtime |
| JSONLogic | A rule as a JSON operator tree; a closed operator set, no I/O, no loops | A GENERATED rule that must travel between a browser and a backend |
| OPA / Rego | A Datalog-descended policy language; a sidecar, a server, or a Go library | Policy beyond authorization, where a sidecar is already part of the shape |
| AWS Cedar | A total, terminating authorization language; Rust core, Lean-formalized semantics | An authorization model that must be reasoned about rather than tested |
| Casbin / pycasbin | A PERM metamodel in a .conf file; policy rows in a table; simpleeval matchers | In-process Python authorization where policy rows belong in a database |
| GoRules Zen Engine | A JDM decision graph — tables, expressions, switches, function nodes | Analyst-editable decision tables evaluated in-process from Python |
| DMN (OMG standard) + its engines | Decision tables, seven hit policies, FEEL, three conformance levels | Hundreds of rules owned by domain experts, where the rules outlive the engine |
| Datalog (pyDatalog, Souffle, Logica) and miniKanren | Facts plus derivation rules, evaluated to a fixed point | Answers requiring recursion over relationships — and nothing else |
| Oso / Polar, business-rules, durable_rules, experta | A Prolog-descended authorization language; three Python rules libraries | Nothing — recorded because all four are still recommended |
What the research found
- The most-installed JSONLogic package for Python cannot run a single rule —
json-logic0.6.3 does 2,849,698 installs a month, has had no stable release since 2015-12-04, and raisesTypeError: 'dict_keys' object is not subscriptableon every call — its source uses Python 2 subscripting of a dict view. Worse, THREE distributions install a top-level module namedjson_logicand the last one installed silently wins, so a single-environment test gives the wrong answer. This survey’s first run reported the format working because a later install had overwritten the broken module. - Two of the six discriminators do not discriminate, and shortlisting on them narrows nothing — Every engine measured evaluated a rule loaded from a database row, and every engine cited documents the same. Report-only mode is a property of the caller in fifteen of sixteen candidates — one
ifstatement — with Gatekeeper’senforcementAction(deny/dryrun/warn/scoped) the sole exception, and it exists there precisely because an admission controller cannot be wrapped in a conditional. A shortlist built on ‘rules as data’ and ‘supports dry run’ has not eliminated anything. - No engine can preview a rule change against real records, and that is the capability people actually want — Every candidate answers ‘is this rule well-formed’. None answers ‘what would this rule do to my rows’, because that needs the rows and the engines do not have them. The answer is a schema decision available with any of them and with none of them: derive into a shadow column, diff it against the current one, count what moved. The same reasoning makes per-record provenance a column rather than a feature — and provenance plus a stored previous derivation is the ONLY thing in this survey that makes a rule change reversible.
- cel-python does not type-check, which is the property CEL is chosen for — Measured with an environment declaring every variable’s type, cel-python 0.5.0 accepted both a misspelled identifier and a number-versus-string comparison at compile time; only a syntax error was caught. Its README states the position: ‘Rather than try to pre-check types, we’ll rely on Python’s implementation.’ The reference Go implementation combines parse and check and returns type errors. The standard says checkable; this implementation is not — and it is what runs.
- A dialect that looks like Python is more expensive than one that does not —
9 ** 9 ** 2evaluates to 1.966271e+77 in Python and simpleeval, and to 1.500946e+17 in rule-engine 5.0.2 — right-associative versus left. Same characters, no error, sixty orders of magnitude apart. A reviewer who knows Python reads that rule confidently, and the confidence is the defect. This is the two-language cost in its sharpest form, and it is the largest cost in the category and the one no feature matrix has a column for.
What the research recommends
Default — Do not adopt a dedicated rules engine. For the common case a derived column with its derivation recorded, or a small sandboxed expression evaluator in the language you already write, answers all six discriminators without a second dialect. The discipline that matters is ONE COPY of the rule, and no engine on this list enforces that for you.
| Use case | Pick |
|---|---|
| hand maintained lookup table | A derived column plus a derivation column. The rows the rule cannot reach become a finite worklist instead of unknown risk. |
| one rule written by several services | A generated column or trigger — the only mechanism that reaches writers who never call your code. |
| analyst must edit rates | A decision table. DMN if a JVM or service boundary is acceptable and the domain uses hit policies; Zen if it must be in-process Python and the tables are genuinely first-match. |
| same rule in browser and backend | CEL through the official binding, or JSONLogic if the rule is GENERATED from a schema. Pin a conformant implementation on each side. |
| authorization model too large to review | Cedar, for its analysis tooling — the only thing here that reasons over all inputs rather than sampled cases. Put it behind a service boundary rather than embedding the unofficial Python binding. |
| policy deviation before blocking | The enforcement layer’s own dry-run mode, because you cannot wrap an admission controller in an if. Or a generated column promoted to a constraint once the violation count reaches zero. |
| rules drafted by a model | Rank by whether it catches an unknown name: SQL’s own parser, then rule-engine with a type resolver, then simpleeval with a name check walked off the parsed tree. NOT JSONLogic, whose misspelled field returns False forever. |
| one person maintains everything | Nothing. Attention is the binding constraint and every dialect spends it. |
| Strategic path | Pick |
|---|---|
| conservative | No dedicated engine. Rules as expressions in the language the team writes, output stored with its derivation. Ends when a non-engineer must edit a rule, the rule must leave your process, or an authorization model outgrows review. |
| standards first | A specified language — DMN, CEL or Cedar — so the rule outlives the engine. In Python this usually means a service boundary, and that should be decided rather than discovered. |
| capability first | Adopt only for one of three things the baselines cannot do: a table a non-programmer edits, one rule across several runtimes, or a policy change reasoned about over all inputs. |
Avoid
| Library | Reason | Risk |
|---|---|---|
| oso (PyPI) | Deprecated 2023-12-18; last code commit 2024-06-13; successor is a hosted service with a different dialect | HIGH |
| json-logic (PyPI) | Python 2 only; raises on every call; no stable release since 2015-12-04 — and it shares a module name with two working distributions | HIGH |
| cedar-policy (PyPI) | An inert stub: one 0.0.1 release from 2023 with project URLs pointing at a nonexistent repository. Not a Cedar binding. | HIGH |
| casbin (PyPI) | Superseded by pycasbin at v2.0.0 on 2025-08-10, with no deprecation notice on the old name. Still installs, a major version behind. | MEDIUM |
| business-rules, durable_rules, experta | Stalled or abandoned: 267 forks and no push since 2024-08-13; 206 open issues and a 2020 release; nothing since 2019 | MEDIUM |
| CozoDB | No maintainer commit since 2024-12-04 with nine-plus unanswered pull requests, including a silent-data-corruption report, and a README still inviting adoption | HIGH |
catches a misspelled field name
- yes: SQL (scratch database, by name); rule-engine (typed resolver); Cedar (validator, cited); OPA (opa check, cited)
- no: cel-python 0.5.0 (even with variables declared); Zen Engine 2.0.2; pycasbin 2.8.0 (fails on the first enforce, not at load)
- silently returns false: JSONLogic
hostile expressions refused
- cel-python: 5 of 5
- simpleeval: 5 of 5
- asteval: 5 of 5
- zen-engine: 4 of 5 — an attribute walk returned None
- rule-engine: 4 of 5 — it EVALUATED the exponent bomb
engine reports which rule fired
- yes: Zen Engine (trace named the row _id and index); pycasbin (enforce_ex returned the matched policy row); SQL (a derivation column, written by the same trigger); Cedar (diagnostics.reason, cited); OPA (explain + decision logs, cited)
- no: cel-python; JSONLogic; simpleeval; asteval; rule-engine
one string two meanings
- expression: 9 ** 9 ** 2
- python and simpleeval: 1.966271e+77 (right-associative)
- rule engine 5 0 2: 1.500946e+17 (left-associative)
missing field vs null
- field present but null: simpleeval False, rule-engine False, cel-python False, jsonlogic False, sqlite generated column NULL — invisible to WHERE flag=1 AND to WHERE flag=0
- field absent entirely: simpleeval raises NameNotDefined, rule-engine raises SymbolResolutionError, cel-python raises CELEvalError, jsonlogic false, silently
jsonlogic ports one module name
- json-logic 0.6.3: TypeError on EVERY row — Python 2 only; 2,849,698 installs/month
- json-logic-qubit 0.9.1: all five rows correct
- panzi-json-logic 1.0.1: all five rows correct
Explainer
What is a rule engine?#
If software libraries were tools in a hardware store, rule engines would be in the aisle with the templates and jigs — the things you set up once so that every cut afterwards comes out the same, and so that changing the shape means moving the jig rather than re-teaching each person who uses the saw.
Every term used below, defined before it is used#
A rule. A statement that decides something about a record. “A shipment over 20 kilograms needs a signature.” It has a condition and an outcome, and it is expected to be applied consistently to many records.
A record. One row, document or object the rule is about. A shipment, an invoice line, a user’s request.
A fact set. The collection of records, and anything else the rule is allowed to look at.
Evaluation. Running a rule against a record and getting an answer.
A rule engine. Something that stores rules separately from the program, and evaluates them on demand. The separation is the whole idea: the rule can change without the program changing.
A dialect. The language a rule is written in. Every engine has one, and it is almost never the language the rest of the program is written in.
Provenance. For a given record, an account of how its answer was arrived at — the rule decided it, or a person did.
A derived column. A column in a database whose value is computed from other columns rather than supplied by whoever wrote the row.
A constraint. A rule the database enforces on every write, refusing any that violates it.
The problem#
A business rule changes more often than the software around it does.
Tax bands are revised. A shipping surcharge moves. An eligibility threshold is renegotiated. A category is redefined by someone in a different department who has never seen the code.
When the rule lives inside the program, changing it means a code change, a review, a release and a deployment — and each of those is a queue. A rule that should have changed on Monday changes the following Thursday, and the difference is money, or a wrong answer, or both.
Three specific pains follow, and they arrive in this order.
The rule ends up written more than once. The first implementation is in the service that needed it. The second is in the importer, written by someone who did not know about the first. By the fourth, they disagree, and nobody notices, because a discrepancy between two classifications does not raise an exception — it produces two plausible answers depending on which path a record took.
The rule ends up written by hand as a table. Rather than express the logic, somebody enumerates the answers: a few hundred rows mapping a code to a category, filled in over three years. It is not wrong exactly, but nobody can say what it means, new rows are added by copying similar ones, and a policy change means re-deciding several hundred rows by hand.
The person who understands the rule cannot change it. They describe it in a ticket, an engineer implements what they understood, and the boundary cases are lost in translation — because the person who knows where the edges are is not the person reading the diff.
The solution categories#
Five broad shapes, and choosing between them is most of the decision.
Embeddable expression evaluators. The rule is a small expression — a string — evaluated in your own process against a record you hand it. Fast, simple, and the rule is trivially stored in a database column. Good when a rule is one condition and one answer.
Policy runtimes. The rule is a policy document about who may do what to which thing. They grew up around authorization and they carry assumptions from it: an actor, an action, a resource. Often run as a separate service rather than a library.
Decision tables. The rule is rows and columns: these conditions produce this outcome. This is the only shape in the category that people who do not write software reliably read and edit, which is why a formal standard exists for it. If the rule already exists as a table in a policy document, this shape loses nothing in translation.
Logic programming. Facts plus rules that derive new facts, computed to exhaustion. Its distinguishing ability is following relationships an unbounded number of steps — who can eventually reach what, through however many layers of grouping. Overkill for a rule about one record, and the only option when the answer requires the recursion.
The database you already have. Constraints, derived columns and triggers are rules, stored in the schema, applied by the database. Rarely counted as a rule engine and frequently the best answer, for one reason given its own section below.
The trade-off that runs through all of them#
Every one of these except the last introduces a second language.
That cost is easy to underestimate because the visible part — learning it — is the small part and the only part anyone budgets for. The parts that last are:
- Every change needs a reviewer who reads the dialect, and reviewers are scarcer than authors.
- Every incident needs a debugger who reads the dialect, at whatever hour the incident happens.
- A dialect that resembles a familiar language is more expensive than one that does not, because the resemblance produces confident misreadings. Two languages in this category use identical-looking syntax for exponents and disagree about what it means, silently, by sixty orders of magnitude.
- The dialect is a bet on a project. If the project stops and the language was specified, you migrate. If it stops and the language was not specified, you rewrite.
That last point is not hypothetical. In the two years to 2026, one widely used rule library was deprecated with no successor for its policies, one major engine reached end of life while its users’ rules survived intact on other engines, and one policy language was redefined by a major release. The difference between those outcomes was entirely whether the language existed independently of the project.
Why the database keeps winning#
A rule expressed as a database constraint or a derived column has one property nothing else does: every writer is subject to it.
An application, a batch job, a notebook, a colleague with a SQL client, a restore, a bulk load. Every other approach evaluates only when some program remembers to call it — and the programs that will not remember are exactly the ones nobody can enumerate.
The recurring failure this whole category is bought to fix is rarely “our rule was wrong”. It is “our rule was implemented in four places and the fourth one did something else.” A constraint cannot be bypassed by forgetting.
The price is real: the rule now changes at the speed of a database migration, the syntax is tied to that database, and nobody outside engineering can edit it. Whether that price is worth paying is the whole question, and it depends on how often the rule changes and who needs to change it.
Store the derivation, whichever shape you pick#
Whichever shape you pick, store what the rule decided, and store how it was decided.
A column holding the answer, and a second column saying whether the rule produced it or a person did. Two columns, one extra assignment in a loop you were writing anyway.
That single decision is what turns a rule into something you can change safely:
- Coverage becomes measurable. A rule that decides most records with the rest named is usable. A rule that decides “most” of them is a guess.
- Human judgment is protected. The records a rule cannot decide are the interesting ones — the exception, the negotiated case. Marking them keeps the next run from quietly overwriting them.
- A change becomes a diff instead of a new answer. With the previous result stored, editing the rule shows you exactly which records moved. Without it, you get a new answer and no way to see what changed.
No rule engine does this for you. It needs the records, and the engines do not have them.
When you need this domain#
You probably do if a rule in your system is implemented in more than one place; if a lookup table has grown past what anyone can check; if a policy change means somebody re-deciding hundreds of rows by hand; or if the person who understands a rule has to file a ticket to change it.
You probably do not if the rules number in the tens, change a few times a year, and are written and read by the same people. Then a function in the language you already use, with its output stored and its derivation recorded, answers everything a rule engine would — without a dialect, a dependency, or a project whose governance you have to keep watching.
The most common mistake in this domain is adopting early, on the strength of an argument about a problem the reader does not yet have. The second is adopting the best-known name, which in this domain is unusually likely to be a project that stopped: popularity and maintenance are almost entirely decoupled here. The most-installed package of its kind for Python has had no working release in a decade.
S1: Rapid Discovery
What is compared here#
Sixteen ways to answer one question — does this record satisfy this condition? — where the condition is expected to change more often than the program around it.
They do not look like peers. One is a specification with several implementations, one is a Go server you talk to over HTTP, one is a Rust crate with a formally verified core, two are ways of using a database you already have, and one is a hundred lines you write yourself. Grouping them is the point: a team choosing here is choosing between those shapes, not between features.
What counts as a rule engine#
Three properties, and a candidate needs all three:
- The condition is data at some layer. A rule is a string, a JSON document, a table row or a schema object — something that can be read, written, diffed and versioned without recompiling the program that evaluates it.
- Evaluation is a separate step from authoring. Somebody writes the condition; something else runs it, later, against records the author never saw.
- The result is a decision about a record, not a search for a record. A solver looks for an assignment that satisfies constraints; an engine here is handed the assignment and asked whether it holds.
The third property is the boundary against constraint solving (surveyed separately) and it is a sharper line than it sounds. Deciding whether a shipment needs a signature is this category. Deciding which of several hundred shipments to load on which truck is not.
The five shapes#
| shape | what a rule looks like | where it runs |
|---|---|---|
| Embeddable evaluator | one expression, in a small language | in your process |
| Policy runtime | a policy document, in a policy language | a sidecar, a server, or a linked library |
| Decision table | rows and columns | in your process, or a hosted editor’s |
| Logic program | facts and derivation rules | a solver, over a loaded fact set |
| The database | a constraint, a generated column, a trigger | inside the database, under every writer |
The sixth entry is the absence of a shape: write your own minimal evaluator. It is scored here as a candidate, because for a large fraction of the situations that send people looking, it wins, and because a survey that omits it is not comparing against the real alternative.
The six questions that decide it#
Feature matrices in this category are unusually useless, because almost every engine can express almost every condition. What separates them is what happens around evaluation.
- Can rules be stored as data, in the same database as the rows they govern — or only as code and config shipped with a deploy?
- Does evaluation require I/O? Most engines assume a pure function over facts handed in. An engine that must read a row mid-decision has consequences: reentrancy, latency, and a decision that is no longer reproducible from its inputs.
- Provenance. Can anything report which records the rule reached and which needed a human — not a pass/fail, but a per-record account of who decided?
- Authoring and preview. Can a rule be validated and dry-run before it goes live, and is the language small enough that something other than a programmer can write in it safely?
- Enforce or observe. Can the same rule run in report-only mode, so a team can see what would have been blocked before anything is?
- Two-language cost. Every candidate except the last one introduces a second dialect alongside whatever a team already writes. What that costs is a question with an answer, and it belongs in the comparison rather than in a footnote.
S2 is organized around these six. S1 profiles each candidate against the same headings so they can be read side by side.
What each profile states#
What it is, what a rule looks like in it, how it is packaged and reached from a program, its registry position as of 2026-09-04, and what you give up. No installation steps and no code samples — S1 is a comparison, not a manual.
Out of scope#
- Constraint solvers and optimizers. Different problem: search for a satisfying assignment, rather than evaluate a predicate against one.
- Single-record input validation. Checking that a submitted form has a well-formed email address is a neighboring category with its own libraries. The line is whether the rule can see beyond the record in front of it.
- Workflow and process engines. A BPM platform contains a decision engine; the decision engine is in scope and the process orchestration around it is not.
- Data-quality frameworks. Expectation suites over a table are a real and adjacent thing, and they answer “is this dataset acceptable” rather than “what does this rule say about this row”.
- Feature flags. Same mechanism, different question — flags target a rollout, not a fact about a record.
CEL — Common Expression Language#
What it is#
A small expression language designed to be embedded in configuration, evaluated fast, and reasoned about. It is the closest thing this category has to a widely deployed standard for single-expression rules — Kubernetes, Envoy, protovalidate and Google Cloud IAM all evaluate CEL — and it is the only candidate here where the specification and the implementation have to be discussed separately, because they disagree.
The whole project moved. All official CEL repositories were consolidated into a dedicated
cel-expr GitHub organization on 2026-06-16. cel-spec, cel-cpp and cel-java
redirect; google/cel-go returns a hard 404 with no redirect. Any citation written before
mid-2026 points at the wrong place.
What the specification guarantees#
Three documents make three different-strength claims and only one of them is normative. The language definition’s own list, which is the one to quote:
- memory-safe — programs cannot access unrelated memory
- side-effect-free — “a CEL program only computes an output from its inputs”
- terminating — programs cannot loop forever
- gradually typed — an optional type-checking phase before runtime can reject some programs that would violate type constraints
The widely repeated “evaluates in linear time” is not in that list, and the specification contradicts it. Its own performance section states that macros “can lead to exponential behavior when nested or chained”, gives worked examples that are exponential in both time and space, and notes that string concatenation is quadratic. The correctly qualified form is in the Go implementation’s FAQ: CEL evaluates linearly with respect to expression and input size when macros are disabled.
Kubernetes does not rely on the unqualified claim. It layers a static cost estimator that
rejects a rule at admission time if it looks prohibitively expensive, and a runtime
instruction budget that halts an interpreter that executes too many instructions. The error
message it emits — asking the author to add maxItems, maxProperties or maxLength — is
the clearest available evidence of what the guarantee is actually worth in production.
Similarly, “CEL has no I/O” is true of the language and routinely false of deployments. Kubernetes injects an authorizer library into the CEL environment so a validation expression can perform an authorization lookup. The guarantee is about the language; the host decides what the language can reach.
Governance#
Openly governed and small. A CEL Language Council of four people, three of them at Google, one at Meta; new members added by unanimous vote; syntax and semantic changes require a design document and Council review at a meeting held every three weeks. There is no foundation.
Spec releases are versioned and frequent — v0.25.3, released 2026-08-13 — and there has never been a 1.0 in eight years. A conformance suite exists as 33 test-data files, described by the spec as “a complementary specification of the CEL language in the form of executable software.”
Where it runs#
| host | status |
|---|---|
| Kubernetes CRD validation rules | GA in v1.29, released 2023-12-13 |
| Kubernetes ValidatingAdmissionPolicy | GA in v1.30, released 2024-04-17 |
| Envoy RBAC | policy carries either a parsed AST or a type-checked AST, as protobuf |
| protovalidate | current; supersedes the archived protoc-gen-validate |
| Google Cloud IAM Conditions | a documented subset of CEL |
| Firebase / Firestore rules | a language based on CEL, further restricted — and historically CEL’s parent rather than its child |
Envoy’s proto is the clearest production evidence for two of this survey’s discriminators at once: a rule is data, and the type-check is a separable phase whose output is also data. The spec commits to keeping the AST protobufs wire-compatible in perpetuity.
The Python situation, which changed in 2026#
There are now two, and most existing write-ups predate the official one.
cel-expr-python — the official binding, a wrapper over the C++ implementation. 0.1.3,
released 2026-06-25; first release 2026-02-13; Apache-2.0; Python ≥3.11; no dependencies;
prebuilt wheels for three platforms; 338,181 downloads a month. Its API declares variable
types up front, which is the shape that makes a check phase possible.
cel-python — the pure-Python implementation, at cloud-custodian/cel-python. 0.5.0,
released 2026-01-31; Apache-2.0; Python ≥3.10; six dependencies including a compiled regular
expression library. 174 stars. Written by an individual author, whose email is still the
package’s author field; the repository shows parent: null and source: null with a
created_at of 2020-06-16 and every commit through 2023 by that author, with the
organization’s maintainers first appearing 2024-05-21 — the signature of a transfer
rather than a fork. Released 0.1.x through mid-2021, then nothing for three and a half years,
then four releases from 2025-02.
Three things a chooser needs to know about the pure-Python one:
It does not type-check, and says so. Its README states: “Rather than try to pre-check
types, we’ll rely on Python’s implementation.” compile() builds an AST and raises only on
syntax errors. Measured: with an environment declaring every variable’s type,
compile() accepted a misspelled identifier and a comparison of a number against a string;
only the truncated expression was rejected (measured 2026-09-04; cel-python 0.5.0;
harness/1-245-rule-policy-evaluation-engines/followup.py, cel_declared_environment).
The Go implementation combines parse and check into one step and returns type errors. The
property CEL is chosen for is absent from this port.
Its conformance position is honest and incomplete. It imports the specification’s test data and converts it into runnable scenarios — 2,430 of them, of which 1,184 (about 49%) are tagged work-in-progress and skipped by the default test command. The gaps concentrate in extensions and protobuf handling; the basic conformance file is 43 scenarios with none skipped. The project makes no claim to pass the suite, which is the right posture.
Its command-line entry point is broken in 0.5.0. The wheel declares a cel-python
console script importing a module the wheel does not contain; running it raises
ModuleNotFoundError. The documented module invocation works.
Other implementations: cel-rust (community, 670 stars, MIT) and
common-expression-language, a Rust-backed Python binding at 0.8.0 (2026-08-19).
Trade-offs#
You get the widest-reach small expression language in the survey, a real specification with versioned releases and a conformance suite, a canonical wire format for a checked AST, side-effect freedom in the language, and skills that transfer to Kubernetes and Envoy.
You give up: a stable 1.0 after eight years; the linear-time guarantee unless macros are disabled; the no-I/O guarantee the moment a host registers a function; and, if you reach it through the pure-Python port, the type-checking phase that is the language’s main advantage. The official Python binding is seven months old and requires Python 3.11.
Where it is weak: anywhere the rule needs an output richer than one value — CEL evaluates an expression, not a decision table — and anywhere the deployment cannot take a compiled extension and cannot accept a port that does not check types.
The database itself#
What it is#
Four mechanisms every relational database has already shipped, which together cover most of what a rule engine is bought for: CHECK constraints, generated columns, triggers, and row-level security. They are grouped as one candidate because they are one acquisition decision — the database is already there — and they divide cleanly by job.
| mechanism | expresses | fires | can see |
|---|---|---|---|
| CHECK constraint | “this row is not allowed” | on write | the row |
| generated column | “this value is derived” | on read or write | the row |
| trigger | “when this happens, do that” | on write | any row, any table |
| row-level security | “this reader sees these rows” | on read | the row, plus session state |
What a rule looks like#
An expression in SQL, stored in the schema. A generated column carries a derivation rule in the table definition; a CHECK carries a predicate; a trigger carries a procedure. All three are DDL — versioned by the migration tool a team already runs, reviewed in the same pull request as the column they are attached to.
The property nothing else in this survey has#
Every writer is subject to it. An application, a batch job, a notebook, a colleague with
a SQL client, a restore, a bulk COPY — all of them go through the constraint. Every other
candidate here evaluates only when some program remembers to call it.
That is the entire argument for this row of the table, and it is a strong one. The recurring failure this category is bought to fix is not “our rule was wrong”; it is “our rule was implemented four times and the fourth writer did something else.” A constraint cannot be bypassed by forgetting.
Measured: a generated column derived the same answer for a row inserted by a client that
had never seen the application code (measured 2026-09-04; SQLite 3.45.1;
harness/1-245-rule-policy-evaluation-engines/probe.py, D5_applies_to_every_writer).
Provenance is a column#
The per-record question — did the rule reach this row, or did a person fill it in? — is
answered here by writing the answer down next to the value. A derivation column set by the
same trigger that computes the value turns “which records did the rule reach” into a
GROUP BY rather than a log search. Measured: a trigger wrote 'rule' for a row whose
lookup succeeded and 'manual' for a row with a NULL key, in the same statement that
computed the value (measured 2026-09-04, same harness, D2).
No other candidate in this survey produces that as a side effect of running. Two produce it in the response and the rest require the caller to build it.
Preview is a scratch database#
A rule can be checked before it ships by creating it in a throwaway database and letting the
real parser judge it. Measured: a generated-column expression naming a misspelled column
was rejected with no such column, and a truncated expression with a syntax error, both
without touching production data (measured 2026-09-04, same harness, D4_compile_check).
Two of the dedicated engines cannot catch the misspelled name at all.
Enforce and observe are two different statements#
The same predicate is a constraint when it should block and a WHERE clause when it should
only report. A team that wants visibility before enforcement writes the query first, watches
the count, and promotes it to a constraint when the count reaches zero. That is the
migration path the dedicated engines charge a feature for.
Trade-offs#
You get universal application, provenance for free, a preview with the database’s own parser, no second language for anyone who already reads SQL, and no new dependency, service or upgrade treadmill.
You give up four things, and they decide whether this is the answer:
- Rules change at the speed of migrations. A generated column is DDL. Changing it means
ALTER TABLE, a review, and on a large table a rewrite. If the rules are expected to change weekly and the schema monthly, that mismatch is the whole problem. - Non-programmers cannot author them. SQL DDL is not a business-user surface, and no database ships a decision-table editor.
- Portability. Generated-column syntax, trigger languages and RLS differ between engines, and the expression that is legal in one is not in another. A rule written here is married to the database.
- Three-valued logic. SQL’s NULL is neither true nor false, and a derived column over a
NULL input is NULL rather than false. Measured: a row whose input was NULL produced a
NULL flag, and that row is invisible to both
WHERE flag = 1andWHERE flag = 0(measured 2026-09-04, same harness,missing_and_null). Every general-purpose engine in this survey returnedFalsefor the same row. This is the single most common way a database-native rule surprises someone who wrote it expecting Python semantics.
Where it is weak#
Anywhere the rule must span records that are not in one database. A policy about a request, a token and a service is not a table constraint. So is anything a person outside engineering is supposed to edit, and anything that has to give the same answer in a browser and a backend.
Decision tables: DMN and Zen#
A decision table is rows of conditions and the outputs they produce. It is the only rule format in this survey that a non-programmer reliably reads, which is the entire reason the shape exists and the reason a twenty-year-old standard sits behind it.
DMN — the OMG standard#
DMN 1.5, OMG document formal/24-01-01, published August 2024. Versions 1.6 and 1.7
exist as betas (“In Process”); 1.5 is the current formal specification. Note that the
document number encodes January 2024 while the page publishes August 2024 — cite the version
and the page, not the number.
DMN defines four things worth knowing before choosing an engine:
Decision Requirements Diagrams — the graph of which decisions feed which.
Decision tables with seven hit policies. This is the part that is hard to reproduce and easy to underestimate:
| U | UNIQUE | rules are disjoint; exactly one can match |
| A | ANY | rules may overlap but all matches agree, so any may be used |
| P | PRIORITY | multiple match; return the one with the highest output priority |
| F | FIRST | multiple match; return the first in rule order |
| C | COLLECT | return all hits, optionally aggregated with SUM, MIN, MAX or COUNT |
| O | OUTPUT ORDER | return all hits ordered by decreasing output priority |
| R | RULE ORDER | return all hits in rule order |
U, A, P and F return one result; C, O and R return several. A domain that already thinks in PRIORITY tables — insurance underwriting, tariff classification, eligibility — is thinking in a shape that only DMN specifies.
FEEL, the Friendly Enough Expression Language, and S-FEEL, its arithmetic-and- comparison subset.
Three conformance levels. CL1 is diagrams plus non-executable tables whose logic may be prose — a handoff artifact. CL2 adds executable tables with S-FEEL. CL3 adds full FEEL, the complete boxed-expression set, relations and function invocation.
The conformance instrument is real, published and current. The DMN TCK is a community-maintained suite of 3,391 test cases with published per-engine results, organized by construct and conformance level, run by a working group of vendors and practitioners. Nothing else in this survey has an equivalent. Note that the repository carries no license file, which matters to anyone planning to vendor the corpus.
Which engines are alive#
From the TCK results page and corroborated against repositories and registries:
| engine | TCK score | submitted | note |
|---|---|---|---|
| jDMN 10.0.0 | 3391/3391 | 2026-04-27 | Goldman Sachs, Apache-2.0, last push 2026-09-03 |
| Trisotech DES 12.12.4 | 3390/3391 | 2026-01-29 | commercial, closed |
| IBM BAMOE 9.5.0 | 3388/3391 | 2026-06-30 | the Red Hat Decision Manager lineage, now IBM |
| Apache KIE (Drools) 10.2.0 | 3388/3391 | 2026-04-27 | still in Apache incubation |
| ÐecisionToolkit 0.3.0 | 3374/3391 | 2026-04-29 | open source, Rust |
| QuantumDMN 1.0.0 | 3367/3391 | 2026-02-20 | Go, with its own FEEL interpreter |
| Camunda Platform 7.21.0 | 2741/3391 | 2024-07-04 | product end of life |
Camunda 7 is over, with dates. Community Edition end of life was announced for October
2025 with a final v7.24 release on 2025-10-14; Maven Central confirms
camunda-engine-dmn frozen at 7.24.0 with a last-updated timestamp of exactly that date. The
GitHub repository was archived on 2025-11-04. Enterprise Edition had a separate, extended
schedule — the two must not be conflated. A community fork, CIB seven, continues the codebase
and released v2.2.0 on 2026-06-01.
Camunda 8 still does DMN, evaluated inside the workflow engine with FEEL as the only expression language and DMN 1.3 models. Its hit-policy coverage is incomplete: RULE ORDER and COLLECT with aggregators work; PRIORITY and OUTPUT ORDER do not, although the modeler will still let you select them. That is a UI-versus-engine mismatch users meet in production.
Drools became Apache KIE. The Kogito runtimes repository is archived and redirects into
apache/incubator-kie, which now houses Drools, OptaPlanner, jBPM and Kogito together — 6,312
stars, releasing actively, and still incubating. Its 10.2.0 announcement claims support for
“the latest DMN 1.6 specification”; OMG lists 1.6 as a beta. The engine is implementing a
draft and describing it as current.
DMN from Python#
There is no conforming Python engine, and there never has been. Every TCK submitter is Java, Rust or Go. If CL3 conformance is a requirement, the decision engine is a service call away from Python, not a library import.
What exists is pyDMNrules (1.4.5, released 2026-08-22; 47 stars; single maintainer;
12,117 downloads a month). Two facts decide whether it is usable:
- It reads DMN from an Excel workbook, not from DMN XML. The package summary says so outright. A model authored in a DMN tool does not load.
- It is GPLv3 — the only copyleft license in this survey — which rules it out of proprietary embedding. Note the PyPI classifier says GPLv3 while GitHub reports the license as unassigned; the classifier is the authoritative signal and the mismatch is worth flagging.
Its FEEL support comes from pySFeel, last released 2022-09-18, which implements S-FEEL.
That places it around conformance level 2. SpiffWorkflow (3.2.0, 2026-08-10) also evaluates
decision tables, as one component of a BPMN engine rather than a decision engine.
Trade-offs#
You get the only non-programmer-editable rule format in the survey, a specification with a decade of tooling behind it, seven hit policies nobody else implements, and a public conformance suite that lets you check a vendor’s claim rather than believe it.
You give up weight and reach. A conforming engine is a JVM, a Rust binary or a Go service. The tooling that makes DMN worth having — modelers, simulators, coverage — is mostly commercial. And the ecosystem is unusually turbulent for a mature standard: the best-known open-source engine is end-of-life, the flagship open-source successor is still incubating, and the enterprise lineage changed owners.
GoRules Zen Engine#
zen-engine 2.0.2 (PyPI, 2026-08-24); Rust crate 2.0.1. MIT. 1,963 stars, created
2023-03-29. 475,848 downloads a month.
A decision-graph engine with a vendor-published Python binding — the only decision-table engine in this survey that is a first-class Python library rather than a service. A decision is a JDM document: a JSON graph of nodes and edges. Node types are input, output, decision table, expression, function, switch, and a reference to another decision.
Hit policies are two: first and collect. That is the whole set. No UNIQUE, ANY,
PRIORITY, OUTPUT ORDER or RULE ORDER, and no COLLECT aggregators. Version 2.0 added
per-column collect so a table can collect on selected outputs. This is the sharpest
functional gap between Zen and DMN, and a domain that already models in PRIORITY tables
will have to be remodeled.
Cell expressions are written in the ZEN Expression Language, GoRules’ own. It is not FEEL and not CEL; family resemblance is not compatibility.
Rules as data: yes, directly. create_decision() takes a JDM document as a JSON string,
so a decision pulled from a database column needs no filesystem. A loader callback is the
documented path for fetching a decision by key. Measured: a JDM document stored in a
SQLite row was loaded through a custom loader and evaluated, and the loader received the
decision key (measured 2026-09-04; zen-engine 2.0.2;
harness/1-245-rule-policy-evaluation-engines/probe.py, D1 and D2).
Evaluation is NOT pure, and this is the decisive contrast in the survey. A function
node runs TypeScript with an http client, a 5,000 ms default timeout, and dayjs,
big.js and zod available. The documentation’s own example fetches exchange rates from a
third-party API mid-decision. A graph built from tables, expressions, switches and
sub-decisions is pure; a graph containing one function node is not, and it inherits that
API’s latency, availability and non-determinism. No documented flag prohibits function nodes
at evaluation time.
Provenance is a per-node trace. evaluate(input, {"trace": True}) returns result,
performance and trace, with input, output and timing per node. Measured: the trace
for a matched row named the rule’s _id, its index, and the input values it consulted
(measured 2026-09-04, same harness, D3) — so at least in this engine version the trace does
identify the row that fired rather than only the node.
Preview exists but reports rather than raises. validate_expression() returns None for
a clean expression and an error dictionary for a broken one — {'type': 'parserError', 'source': ...}. Measured: a truncated expression and an unbalanced parenthesis were both
caught this way, while a misspelled variable name validated clean (measured 2026-09-04, same
harness, D4_compile_check). Two consequences: a caller that only catches exceptions will
conclude every expression is valid, and unknown names are not checked at all. The commercial
platform adds a simulator, per-rule test coverage and a simulate-decision API.
Maintenance is strong and the version numbering is not. Seventy-two releases since 2023,
typically two to four a month. The 1.0 line never shipped stable — it went from
1.0.0-beta.13 straight to 2.0.0 on 2026-08-20 — and the vendor’s changelog calls 2.0 “a
drop-in upgrade” with no migration steps. The only break is Rust-side: arbitrary_precision
is no longer a default feature. The crate sits at 2.0.1 while Python and npm are at 2.0.2, a
bindings-only patch.
The format is documented and singly vendored. A JSON Schema for JDM exists, the editor component is open source, and the engine is MIT. The README is candid about governance: “The JDM standard is growing and we need to keep tight control over its development.” That is a reasonable position and it is not what “standard” means elsewhere in this survey — the Oso entry is what a singly-vendored language looks like when the vendor’s plans change.
Trade-offs#
You get a real Python library, decision tables that a business analyst can read, a per-node trace, batch evaluation where one bad input does not fail the batch, an editor component you can embed, and a release cadence nothing else here matches.
You give up two of DMN’s seven hit policies at best, purity if anyone reaches for a function node, a validation step that catches unknown names, and vendor neutrality — the format’s governance is stated as deliberate vendor control.
JSONLogic#
What it is#
A rule expressed as a JSON document. {">": [{"var": "weight_kg"}, 20]} is a rule; it is also
a value; it is also a database column. That equivalence is the whole idea and it is a good
one — a rule you can serialize, ship to a browser and a backend, store in a row, and generate
from a program without a parser.
The design constraints are stated plainly by the project and are worth quoting because they
are the safety argument: rules “only have read access to data you provide, and no write access
to anything”; there are “no setters, no loops, no functions or gotos”; “one rule leads to one
decision, with no side effects and deterministic computation time”; and “we never eval()”.
There is no specification, and there never has been#
The project’s site is a GitHub Pages branch last modified 2024-07-09. It holds an
operator list, a truthiness page, and tests.json — a 17 KB fixture file. That fixture is
the specification. There is no version string, no changelog, and no document defining what
any operator means when the data is imperfect.
The reference JavaScript implementation is at 1,480 stars and 9.5 million npm installs a month, with its last release on 2024-07-09 and, before that, roughly one substantive commit in four and a half years. The maintainer’s most recent public statement on the project’s status is from 2020: “my use case in my day job has pretty much plateaued, so we’re not pushing lots of new changes.”
What filled the gap#
A json-logic GitHub organization, created 2024-12-04, whose stated mission is “to
foster collaboration among maintainers and users of JSON Logic implementations across
platforms to develop a more rigorous specification”, because “with time, inconsistencies and
ambiguities have emerged between different implementations.” It names the ambiguities:
keys containing dots, truthiness edge cases, whether an iterator can see its parent scope.
In twenty-one months it has not produced a specification. Its roadmap issue has been open since the day the organization was created. What it has produced is arguably more useful: a cross-language compatibility suite of 1,138 cases with runners for eight languages, published with per-implementation scores.
Read those scores carefully, because the published table is misleading in three ways at once — it attributes implementations to the wrong languages, prints the denominator as 1,127 when it is 1,138, and tests against an expanded language rather than the original. Recomputed from the raw results (report generated 2026-08-31): a Rust implementation scores 1,138 of 1,138; the leading JavaScript alternative scores 1,127; and the reference implementation scores 782, or 68.7%. That last number does not mean the reference is broken. It means about a third of the suite tests behavior the 2015 language never had, which is what “no specification” looks like when someone finally writes tests.
The one versioned JSONLogic specification ever written is CertLogic, at version 1.3.3,
produced for the EU digital COVID certificate scheme and now archived — the project wound
down in 2023. It is instructive rather than usable: a restricted subset, keeping thirteen
operators and dropping twenty, including ==, or, map, filter and every aggregate.
It tightened truthiness so that values which are neither truthy nor falsy cause a throw, and
it added a validation pass that checks an expression before evaluating any operand —
independently arriving at the same conclusion this survey’s measurements reach.
The Python situation is worse than the format’s#
Three PyPI distributions install a module named json_logic, and whichever pip resolves
last silently wins. Nothing warns.
| distribution | latest | downloads/mo | module |
|---|---|---|---|
json-logic | 0.6.3, 2015-12-04 | 2,849,698 | json_logic |
panzi-json-logic | 1.0.1, 2021-09-12 | 591,335 | json_logic |
json-logic-qubit | 0.9.1, 2018-08-15 | 506,167 | json_logic |
python-jsonlogic | 0.2.0, 2026-08-30 | 19,732 | jsonlogic |
Measured (2026-09-04; one clean virtual environment per distribution;
harness/1-245-rule-policy-evaluation-engines/jsonlogic_isolated.sh): the same rule and the
same five rows through each.
json-logic0.6.3 raisesTypeError: 'dict_keys' object is not subscriptableon every call. Its source readsop = tests.keys()[0], which is Python 2 syntax, and it usesreduceas a builtin. The package has had no stable release in ten years and nine months and does 2.85 million installs a month. It cannot execute a single rule on any supported Python.json-logic-qubitandpanzi-json-logicboth evaluated all five rows and agreed.
The collision was found the hard way: a first run installing all three into one environment reported that the format worked, because a later install had overwritten the broken module. A single-environment test of this format gives the wrong answer.
python-jsonlogic is the one to know about, and it is not compatible on purpose.
Its README names the same ambiguities the standards organization does — that comparison
operators are said to work on “numeric” values without validating inputs, that dot notation
is ambiguous for a key containing a dot, that map provides its own scope with no way to
reach a higher one — and concludes that it “provides a way to typecheck your JSON Logic
expressions at ‘compile’ time, before applying input data to them.” Variable types are
declared as a JSON Schema; the pipeline is parse, build an operator tree, typecheck, then
evaluate. It replaces var paths with an unambiguous notation carrying an explicit scope
level. It is a different language that reads like the same one.
The failure mode that decides it#
Measured (2026-09-04; json-logic-qubit 0.9.1; probe.py, D4_compile_check): the rule
{">": [{"var": "wieght_kg"}, 20]} — the field name misspelled — evaluated to False
with no error raised. The var operator resolves an unknown path to null, so a rule that
reads a field which does not exist is indistinguishable from a rule that read it and found
nothing false.
There is no check phase to add. JSON always parses; the operator table is consulted at evaluation; there is no environment declaring what fields exist. A rule with a typo produces a plausible answer forever, and the shape of the data — where the misspelling is inside a string inside a JSON document — makes it invisible to every linter a team already runs.
An unknown operator does raise. Measured: {"greaterthan": [...]} raised ValueError
(same harness). So a rule with a typo in the operator fails loudly and a rule with a typo in a
field name fails silently, which is the wrong way round.
Trade-offs#
You get the most portable rule representation in the survey — one document that a browser,
a backend and a database row all hold identically — no parser to write, no eval, no I/O, no
loops, and generation from a program that is trivially correct because the output is JSON.
You give up a specification, a maintained reference implementation, a check phase, and any warning when a field name is wrong. The most-installed Python package for it does not run.
Where it is weak: anywhere a rule is authored by hand against a schema that changes. Where it is strong: a rule that is generated from a form, a query builder or a model, whose field names come from the same place the data does, and which must be evaluated in more than one language. For that job it is the right shape and the Rust and modern JavaScript ports are the ones to reach for.
Logic programming: Datalog and miniKanren#
Two families that answer a question adjacent to this survey’s rather than the same one. Both appear in searches for “rules engine”, both deserve a place in the comparison, and for most readers neither is what they are looking for.
Datalog#
A Datalog program is facts plus rules that derive new facts, evaluated to a fixed point. The distinguishing capability is recursion over relations — reachability, transitive ownership, “everything this group can eventually see”. A predicate evaluated against one record does not need any of that; a policy about a hierarchy might.
The boundary, plainly: Datalog is at its best when the answer requires following relationships an unbounded number of steps. If the rule is “over 20 kg or worth more than 1000”, every engine in this survey does it and Datalog is the most expensive way.
The engines, and what state they are in#
pyDatalog — 0.22.4 (2026-06-13), LGPL-2.1, 319 stars, 2 open issues. It was
restarted. The README says so directly: the author writes that he restarted
support of the package in June 2026, after a three-and-a-half-year gap. Seven releases in six
days, and the commits are engine work — parallelized aggregate computation, thread safety,
single-pass unification, a custom deque, index cleanup, typing annotations — closing a batch
of issues open since 2018. Two issues remain, both predating the revival. It ships wheels for
CPython 3.8 through 3.13 plus a pure-Python wheel, which the README notes runs under
WebAssembly and on mobile. Its continuous integration tests one Python version.
Its most interesting property for this survey is documented rather than advertised: pyDatalog integrates with an ORM and fetches facts from a live database as it resolves. The documentation shows queries joining across databases, using the latest in-session data, against tables reverse-engineered from an existing schema. Resolution stays in Python — this is rules in Python, facts fetched live from SQL, not rules translated into SQL.
Soufflé — 1,155 stars, UPL-1.0, C++, last push 2026-07-13, last release 2.5 on 2025-03-24. Seventeen months of unreleased work is worth knowing before pinning a version. Its tagline is “Logic Defined Static Analysis” and that is its actual constituency: program analysis, at a scale where compiling Datalog to parallel C++ pays for itself. Note that the interpreter is the default; compilation is opt-in with a flag, and the documentation is candid that compiling costs “in the order of minutes”.
Its I/O is a load-and-store adapter, not a database integration. Facts arrive from a file, from standard input, or from SQLite — and the SQLite path demands a table named for the relation with a leading underscore plus a view without one. Relations then live in Soufflé’s own index structures. There is no packaged Python binding: an in-tree SWIG interface can generate one per program if you build the project with the right flag, and even then it round-trips through CSV. For most Python users the realistic integration is invoking the binary.
Logica is the one that does what “Datalog over a database” sounds like it should mean. It
compiles a Datalog program into SQL and runs it in the engine — its README’s argument
being that SQL engines are orders of magnitude more powerful than native logic-programming
engines. 2,134 stars, Apache-2.0, last push 2026-08-23. Its own documentation disagrees with
itself about how many backends it supports; the source lists eight, including DuckDB,
PostgreSQL, SQLite, BigQuery and ClickHouse. Its PyPI release trails the repository by about
eleven months. Note that the widely cited google/logica URL is a hard 404 — the live
repository is under an individual account, and the code still carries a Google copyright.
What happened to the rest#
| project | state |
|---|---|
vmware/differential-datalog | archived; the URL redirects to an archive organization |
| CozoDB | dormant — no maintainer commit since 2024-12-04, with nine or more unanswered community pull requests including a silent-data-corruption report; PyPI packages frozen at 0.7.6 (2023-12-11); README still says “we encourage you to try it out” |
| Ascent, Crepe (Rust) | alive, low cadence; both shipped after multi-year gaps |
| Datalevin | the most actively maintained thing on this list — 1.1.0 on 2026-09-03, with Python bindings that require a JVM. It is a Datalog query language over stored data, not a forward-chaining rule engine |
| XTDB v2 | dropped Datalog. Its README describes an immutable SQL database speaking SQL and its own query language; Datalog is named as an inspiration, and version 1’s Datalog is fenced off in separate documentation |
CozoDB is the entry to read carefully, because it is what a project looks like six months before someone notices. Nothing declares it dead. Its README is encouraging. The signal is entirely in the ratio: patches arriving, nobody merging.
DuckDB’s recursive queries, as the alternative that is already installed#
WITH RECURSIVE gives genuine in-database fixpoint computation. The documentation is explicit
that the query must be formulated to terminate, that cyclic graphs require the author to
implement cycle detection, and that mutually recursive common table expressions are not
supported. What you lose relative to Datalog is the syntax, unification, and rule composition
— which is to say you lose the reasons to want Datalog and keep the recursion.
Trade-offs#
You get recursion over relations, which nothing else in this survey has, and a declarative form that is easier to reason about than the equivalent procedural traversal.
You give up ecosystem stability — of seven engines examined, one is archived, one is dormant, one dropped the feature, and the two liveliest need a JVM or a C++ toolchain — and you take on a language that is unlike anything most teams write. For a rule about one record, you also pay for a fixed-point evaluation you do not need.
miniKanren#
miniKanren 1.0.5, released 2025-06-24. 248 stars. 1,062,201 downloads a month.
A relational programming language — unification, logic variables, and goals producing streams of substitutions — from the tradition of The Reasoned Schemer.
It is not a rules engine and should not be evaluated as one. The Python port’s own README describes its motivation as an algorithmic core for computer algebra systems and for the generation and optimization of numeric software, and it aims “to be a low-level core” for such projects. There is no rule store, no fact base loaded from configuration, no serialization format for a rule, no separation between authoring and evaluation, and no decision idiom. A rule here is Python code constructing goals.
The download figure is a bundling artifact. It, etuples and logical-unification move
within one percent of each other day by day, and are installed together as an optional extra
of a numerical-computing library whose own figure is 1.78 million a month. Dependent-package
counts are effectively zero. It is not being chosen; it is being installed.
It has been functionally frozen since January 2023. The two 2025 releases follow a
three-and-a-half-year gap and consist of packaging modernization contributed by four
outsiders — removing a dependency, publishing a wheel, correcting a license classifier,
migrating to pyproject.toml. The maintainer’s own commits in that period are workflow and
build updates. The last functional commit is 2023-01-24. The README still shows a build badge
from a service that shut down for open source years ago.
An acknowledged correctness bug is open and unfixed. A report from 2024-06-14 that the disequality constraint is wrong with multiple variables drew a same-day reply from the maintainer confirming it looks like a genuine bug. It remains open twenty-seven months later, with one of miniKanren’s own originators participating in the thread.
It is included in this survey to be ruled out, and the reason it needs ruling out is the download number.
Registry and repository data#
Every figure on this page was fetched from a registry API on 2026-09-04, not read from a project’s own marketing. Downloads are PyPI’s last-month figure; stars, pushes and issue counts are the GitHub API.
Download counts are a reach signal and nothing else. Three of the numbers below are inflated by a single popular dependent, and one belongs to a package that cannot run.
Python packages#
| package | version | released | first release | license | downloads/mo |
|---|---|---|---|---|---|
simpleeval | 1.0.7 | 2026-03-16 | 2014-05-28 | MIT | 12,845,745 |
RestrictedPython | 8.5 | 2026-08-19 | 2007-07-28 | ZPL | 12,389,623 |
cel-python | 0.5.0 | 2026-01-31 | 2021-05-06 | Apache-2.0 | 6,455,685 |
asteval | 1.0.10 | 2026-08-21 | 2012-04-09 | MIT | 6,221,213 |
json-logic | 0.6.3 | 2015-12-04 | 2015-12-03 | MIT | 2,849,698 |
cel-expr-python | 0.1.3 | 2026-06-25 | 2026-02-13 | Apache-2.0 | 338,181 |
pycasbin | 2.8.0 | 2026-02-02 | 2024-10-25 | Apache-2.0 | 2,293,275 |
miniKanren | 1.0.5 | 2025-06-24 | 2019-12-22 | BSD | 1,062,201 |
cedarpy | 4.8.7 | 2026-07-10 | 2023-07-06 | — | 554,388 |
panzi-json-logic | 1.0.1 | 2021-09-12 | 2021-09-11 | MIT | 591,335 |
json-logic-qubit | 0.9.1 | 2018-08-15 | 2018-08-15 | MIT | 506,167 |
zen-engine | 2.0.2 | 2026-08-24 | 2023-10-13 | MIT | 475,848 |
rule-engine | 5.0.2 | 2026-07-08 | 2018-05-16 | BSD-3 | 421,984 |
business-rules | 1.1.1 | 2022-03-18 | 2014-05-19 | MIT | 92,748 |
python-rule-engine | 1.0.0 | 2025-06-07 | 2022-12-20 | MIT | 59,841 |
opa-python-client | 2.1.0 | 2026-08-18 | 2019-12-02 | MIT | 45,103 |
oso | 0.27.3 | 2024-01-13 | 2020-06-18 | Apache-2.0 | 27,282 |
python-jsonlogic | 0.2.0 | 2026-08-30 | 2024-03-12 | — | 19,732 |
durable-rules | 2.0.28 | 2020-06-07 | 2016-02-13 | MIT | 9,929 |
experta | 1.9.4 | 2019-11-16 | 2019-08-13 | LGPL-3 | 7,445 |
py-rules-engine | 0.3.0 | 2023-12-15 | 2023-12-13 | BSD-3 | 3,377 |
kanren | 0.3.0 | 2025-10-23 | 2016-09-06 | BSD | 2,636 |
pyDatalog (0.22.4, released 2026-06-13) and pydmnrules (1.4.5, released 2026-08-22)
are on PyPI; their download figures could not be fetched inside the request budget and are
recorded as unmeasured rather than estimated.
Three of these numbers do not mean what they look like, and one of them is about to change.
cel-python’s 6.5 million was a single dependent’s, and that dependent has left.protovalidate— 1.5 million installs a month of its own — requiredcel-pythonthrough version 1.2.0. Its 2.0.0 release on 2026-08-19 replaced it with a native extension wrapping the C++ implementation, and 2.0.0’s only dependency is protobuf. Independent library dependents number in the single digits. Expect this figure to fall, and do not read it as adoption today. (A second belief needs correcting while here: the package’s own README says the intent is to serve a cloud-governance tool’s policy filter, and that tool does not depend on it — a code search across that project for either the package or its module name returns nothing.)miniKanren’s 1.06 million against 248 GitHub stars is a bundling artifact. It,etuplesandlogical-unificationmove within 1% of each other day by day, and they are installed together as thekanrenextra of a numerical-computing library whose own figure is 1.78 million. Dependent-package counts for it are effectively zero.json-logic’s 2.85 million belongs to a package whose last stable release was 2015-12-04 and which raises on its first call under Python 3. Seejsonlogic.md.
The general lesson is on the page rather than in a footnote: in this category popularity and maintenance are decoupled. The most-installed JSONLogic package is the broken one, the most-installed CEL package is losing the dependent that installed it, and the most-installed logic-programming package is a test extra.
Repositories#
| repository | stars | open issues | last push | created | license |
|---|---|---|---|---|---|
casbin/casbin (Go) | 20,370 | 44 | 2026-08-21 | 2017-04-08 | Apache-2.0 |
open-policy-agent/opa | 12,194 | 333 | 2026-09-04 | 2015-12-28 | Apache-2.0 |
cel-expr/cel-spec | 3,961 | 79 | 2026-08-13 | 2017-09-04 | Apache-2.0 |
cel-expr/cel-go | 3,090 | — | 2026-09-04 | — | Apache-2.0 |
jwadhams/json-logic-js | 1,480 | 67 issues + 17 PRs | 2024-07-09 | 2015-10-06 | MIT |
osohq/oso | 3,490 | 119 | 2025-02-26 | 2020-05-04 | Apache-2.0 |
gorules/zen | 1,963 | 40 | 2026-08-25 | 2023-03-29 | MIT |
casbin/pycasbin | 1,767 | 3 | 2026-08-13 | 2018-09-20 | Apache-2.0 |
cedar-policy/cedar | 1,708 | 176 | 2026-09-04 | 2023-04-25 | Apache-2.0 |
jruizgit/rules (durable_rules) | 1,299 | 206 | 2025-07-15 | 2013-12-21 | MIT |
souffle-lang/souffle | 1,155 | 132 | 2026-07-13 | 2016-03-12 | UPL-1.0 |
venmo/business-rules | 993 | 30 | 2024-08-13 | 2014-04-03 | MIT |
zopefoundation/RestrictedPython | 741 | 16 | 2026-08-27 | 2013-02-28 | ZPL |
danthedeckie/simpleeval | 611 | 18 | 2026-08-07 | 2013-12-03 | MIT |
zeroSteiner/rule-engine | 597 | 3 | 2026-08-02 | 2018-03-28 | BSD-3 |
pcarbonn/pyDatalog | 319 | 2 | 2026-06-13 | 2015-08-27 | LGPL-2.1 |
pythological/kanren | 248 | 24 | 2025-08-22 | 2019-03-20 | — |
nadirizr/json-logic-py | 226 | 23 | 2023-12-19 | 2015-12-02 | MIT |
lmfit/asteval | 221 | 0 | 2026-08-27 | 2012-03-31 | MIT |
cloud-custodian/cel-python | 174 | 17 | 2026-09-01 | 2020-06-16 | Apache-2.0 |
santalvarez/python-rule-engine | 60 | 3 | 2026-03-05 | 2022-12-19 | MIT |
russellmcdonell/pyDMNrules | 47 | 1 | 2026-08-22 | 2020-01-13 | — |
saurabh0719/py-rules | 36 | 3 | 2024-02-29 | 2023-12-13 | BSD-3 |
camunda/camunda-bpm-platform | 4,270 | 0 | 2025-11-04 | 2013-01-03 | ARCHIVED |
The three states in this table#
Read the last-push column against the open-issue count and the projects sort into three groups, which is a more useful ordering than stars.
Maintained. OPA, Cedar, Casbin, Zen, rule-engine, simpleeval, asteval,
RestrictedPython, cel-python. All pushed within the last month.
Finished or forgotten — and the two look identical from a distance. asteval has
zero open issues and a push this month: that is a tended tracker on a library that does
one thing. rule-engine has three open against 597 stars, the same shape. pyDMNrules has
one. These are small projects that are done, not projects that died.
Stalled. venmo/business-rules has 993 stars, 267 forks and thirty open issues with no
push since 2024-08-13 — patches arriving and nobody merging, which is the shape that fires
before an archive notice. jruizgit/rules carries 206 open issues against 1,299 stars
with its last PyPI release in 2020. osohq/oso describes itself as deprecated. experta
has not released since 2019.
Archived. camunda/camunda-bpm-platform, with a repository description announcing its
own end of life; and vmware-archive/differential-datalog. Zero open issues is what an
archived tracker looks like, not a healthy one — which is why the close ratio has to be read
next to the archive flag, not instead of it.
Repository moves that break existing citations#
Four projects in this survey changed location inside the last two years, and three of the old URLs no longer resolve. Anything written about this category before 2026 cites at least one of them.
| was | is now | note |
|---|---|---|
google/cel-spec, google/cel-cpp, google/cel-java | cel-expr/* | redirects; the whole CEL project moved to its own org on 2026-06-16 |
google/cel-go | cel-expr/cel-go | hard 404, no redirect |
casbin/* | mirrored at apache/casbin-* | Apache Incubator since 2026-02-07 |
TotalTechGeek/json-logic-engine | json-logic/json-logic-engine | redirects |
vmware/differential-datalog | vmware-archive/differential-datalog | redirects, and archived |
apache/incubator-kie-kogito-runtimes | apache/incubator-kie | archived, redirects |
camunda/camunda-bpm-platform | — | archived 2025-11-04 |
Your own minimal evaluator#
What it is#
A few hundred lines that walk a Python abstract syntax tree with an allowlist of node types, evaluate the ones on the list, and refuse everything else. A validator that reports what a rule needs before it runs, and a preview mode that shows what a rule would do without committing it.
It is not an exotic idea. It is what simpleeval and asteval are, and both are on PyPI
with a decade of history, so the practical form of this candidate is: use one of those, or
write the same thing.
simpleeval | asteval | |
|---|---|---|
| downloads/mo | 12,845,745 | 6,221,213 |
| version | 1.0.7, 2026-03-16 | 1.0.10, 2026-08-21 |
| first release | 2014-05-28 | 2012-04-09 |
| license | MIT | MIT |
| stars / open issues | 611 / 18 | 221 / 0 |
| scope | one expression | expressions and statements |
RestrictedPython (12.4 million a month, first released 2007) is the third member of the
family and a different tool: it compiles restricted Python to bytecode with policy hooks,
which is more machinery than a rule needs.
What a rule looks like#
A Python expression, stored as a string. weight > 20 or value > 1000. The team already
reads it. The reviewer already reviews it. There is nothing to learn.
Why this is a candidate rather than a strawman#
Three properties that no dedicated engine in this survey has all of.
No second language. Every other candidate here introduces a dialect: Rego, Cedar, CEL,
FEEL, ZEN, a JSON AST, a .conf matcher. That dialect has to be learned, reviewed,
debugged, kept in a reader’s head alongside the primary language, and explained to whoever
inherits the system. S2 quantifies this; the short version is that it is the largest cost in
the category and the one most often left out of the comparison.
The provenance loop is already yours. Nothing here reports which records a rule reached — but the caller writes the loop, so the caller writes it down. Recording per-record outcomes costs one column and one assignment. Two of the dedicated engines give you this in the response; the rest do not, and then you write the same loop anyway with an extra dependency in it.
The rule’s dependencies are extractable. The names an expression needs come out of the
AST without running it, so “which fields does this rule read” and “does this rule mention a
column that no longer exists” are both answerable at authoring time. Measured: the
misspelled identifier in a rule was recovered from the parsed tree before evaluation
(measured 2026-09-04; Python 3.12.3; harness/1-245-rule-policy-evaluation-engines/probe.py,
D4_compile_check).
Sandboxing: what was actually measured#
Five hostile expressions were sent through each evaluator under a five-second clock and a
2 GB address-space cap (measured 2026-09-04; simpleeval 1.0.7, asteval 1.0.10;
probe.py, D4_hostile):
| attack | simpleeval | asteval |
|---|---|---|
__import__('os').listdir('.') | refused — FunctionNotDefined | refused — NameError |
().__class__.__bases__[0].__subclasses__() | refused — FeatureNotAvailable | refused — AttributeError |
attribute walk to __mro__ | refused — FeatureNotAvailable | refused — AttributeError |
9**9**9 | refused — NumberTooHigh | refused — RuntimeError |
'a' * 100000000 | refused — IterableTooLong | refused — RuntimeError |
Both refused all five. The two resource-exhaustion cases matter more than they look: they
are the attacks a hand-rolled allowlist forgets, because an expression made only of allowed
node types can still take the process down. simpleeval caps exponents at 4,000,000 and
strings at 100,000 characters by default; a first draft written in an afternoon does not.
The library authors’ own caveat should be quoted rather than paraphrased: simpleeval’s
documentation states there is no warranty and that “a lot of very clever people think the
whole idea of trying to sandbox CPython is impossible.” That is the correct posture. These
evaluators are appropriate for rules written by colleagues and for rules generated by a
model whose output you review. They are not an answer to a hostile author.
Trade-offs#
You get no new language, no new dependency worth the name, provenance and preview for the cost of writing them, an authoring surface a language model already writes fluently because it is a Python expression, and a rule that a reviewer can read without a reference card.
You give up:
- Anything a non-programmer is meant to edit. There is no table editor, no versioned policy store, no web UI. Building those is a project.
- Portability across runtimes. A Python expression is a rule for Python. Cedar, CEL and DMN each run the same rule in several languages, which matters if the same decision has to be made in a browser and a backend.
- A specification. There is no document defining what your language means, so the answer to “what does this rule do with a missing field” is whatever your code happens to do, and it will differ from whatever the next team’s code happens to do.
- Enforced purity. Nothing stops a maintainer from putting a callable into the symbol
table that opens a socket. Measured: an arbitrary Python function placed in the
evaluator’s namespace was reachable from a rule in both libraries (measured 2026-09-04,
same harness,
D2_io_via_host_function). Cedar cannot do this by construction; here it is a convention.
Where it is weak#
Multi-language deployments, non-technical authors, and anywhere the rules are a product surface rather than an implementation detail. Also anywhere a formal argument about the rules is needed — you cannot ask a Python expression whether two rule sets are equivalent, and one engine in this survey can answer exactly that.
Policy runtimes: OPA, Cedar, Casbin, Oso#
Four projects that answer “may this actor do this thing to this resource”. They are grouped because they share an assumption the rest of the survey does not: the decision is about an actor, and the policy is a document that outlives any one service.
Three of the four had a governance event in the last two years, and two of those events are more decision-relevant than any feature listed below.
Open Policy Agent (OPA) — Rego#
v1.20.2, released 2026-09-03. Apache-2.0. CNCF Graduated 2021-01-29. 12,194 stars.
A general-purpose policy engine, and the most widely deployed thing in this survey. Rego is a Datalog-descended query language: a policy is a set of rules over documents, and a decision is a query result.
Rego changed under its users’ feet. OPA v1.0.0 (2024-12-20) made if mandatory for every
rule definition and contains mandatory for multi-value rules — previously opt-in through
import rego.v1, introduced in v0.59.0 (2023-11-30). Checks that used to require
opa check --strict are now the default. A --v0-compatible flag exists across eleven
subcommands with no announced removal date, so old policies still run; but Rego written
before 2025 and Rego written after it are visibly different languages, and search results,
tutorials and books split across the boundary.
The vendor is gone. On 2025-08-20 the OPA creators and much of Styra’s staff announced
they had joined Apple. The project is CNCF-graduated and its governance and licensing are
stated as unchanged; what changed is the commercial tier. Styra’s products were donated into
the CNCF open-policy-agent GitHub organization, and Enterprise OPA is now an archived
repository whose README asks for a maintainer: “If you are interested in maintaining this
project, please reach out to the maintainers over on the OPA Slack.” A reader evaluating OPA
on the strength of a supported commercial edition should check what is left of it.
Python is not a first-class host. Official SDKs exist for Go, Java, C#, TypeScript,
Swift and JavaScript/WASM. For Python the documented integration is a sidecar over HTTP,
which is a deployment decision as much as a library one. The WebAssembly route is not viable:
the third-party opa-wasm package last released 2022-02-11, golang-opa-wasm is archived,
and the WASM build does not support http.send at all. microsoft/regorus — a Rego
interpreter in Rust with Python bindings — is the only in-process option, and it is not
published on PyPI, so adopting it means building from source with maturin.
Rules as data: yes, two ways. PUT /v1/policies/<id> creates or replaces a module at
runtime. Bundles are the production path: signed gzipped tarballs polled over HTTP, with
delta bundles that patch data rather than resending it.
Evaluation can do I/O, and OPA says so carefully. http.send exists with a five-second
default timeout and intra- and inter-query caching. The documentation states it “must not be
used for effecting changes in external systems”, because caching may prevent an identical
request from re-executing, and lists it last among five ways to get data into a policy,
noting that “latency and availability of decision-making are dependent on the network.”
Preview and provenance are the best in the survey. opa check validates without running.
opa test discovers test_-prefixed rules, supports --coverage with per-file percentages,
and --fail-on-empty for CI. opa eval --explain takes off, full, notes, fails or
debug; the server accepts explain=full and returns trace events tagged
Enter/Exit/Eval/Fail/Redo. Decision logs record the input, the result, the bundle
revision and a nd_builtin_cache of every nondeterministic builtin call, which exists
specifically so a decision can be replayed.
Report-only is real, at the enforcement layer rather than in OPA. Gatekeeper — OPA’s
Kubernetes admission controller — supports enforcementAction values deny, dryrun,
warn and scoped, the last routing different actions to different enforcement points so a
policy can be dryrun at admission and deny in audit. Note that Gatekeeper’s own
documentation lists only the first three; the fourth is in the source.
Trade-offs. You get the largest ecosystem, real test tooling, and a policy language that handles much more than boolean predicates. You give up in-process evaluation from Python, and you accept a language that is unlike anything else the team writes and that changed incompatibly inside the last two years.
AWS Cedar#
Rust crate 4.12.0, released 2026-07-28. Apache-2.0. CNCF Sandbox since 2025-10-08. 1,708 stars.
A small authorization language with an unusual property: its semantics are formalized in Lean and the formalization carries proofs. The claim has to be stated in two parts or it is wrong. The model is verified — chiefly validator soundness, that a policy the validator accepts cannot produce a type error at evaluation. The Rust implementation is not itself proven; it is continuously differentially fuzzed against the model. That is a stronger evidentiary position than anything else here, and it is not the same as “the code is proven correct.”
Evaluation is pure by construction. No loops, no I/O, no user-defined functions. Authorization is deterministic, guaranteed to terminate, and order-independent, with three stated rules: default deny, forbid overrides permit, skip on error.
The purity has a price, and Cedar has an answer for it. A pure evaluator must be handed every entity it might dereference. Level validation bounds how far a policy can follow entity references, and level-based entity slicing uses that bound to fetch only the entities the policy can reach — with the documented guarantee that “an authorization request made using the sliced entity data will give the same result as an authorization request using all entity data.” The best-practice guidance is explicit that the slice comes from database queries, split into a cacheable principal slice and a per-request resource slice. There is also partial evaluation, which returns residual policies plus the unknowns that blocked a decision.
Rules as data: yes. PolicySet loads from a string, from Cedar’s JSON form, or from a
file. Templates hold ?principal and ?resource placeholders in the policy scope, and
linked policies track the template live — editing the template changes every link
immediately. The documentation names the cost of that: it “tightly couples the policy store
to the life-cycle management of your users.”
Preview is the strongest in the survey. The validator runs against a schema, in strict mode by default, catching unrecognized types and attributes, unguarded optional-attribute access and operator type mismatches. Beyond that, Cedar Analysis (announced 2025-06-16) compiles policies to SMT and answers questions no test suite can: are two policy sets equivalent, is one strictly more permissive than the other, are there shadowed permits or conditions that can never hold. It is implemented in Lean with soundness and completeness proofs. Nothing else in this survey can reason about all inputs rather than the ones somebody thought to test.
Provenance is in the response. Diagnostics carries reason — the set of policy IDs
that determined the outcome — and errors. For an allow that is the satisfied permit
policies; for a forbid-caused deny, the forbid policies.
Python is unofficial and behind. cedarpy (554,388 downloads a month, v4.8.7 released
2026-07-10) is published by a third party whose README says plainly that it is “not
officially supported by AWS or the Cedar Policy team.” It wraps the Rust crate, and version
4.8.7 embeds Cedar 4.8.2 against a current crate of 4.12.0. The project’s own curated
list files Python under unofficial, next to official Java and Go. The PyPI name
cedar-policy is an inert stub — one 0.0.1 release from 2023 with project URLs pointing at
github.com/unknown/cedar-policy. Do not install it expecting an engine. Note also that
cedarpy’s PyPI metadata carries a null license field despite the repository being
Apache-2.0.
Trade-offs. You get purity you can rely on, a validator with a proof behind it, analysis tooling that is a category of its own, and neutral governance. You give up expressiveness outside authorization — Cedar is a language for deciding whether a principal may act on a resource, and bending it toward “is this shipment heavy” is working against the grain — and in Python you accept an unofficial binding that trails the engine.
Casbin#
Go v3.11.0 (2026-08-20); pycasbin 2.8.0 (2026-02-02). Apache-2.0. Apache Incubator since 2026-02-07. 20,370 stars.
An access-control library built on a metamodel called PERM — Policy, Effect, Request,
Matchers. The model is a .conf file describing the shape of a decision; the policy
is a set of rows, in CSV or a database table. Adapters exist for SQLAlchemy (sync and async),
the Django ORM, MongoDB and others; watchers propagate policy changes across instances over
etcd, Redis, PostgreSQL, RabbitMQ or Kafka.
Two renames make most existing documentation wrong. The project entered the Apache
Incubator on 2026-02-07; casbin.org now redirects to casbin.apache.org and the core
repositories are mirrored under apache/casbin-*. Separately, and more dangerously, the
PyPI package renamed itself from casbin to pycasbin at version 2.0.0 (2025-08-10) and
left the old name published at 1.43.0 with no deprecation notice. pip install casbin
still succeeds and installs a package a major version and sixteen months behind. Nothing on
the old PyPI page says so. This survey’s own harness fell into it on the first run.
Note also that the Apache move is partial: the core libraries are mirrored, while the adapter
and watcher packages remain under the pycasbin and officialpycasbin organizations.
Rules as data: yes, and this is the design rather than a feature. Policy rows live in a
table by default. Adding a permission is an INSERT.
Evaluation cannot do I/O on its own. pycasbin’s entire runtime dependency set is two
packages, simpleeval and wcmatch, and its built-in matcher functions are string, glob, IP
and time comparisons. Nothing in the shipped surface opens a socket. The host can register an
arbitrary Python callable into the matcher namespace, so I/O is reachable by the host’s
choice rather than the policy author’s — a meaningfully different position from Rego’s.
Provenance is in the API. enforce_ex() returns (bool, matched_policy_row) — the
actual policy row that fired, or an empty list. Measured: an allow returned
(True, ['dispatcher', 'shipment', 'sign']) and a deny returned (False, []) (measured
2026-09-04; pycasbin 2.8.0; harness/1-245-rule-policy-evaluation-engines/probe.py, D3).
Worth knowing that this path was buggy until recently — v2.6.0 (2025-11-06) fixed
enforce_ex() returning empty explanations for allow rules in deny-override models.
There is no preview and no dry run. Measured: a model file whose matcher named a
field that does not exist loaded without complaint and failed on the first enforce()
call with NameNotDefined (measured 2026-09-04, same harness, D4_compile_check). A typo in
a matcher is a runtime error in production, not a startup error in CI. Report-only mode does
not exist either; enforce_ex logs the request and result, and acting on the answer is the
caller’s job.
Trade-offs. You get genuine in-process Python, rules in a database table by default, and an explain API. You give up a preview step entirely, and you inherit a project that is mid-migration on two axes at once.
Oso / Polar — deprecated#
oso 0.27.3, released 2024-01-13. Deprecated 2023-12-18. Last code commit 2024-06-13.
Polar was a Prolog-descended declarative language for authorization, with a Rust core and genuine in-process bindings for Python — for a while it was the most credible embeddable answer in this category.
The repository description reads “Deprecated: See README”, and the README says: “We have deprecated the legacy Oso open source library. We have plans for the next open source release … In the meantime, if you’re happy using the Oso open source library now, nothing needs to change – i.e., we are not end-of-lifing (EOL) the library and we’ll continue to provide support and critical bug fixes.”
Read against the dates, that promise has not been kept in the way a reader would expect. The notice went up on 2023-12-18; the last commit of any code is 2024-06-13, twenty-seven months before this survey; the announced next open-source release has not appeared. Downloads have fallen to 27,282 a month. The ten most recent forks all have zero stars, so no community successor has taken hold.
Users are pointed at Oso Cloud. The oso-cloud PyPI package is maintained — 2.6.0,
released 2026-03-30 — and it is a network client for a hosted service. Polar survives
there as a dialect of the vendor’s runtime, documented separately from and not interchangeable
with the open-source one. The replacement for an embeddable library is a service, which is a
different acquisition.
This is the survey’s clearest illustration of a specific risk: a rule language with no specification, one implementation, and one company. When the company’s plans changed there was nothing to fork toward, because the language was the product.
The Python rules-engine shelf#
Six libraries that come up when someone searches for a Python rules engine. They are grouped because the search results do not distinguish them and the registry data does: one is maintained and finished, one is maintained and small, and four are in states between stalled and abandoned.
Read this page next to observed-data.md. Every date below came from PyPI or the GitHub API
on 2026-09-04.
rule-engine (zeroSteiner) — the one that is alive#
5.0.2, released 2026-07-08. BSD-3. 597 stars, 3 open issues, last push 2026-08-02. 421,984 downloads a month.
An optionally typed expression language with its own grammar, evaluated against a mapping.
Rule('weight_kg > 20 or value_usd > 1000').matches(row). Three open issues against 597
stars and a push this month is the signature of a small project that is finished rather than
forgotten.
Its type resolver is the strongest preview in the pure-Python group. Declare the shape of
the data and the parser checks the rule against it. Measured: untyped,
Rule.is_valid() accepted both a misspelled identifier and a comparison between a number and
a string, rejecting only the syntax error. With a declared type resolver, all three were
rejected before evaluation — RuleSyntaxError, SymbolResolutionError and EvaluationError
respectively (measured 2026-09-04; rule-engine 5.0.2;
harness/1-245-rule-policy-evaluation-engines/probe.py, D4_compile_check). Nothing else in
this survey’s Python group catches all three, and one thing that markets itself on type
safety catches none of them.
It has no host-function hook, which is a security property rather than a limitation: the builtins are fixed, so a rule cannot reach I/O even if the host wants it to.
One measured surprise, and it is the survey’s cleanest illustration of two-language cost.
The language looks like Python. Its ** operator does not behave like Python’s.
Measured: 9 ** 9 ** 2 evaluates to 1.966271e+77 in Python and in simpleeval, and to
1.500946e+17 in rule-engine (measured 2026-09-04; rule-engine 5.0.2, simpleeval 1.0.7;
harness/.../followup.py, power_associativity). Python’s exponent operator is
right-associative; this grammar binds it left. The same string is two different rules, with
no error and a difference of sixty orders of magnitude. A reviewer who knows Python will read
it wrong, and knowing Python is what makes them read it wrong.
Its resource guards are also thinner than simpleeval’s: it evaluated the exponent bomb that
simpleeval, asteval, CEL and Zen all refused (same harness, D4_hostile).
business-rules (venmo) — stalled#
1.1.1, released 2022-03-18. MIT. 993 stars, 267 forks, 30 open issues, last push 2024-08-13. 92,748 downloads a month.
The best-known name on this shelf and the reason many searches land here. Rules are JSON — a variable, an operator and a value — matched against a class that declares its variables and actions with decorators. The design goal was rules a non-programmer could edit, which is the right goal and the reason it still gets recommended.
The registry reading is the one that fires before an archive notice: 993 stars, 267 forks, thirty open issues and no push in two years. Patches arriving and nobody merging. Its PyPI metadata still declares support for Python 2.7. It is not dangerous to use — the code works — but nobody is behind it, and the JSON format is defined by the implementation rather than by a document, so a fork inherits the definition along with the code.
durable_rules — stalled, with a large backlog#
2.0.28, released 2020-06-07. MIT. 1,299 stars, 206 open issues, last push 2025-07-15. 9,929 downloads a month.
A forward-chaining engine — Rete-style pattern matching over asserted facts, rather than a predicate evaluated against one record. That makes it the only thing on this shelf that answers a genuinely different question: it fires rules as facts arrive and derives new facts from them.
The numbers are the problem. 206 open issues against 1,299 stars is the highest ratio in this survey, the last PyPI release is over five years old, and the repository’s primary language is JavaScript with a C core. Ten thousand downloads a month against a Rete engine’s complexity is not a base that will maintain itself.
experta — abandoned#
1.9.4, released 2019-11-16. LGPL-3. 7,445 downloads a month.
A CLIPS-inspired forward-chaining engine, itself a fork of the earlier pyknow. Seven years
without a release. Included because it is the answer people find when they search for “Python
expert system”, and because the fork lineage — pyknow to experta and no further — is the
shape this corner of the ecosystem keeps making.
py-rules-engine and python-rule-engine — small and recent#
py-rules-engine 0.3.0, released 2023-12-15, BSD-3, 36 stars, last push 2024-02-29, 3,377
downloads a month. Rules are JSON documents with if/then/else, storable and
composable. The design is sound; two years without a commit and thirty-six stars means the
maintenance question is answered by one person’s attention.
python-rule-engine 1.0.0, released 2025-06-07, MIT, 60 stars, last push 2026-03-05, 59,841
downloads a month. JSON rules with named conditions, and it reached 1.0 rather than sitting
at 0.x. Alive, small, and the newest thing on the shelf that is still being touched.
Neither offers provenance, a type check, or a preview mode. Both are, in substance, a JSON wrapper over what the previous page describes writing yourself.
What the shelf as a whole tells a chooser#
The gravitational pull is toward JSON. Five of the six store rules as JSON documents, which makes them trivially storable in a database and trivially generated by a program. That is the shared insight of the shelf and it is a good one.
None of them offers per-record provenance. All six return a boolean or fire an action. The question “which records did this rule reach, and which needed a person” is the caller’s to answer in every case.
The maintenance distribution is unusually bad even for a long tail. One project with three open issues and a push this month; one with 206 open issues and a five-year-old release; one with 267 forks and no push in two years; one abandoned since 2019. A category where the well-known name is the stalled one is a category where the search results are actively misleading, and that is worth more to a reader than any feature comparison on this page.
What S1 establishes, and what goes to S2#
The category is smaller than the search results#
Sixteen candidates were profiled. Six of them can be set aside on registry evidence alone, before any capability question is asked:
| candidate | why it is out |
|---|---|
| Oso / Polar | deprecated 2023-12-18; last code commit 2024-06-13; successor is a hosted service |
business-rules | 993 stars, 267 forks, 30 open issues, no push since 2024-08-13 — patches arriving, nobody merging |
durable_rules | 206 open issues; last release 2020-06-07 |
experta | no release since 2019 |
| miniKanren | not a rules engine; frozen since January 2023; an acknowledged correctness bug open 27 months; its download figure is another library’s test extra |
json-logic (PyPI) | Python 2 only; raises on every call under Python 3; no stable release in ten years |
That last row is the one to carry forward. It is the most-installed JSONLogic package in Python, at 2.85 million a month, and it does not run. Anyone who found this category through a search result and installed the obvious thing has installed that.
The three findings S1 produced that change how the rest is read#
Popularity and maintenance are fully decoupled here. The most-installed JSONLogic package is broken. The most-installed CEL package owed its figure to one dependent that dropped it in August 2026. The most-installed logic-programming package is a test extra. Downloads are a worse signal in this category than in any other this corpus covers, and the last-push date against the open-issue count is a better one.
Three governance events landed inside the survey window, and all three are more
decision-relevant than any feature. OPA’s commercial vendor was absorbed and its enterprise
edition is now an archived repository asking for a maintainer. Casbin entered the Apache
Incubator and, separately and more dangerously, renamed its PyPI package from casbin to
pycasbin with no notice on the old name — which still installs, a major version behind.
Camunda 7’s DMN engine reached end of life and was archived.
Whether a rule language has a specification decides what happens when its project stops. Camunda 7 died and its users’ DMN models still run on five other engines. Oso died and its users’ Polar policies run nowhere. That is the same event with two outcomes, and the variable is the row of the standards table.
What is left, and the shape of the choice#
Ten candidates survive to S2, and they do not compete with each other. They divide by the job:
One expression, evaluated in your process. CEL, rule-engine, a hand-rolled evaluator, or
simpleeval/asteval which are the packaged form of the last. CEL if the rule must also run
somewhere that is not Python; the others if it must not.
A decision about an actor. OPA, Cedar, Casbin. Casbin is the only one that is in-process from Python; OPA’s sanctioned Python path is a sidecar; Cedar’s is an unofficial binding four minor versions behind the engine.
A table an analyst edits. DMN or Zen. DMN has seven hit policies, a conformance suite and no conforming Python engine; Zen has two hit policies, a vendor-published Python binding, and a function node that can call an HTTP API mid-decision.
Recursion over relations. Datalog, and only if the answer requires following relationships an unbounded number of steps. The ecosystem is unstable enough that this should be a considered choice rather than a default.
A constraint every writer obeys. The database, which is the only candidate that applies to a writer that has never loaded your application.
The question S1 cannot settle#
Every one of the ten can express the rule. Almost every one can store it as data. The thing that separates them is what happens around evaluation — whether a rule can be checked before it ships, whether anything records which records it reached, whether it can run without blocking, and what a second dialect costs the team that has to review it at three in the morning.
Those are S2’s six discriminators, and the reason S2 is organized around them rather than around a feature matrix is that a feature matrix for this category is almost entirely ticks.
Which of these are standards, and what that is worth#
Four of the candidates are a specification with implementations; the rest are a project. The difference decides what happens when the project you chose stops, and it has already decided that for two of the entries in this survey.
The four levels of standardization present here#
| level | candidates | what exists |
|---|---|---|
| A formal standard from a standards body | DMN | OMG specification, versioned since 2015, current formal version 1.5 (August 2024); a public conformance suite of 3,391 test cases with per-engine published scores; six or more independent conforming engines |
| A published specification with governance and a conformance suite | CEL | versioned spec releases (v0.25.3, 2026-08-13, still 0.x after eight years); a four-person Language Council with a documented change process; conformance test data; four official implementations |
| A published, singly governed format | Cedar; Zen’s JDM | Cedar has an RFC process, a Lean formalization with proofs, and CNCF Sandbox governance since 2025-10-08. JDM has a JSON Schema and documentation, and its README states the vendor intends to “keep tight control over its development” |
| An implementation that is its own definition | JSONLogic; Rego; Polar; Casbin’s matcher; rule-engine; every hand-rolled evaluator | behavior is whatever the code does |
Note where Rego lands. It is the most widely deployed policy language in the survey and it
has no specification — which is exactly why OPA v1.0.0 could redefine it on 2024-12-20,
making if and contains mandatory and turning previously optional strict checks on by
default. There was no document to amend and no second implementation to keep in step.
What the standard buys, tested against three events#
The abstract argument for a standard is portability. Three things that actually happened during the window this survey covers show what that is worth in practice.
Camunda 7’s DMN engine reached end of life — final release 2025-10-14, repository archived 2025-11-04. Its users’ decision models are DMN XML, which five other conforming engines read. The engine died and the rules did not. A migration, not a rewrite.
Oso deprecated its open-source library on 2023-12-18, with the last code commit 2024-06-13. Polar had one implementation and no specification. There was nothing to migrate to, because the language was the product. The documented successor is a hosted service whose dialect is documented separately and is not interchangeable.
Rego changed shape under its users rather than dying. A compatibility flag exists with no announced removal date, so nothing broke — but every tutorial, book and search result written before 2025 describes a language that is now rejected by default.
Those are the three outcomes available to a rule language, and which one you get is decided almost entirely by the row of the table above.
Why the standards are covered here rather than separately#
A survey of protocols and standards would compare DMN against CEL against Cedar’s RFC process as specifications — governance, conformance regimes, version cadence, adoption by implementers. That is a real subject with a real audience: someone writing an engine, or choosing a format to commit an organization’s rules to for a decade.
It is not the question in front of the reader here. The question here is which thing to adopt, and for that the standard is a property of a candidate — the answer to “what happens if this project stops” — rather than a candidate itself. Two of the four specifications are also, in practice, reached through exactly one library in Python, which collapses the distinction further: choosing CEL in Python means choosing between two implementations that disagree about whether type checking exists.
So the standards appear on this page and inside each profile, as durability evidence. The signal that would justify separating them is an independent audience — someone who needs to compare DMN’s conformance regime against CEL’s without caring which library they call. If that reader shows up, the specifications carry enough weight for their own treatment; the conformance data alone is a substantial body of evidence that nothing else in this survey has.
The conformance data, since it is the strongest evidence available#
DMN publishes per-engine scores against 3,391 test cases, refreshed as vendors resubmit. Two engines score above 3,388; the best-known open-source engine of the previous decade scores 2,741 and is end-of-life. That is a rare thing in software: a public, third-party, quantitative answer to “does this vendor’s claim hold”.
CEL ships conformance test data and describes it as a complementary specification. Its pure-Python implementation converts that data into 2,430 runnable scenarios and skips 1,184 of them by default, concentrated in extensions and protobuf handling. It makes no conformance claim, and the number is available because the project imports the suite rather than asserting compliance.
JSONLogic has no conformance suite of its own, and the community organization that formed to write a specification has instead produced a 1,138-case compatibility suite across eight languages. Its published table is misleading in three separate ways; recomputed from the raw results, the reference JavaScript implementation scores 68.7%, because a third of the cases test behavior the original language never had. That is what measuring an unspecified format looks like.
Cedar’s evidence is of a different kind entirely. Rather than a test suite, its semantics are formalized in Lean with proofs — chiefly that a policy the validator accepts cannot produce a type error — and the Rust implementation is continuously differentially fuzzed against that formalization. The claim has to be stated in two parts: the model is verified, and the implementation is tested against the model. Nothing else here offers either half.
S2: Comprehensive
The six questions, and why a feature matrix does not answer them#
Almost every candidate in this category can express almost any condition. A feature matrix comes out nearly all ticks, and a chooser reading it learns nothing. What separates these engines is what happens around evaluation.
The discriminators#
| question | where it is settled | |
|---|---|---|
| D1 | Can rules be stored as data, in the same database as the rows they govern? | d1-rules-as-data.md |
| D2 | Does evaluation require I/O, and may a rule reach a fact the caller did not hand it? | d2-evaluation-io.md |
| D3 | Can anything report which records the rule reached, and which needed a person? | d3-provenance.md |
| D4 | Can a rule be validated and dry-run before it goes live? Is the language small enough to be authored safely by something other than a careful programmer? | d4-authoring-and-preview.md |
| D5 | Can the same rule run in report-only mode as well as blocking mode? | d5-enforce-vs-observe.md |
| D6 | What does introducing and maintaining a second rule dialect cost? | d6-two-language-cost.md |
The capability matrix#
Compiled from the pages above. measured rows were run
(harness/1-245-rule-policy-evaluation-engines/, 2026-09-04); cited rows come from
project documentation and say so.
| D1 rules as data | D2 I/O reachable | D3 engine reports which rule | D4 catches an unknown name | D5 report-only mode | |
|---|---|---|---|---|---|
SQL database measured | yes, as schema | yes, in a trigger, in-transaction | yes, as a column | yes | two statements, one predicate |
rule-engine measured | yes | no | no | yes, typed only | caller |
cel-python measured | yes | via host function | no | no | caller |
cel-expr-python cited | yes | via host function | no | declares variable types | caller |
Zen Engine measured | yes | yes, in the language | yes, per node and row | no | caller |
simpleeval / asteval measured | yes | via host function | no | not built in; names extractable | caller |
JSONLogic measured | yes | no hook | no | no — evaluates to false | caller |
Casbin measured | yes, rows in a table | via host function | yes, enforce_ex | no — fails at first call | caller |
OPA / Rego cited | yes, bundles or REST | yes, http.send | yes, explain + decision logs | yes, opa check | yes, at the enforcement layer |
Cedar cited | yes, policies + templates | no, by construction | yes, diagnostics.reason | yes, validator + SMT analysis | caller |
DMN engine cited | yes, DMN XML | no | vendor-dependent | vendor-dependent | vendor-dependent |
Two columns are nearly all ticks and are therefore nearly useless for choosing: D1 and, in substance, D5. Both pages explain why that is the finding rather than a defect in the comparison.
Three columns discriminate sharply. D2 splits the field into three positions that are design commitments, not features. D3 is answered by three engines and by the surrounding harness everywhere else. D4 is where the largest surprises are, including two engines that cannot catch a misspelled field name and one that returns a plausible answer when a field name is wrong.
D6 has no column because it is not a property of the engine. It is priced per candidate on its own page, and it is the largest cost in the category.
Where the numbers come from#
Every measured claim was produced by a script in
harness/1-245-rule-policy-evaluation-engines/, against pinned versions listed in that
directory’s requirements.txt, with raw JSON committed alongside. The harness prints its
results to standard output as well as writing them, so a re-run can be diffed against what
these pages claim.
Nothing is timed and no speed claim is made anywhere in this survey. The reasoning is in
measurement-plan.md; the short version is that for the record counts this category is
pointed at, evaluation is not the cost.
Four candidates could not be reached from one Python process without a second toolchain — OPA,
Cedar, a conforming DMN engine, and Soufflé. Their rows are cited, and the distinction is
load-bearing: three of this survey’s sharpest results are places where a measurement disagreed
with what the documentation implied.
Can a rule be a row?#
Every candidate can. That is the finding, and it inverts the question.#
A rule loaded from a database row at evaluation time worked in every engine that was measured, and is documented in every engine that was not.
Measured (2026-09-04; versions in harness/1-245-rule-policy-evaluation-engines/requirements.txt;
probe.py, D1_rules_as_data): a rule table holding name, dialect, version,
enabled and body was created in the same SQLite file as the shipment table it governed.
Each engine’s rule was inserted as a row, selected back with an ORDER BY version DESC LIMIT 1,
and evaluated against five shipment rows.
| engine | rule stored as | result |
|---|---|---|
| cel-python 0.5.0 | a CEL expression string | evaluated all five rows |
JSONLogic (json-logic-qubit 0.9.1) | a JSON document | evaluated all five rows |
| Zen Engine 2.0.2 | a JDM decision graph, as JSON | evaluated all five rows, with the matched rule name in the output |
| simpleeval 1.0.7 | a Python expression string | evaluated all five rows |
| asteval 1.0.10 | a Python expression string | evaluated all five rows |
| rule-engine 5.0.2 | a rule-engine expression string | evaluated all five rows |
| pycasbin 2.8.0 | policy rows in a table by design | enforced against both a permitted and a denied request |
| SQLite 3.45.1 | a generated-column expression in the schema | derived all five rows |
Cited rather than measured, and documented in each case: OPA loads modules through
PUT /v1/policies/<id> or a signed bundle; Cedar’s PolicySet loads from a string, from its
JSON form, or as a template that later links; a DMN engine loads DMN XML.
One rule, seven dialects#
The same condition — over 20 kg, or worth more than 1000, or fragile and going overseas —
as it was stored in the harness’s rule table. This is the comparison, not an API tour: the
gap between these seven strings is the whole of what a chooser is choosing between.
cel-python weight_kg > 20.0 || value_usd > 1000.0 || (fragile && zone == 'overseas')
rule-engine weight_kg > 20 or value_usd > 1000 or (fragile and zone == 'overseas')
simpleeval weight_kg > 20 or value_usd > 1000 or (fragile and zone == 'overseas')
SQL weight_kg > 20.0 OR value_usd > 1000.0 OR (fragile = 1 AND zone = 'overseas')
JSONLogic {"or": [{">": [{"var": "weight_kg"}, 20]},
{">": [{"var": "value_usd"}, 1000]},
{"and": [{"var": "fragile"},
{"==": [{"var": "zone"}, "overseas"]}]}]}
Zen (a row) {"_id": "r1", "i1": "> 20", "i2": "", "o1": "true", "o2": "\"heavy\""}
Casbin p, dispatcher, shipment, sign # the rule is the ROW; the
m = r.sub == p.sub && r.obj == p.obj # matcher is the model fileTwo things are visible here that no feature table shows. The three expression dialects are
close enough to read at a glance and not close enough to be the same language — the
false-friend problem d6-two-language-cost.md prices. And the bottom two are not expressions
at all: Zen’s rule is a table row whose cells are fragments, and Casbin’s rule is data whose
meaning lives in a separate model file. For those two, “store the rule in a database” means
storing two different things in two places.
So “can rules be data” does not discriminate between engines. It discriminates between adopting an engine at all and the status quo it replaces, which is a rule expressed as procedural code inside whichever service happened to need it first. Every candidate here clears that bar. The question a chooser actually has to answer is the four that follow.
What the question is really asking, in three parts#
“Rules as data” bundles three properties that come apart, and an engine can have any one without the others.
1. Is the rule addressable — can it be looked up rather than deployed?#
An expression in a database row can be updated with an UPDATE. A Rego module in a signed
bundle is updated by publishing a bundle. A generated column is updated by a migration.
These are three different change-management stories wearing the same phrase:
| change is | reaches production | reversible by | |
|---|---|---|---|
| a row in a table | a DML statement | on the next evaluation | another UPDATE |
| a bundle | a publish + a poll interval | eventually, per instance | publishing the prior revision |
| a schema object | a migration | at deploy | a down-migration, or a table rewrite |
The middle row is the one people underestimate. OPA’s bundle mechanism is eventually consistent by design — the documentation offers that as the advantage over the policy REST API, because it is the right property “for deployments with many OPA instances”. It also means two instances can be enforcing different policy versions at the same instant, which is correct for authorization and wrong for anything where a decision must be identical everywhere.
2. Is the rule versioned — is there a history?#
None of the engines provides this. All of them permit it. The harness’s rule table
carries version and enabled columns and its loader takes the highest enabled version,
which is nine words of SQL and the whole feature. Casbin’s adapters store policy rows and say
nothing about their history. OPA bundles carry a revision in the manifest, which is the
closest thing to a built-in answer and is a revision of the bundle, not of a rule.
This is the survey’s clearest case of a capability that reads as an engine feature and is actually a schema decision. A team that wants “what did this rule say last March” gets it by writing a table that keeps old rows, in any of these engines or in none of them.
3. Is the rule co-located — does it live with the data it governs?#
Only two candidates put the rule and the rows in the same transactional boundary: the database baseline, where the rule is the schema, and any engine whose rules you choose to store in the same database as its facts.
That boundary is not decoration. When the rule and the data are in one transaction, a rule change and a data migration can be one atomic operation, and a backup contains both. When they are not, restoring the database to Tuesday and the policy bundle to Thursday is possible and nothing will complain.
The disagreement nobody puts in a feature matrix#
Storing a rule as data means the rule is a string, and a string means an encoding question that only shows up on the rows where the data is imperfect.
Measured (2026-09-04, same harness, followup.py, missing_and_null): one rule —
“fragile and the zone is overseas” — against a row whose zone is NULL, and against a row
where the zone key is absent entirely.
| engine | field present but NULL | field absent |
|---|---|---|
| simpleeval | False | raises NameNotDefined |
| rule-engine | False | raises SymbolResolutionError |
| cel-python | False | raises CELEvalError |
JSONLogic (json-logic-qubit) | False | False |
| SQLite generated column | NULL | — (the column exists or it does not) |
Four behaviors from five engines on the same rule and the same missing value.
Two of these deserve attention.
JSONLogic returns False for the absent field. Its var operator resolves a missing path
to null rather than failing, so a rule that reads a field which does not exist is
indistinguishable from a rule that read it and found nothing. A rule with a typo in a field
name evaluates cleanly and returns the wrong answer forever. Measured: {">": [{"var": "wieght_kg"}, 20]} — the field name misspelled — evaluated to False with no error raised
(same harness, D4_compile_check).
SQL returns NULL, which is neither true nor false. A row whose derived flag is NULL is
invisible to WHERE flag = 1 and to WHERE flag = 0. Measured: with one NULL-input row
and one matching row in the table, SELECT count(*) FROM t WHERE flag = 0 returned zero
(same harness). Every general-purpose engine here would have called that row false. Anyone
carrying an intuition from Python into a generated column will eventually count a set of rows
and find some of them missing.
Neither behavior is a defect. They are two coherent answers to “what does an incomplete record mean”, and the point is that storing the rule as data does not store the answer to that question. The dialect decides it, the dialect is not in the row, and the row is what gets migrated.
Whether evaluation may do I/O#
Three positions, and they are design commitments rather than features#
Most engines here assume evaluation is a pure function of the facts handed in. Some allow the host to open a door. One puts an HTTP client in the language.
| position | engines | what it means |
|---|---|---|
| Structurally pure | Cedar; JSONLogic; rule-engine | no extension point exists; a rule cannot reach anything |
| Pure unless the host opens a door | CEL; simpleeval; asteval; Casbin; the SQL predicate | the language has no I/O, but a host callable can be registered into its namespace |
| I/O in the language | Rego (http.send); Zen (function nodes); a SQL trigger | a rule author can reach the network or another table without asking anyone |
Measured (2026-09-04; harness/1-245-rule-policy-evaluation-engines/probe.py,
D2_io_via_host_function): a function that queries a second SQLite table was registered into
each evaluator’s namespace and called from a rule. It succeeded in cel-python, simpleeval,
asteval and Casbin; there is no registration point in JSONLogic or rule-engine. A SQLite
AFTER INSERT trigger read a second table and wrote a derived row. Zen’s loader callback was
invoked with the decision key and fetched the rule itself from the database — which is I/O to
get the rule, not I/O inside it, and the distinction matters.
Why the middle row is not a compromise#
“The host can open a door” sounds weaker than structural purity and is a different property, not a lesser one. The person who can introduce I/O is the person who deploys the process, not the person who writes the rule. That is a governance boundary, and it is exactly the boundary a team wants when the rules are authored by analysts or generated by a model and the runtime is owned by engineers.
Cedar’s position is stronger still and costs something specific. Because nothing can be fetched mid-decision, the caller must hand over every entity the policy might dereference. Cedar answers that with level validation — a validated policy cannot follow a chain of entity references longer than n — and level-based entity slicing, which uses the bound to fetch only what the policy can reach, with the documented guarantee that a decision made on the slice equals a decision made on everything. The best-practice guidance splits the slice into a cacheable principal part and a per-request resource part.
Read that sequence carefully, because it is the general shape of the problem. Purity does not remove the I/O; it moves it to the caller and makes it visible. The queries still happen. What changes is that they happen before the decision, where they can be batched, cached, retried and reasoned about, instead of inside it.
What I/O inside a decision actually costs#
Rego and Zen both allow it, and both document the consequences rather than hiding them. Four costs, in the order they bite.
The decision stops being reproducible from its inputs#
If a rule called an API, the decision is a function of the rule, the input, and whatever that API said at that instant. Replaying the decision later gives a different answer.
OPA takes this seriously enough to ship a mechanism for it: decision logs can carry
nd_builtin_cache, which records the input and output of every nondeterministic builtin call
in the evaluation, existing specifically so a decision can be replayed. That is the right
engineering answer, and its existence is the admission that the problem is real.
Caching changes the semantics as well as the speed#
http.send caches within a query and across queries. OPA’s documentation is explicit that it
“must not be used for effecting changes in external systems”, because two identical requests
in one policy may result in one outbound call. A rule author reasoning about the rule as a
sequence of steps will be wrong about how many times each step happened.
Availability of the decision becomes availability of the dependency#
OPA’s own external-data page says it plainly: “latency and availability of decision-making are dependent on the network.” Zen’s function nodes carry a 5,000 ms default timeout, which is the same statement expressed as a number. A decision path that was a microsecond of arithmetic becomes a network call in the hot path, and the failure mode is that the thing making the decision is down because something else is.
Reentrancy, where the fetched fact is governed by the rule#
The specific hazard when a rule reads the same store it is deciding about: the trigger that reads a table that a trigger writes, the policy that fetches an entity whose visibility that policy controls. Databases handle this with defined trigger-recursion semantics that differ between engines; the general-purpose engines have no position on it at all, because the question does not arise until the host opens the door.
The two-sided reading of the SQL trigger#
The database baseline sits in the third row of the table, and it is the only candidate for which that is unambiguously an advantage.
Measured (same harness, D2): an AFTER INSERT trigger on shipment read a zone_policy
table, computed a surcharge, and wrote a row recording both the value and whether the rule had
reached it — ('rule') for a row whose lookup succeeded, ('manual') for a row with a NULL
key. Two inserts, two audit rows, no application code.
That is cross-row I/O inside evaluation, and it is fine here for a reason that does not generalize: the I/O is inside the same transaction as the write that triggered it. There is no network, no cache with its own semantics, no availability question. The trigger sees a consistent snapshot and its effects commit or roll back with the statement.
The cost lands elsewhere. Trigger logic is invisible from the application, executes on a writer that never asked for it, and — on a large table under load — is the hardest kind of performance problem to attribute. “Purity” is not the axis the database should be judged on; “can you see what ran” is.
The rule that follows from all of this#
Prefer an engine whose language has no I/O, and put the fetching in the caller where it is visible. Where an engine offers a host-function hook, treat registering a function that performs I/O as a deployment decision with a review, not a convenience.
Two exceptions, both real:
- A trigger, where the fetched row is in the same database and the same transaction. The properties that make in-decision I/O dangerous are all absent.
- Dynamic data of a size that cannot be pushed. This is OPA’s stated reason for
http.sendand it is a legitimate one. It is also the last of five options in OPA’s own taxonomy, which is the position it deserves.
Which records the rule reached#
The question#
Not “did the rule pass” but: across the records this rule was supposed to decide, which ones did it decide, and which ones did a person have to?
That is a different shape from a boolean. It is a per-record account with at least three outcomes — the rule reached it, the rule declined it, a human supplied the answer — and it is what makes a rule auditable and a migration reversible. A rule that covers 94% of records with the remaining 6% named is a usable rule. A rule that covers “most” of them is a guess.
Three engines answer it. The rest return a value.#
Measured (2026-09-04; harness/1-245-rule-policy-evaluation-engines/probe.py, D3_provenance):
| engine | what evaluation returns | provenance |
|---|---|---|
| Zen Engine 2.0.2 | result, performance, and trace | yes — the trace named the matched rule’s _id, its index, and the input values consulted |
| pycasbin 2.8.0 | enforce_ex() returns (bool, matched_row) | yes — an allow returned the policy row ['dispatcher', 'shipment', 'sign']; a deny returned [] |
| SQL database | the derived value | yes, by construction — a trigger wrote 'rule' or 'manual' into a derivation column beside the value, in the same statement |
| cel-python 0.5.0 | a value | no |
| JSONLogic | a value | no |
| simpleeval / asteval | a value | no |
| rule-engine 5.0.2 | a boolean from matches() | no |
Cited, and documented: Cedar returns Diagnostics with a reason field — the set of
policy IDs that determined the outcome, and for a deny caused by a forbid, which forbid.
That is the tightest form of the answer in the survey. OPA’s decision logs carry the
input, result, bundle revision and, with explain=full, a trace of Enter/Exit/Eval/
Fail/Redo events — richer than any of the above and aimed at a different consumer, since
it is a log stream rather than a return value.
The three answers are not the same answer#
They differ on who is being told, and when.
Cedar and Casbin answer at the call site. The caller that asked gets, in the same return, which policy decided. That is the right shape for authorization, where the consumer is the service handling the request and the audit question is “why was this request refused”.
Zen answers with a trace. Richer — every node’s input, output and timing — and heavier. It is a debugging and simulation artifact rather than something to keep for every decision.
The database answers by writing it down. A derivation column set alongside the derived
value turns the question into SELECT derivation, count(*) FROM t GROUP BY derivation. It is
the only one of the three that survives the process ending, and the only one where the answer
is queryable months later against the same rows it describes.
For the question this discriminator actually asks — which records did the rule reach — the last shape is the one that answers it. The first two answer why did this one decision go that way, one decision at a time.
The finding: provenance is a harness property, not an engine property#
Five of the eight measured candidates return a bare value, and for those the caller must record the outcome itself. That sounds like a gap in five engines. It is better read the other way round.
The caller already owns the loop. Something iterates the records, calls the engine, and writes the result somewhere. Recording how each record was decided costs one more column and one more assignment in a loop that already exists. Adopting an engine to obtain provenance is adopting a dependency for a feature that is four lines of the code you were going to write anyway.
The corollary is sharper: an engine that does report provenance is not thereby giving you
the per-record account. Casbin’s enforce_ex tells you which row matched this call.
Turning that into “which records across the table the rule reached” still means the loop and the
column. The engine saves you the part where you work out why; it does not save you the part
where you write it down.
So this discriminator, unlike the other five, mostly does not separate the engines. It separates two designs:
- Derive and store, with the derivation recorded. The rule’s output is a column; a second column says where the value came from. Every record has an account. Anyone can query the coverage. A rule change is a re-derivation that can be diffed against the previous one.
- Derive on read. The rule runs when someone asks. There is nothing to query, coverage is unknown, and “which rows would change if I edited this rule” cannot be answered without running it over everything — which is the stored design, arrived at reluctantly.
What the derivation column is worth, concretely#
Three things you can do with it that you cannot do without it, none of which requires an engine.
Measure coverage before committing. Derive into a shadow column, group by derivation, look at how many records the rule failed to reach. A rule that reaches 94% of records is a decision about the other 6%; a rule that reaches 40% is a rule that has not been written yet.
Keep human judgment. The records a rule cannot decide are usually the interesting ones —
the edge case, the exception, the one somebody negotiated. Marking them manual protects them
from the next re-derivation, which is the difference between a rule that helps and a rule that
quietly overwrites the exceptions every time it runs.
Make the change reversible. With the previous derivation stored, changing the rule produces a diff: these records changed, these did not, these were manual and stayed manual. Without it, changing the rule produces a new answer and no way to see what moved.
That last point is where this discriminator meets the question the category is usually bought to answer. A rule is only an evolvable object if you can see what an edit did to the records it governs. None of the sixteen candidates provides that. What provides it is storing the output and recording the derivation — a schema decision, available with any engine on this list and with none of them.
Checking a rule before it runs#
Two questions, and engines that are good at one are frequently bad at the other.
Can a broken rule be caught before it decides anything? Three ways a rule is broken: it does not parse; it parses but names something that does not exist; it parses and names real things but compares them incoherently. A check that catches only the first is a syntax highlighter.
Can something other than a careful programmer write one safely? A colleague, an analyst, a language model. That is a question about how small and how enclosed the language is.
What was measured#
Three malformed rules through whatever check phase each engine offers, without evaluating
(measured 2026-09-04; versions in
harness/1-245-rule-policy-evaluation-engines/requirements.txt; probe.py,
D4_compile_check, and followup.py, cel_declared_environment):
| engine | syntax error | misspelled name | number vs. string |
|---|---|---|---|
| SQL (scratch database) | rejected — syntax error | rejected — no such column: wieght_kg | n/a (SQL coerces) |
| rule-engine, typed | rejected — RuleSyntaxError | rejected — SymbolResolutionError | rejected — EvaluationError |
| rule-engine, untyped | rejected by is_valid() | accepted | accepted |
| Zen Engine | reported as an error dict | accepted | n/a |
| cel-python, undeclared env | rejected — CELParseError | accepted | accepted |
| cel-python, declared env | rejected | accepted | accepted |
| simpleeval / asteval | rejected by ast.parse | not checked (names are extractable) | not checked |
| JSONLogic | n/a — JSON always parses | evaluates to False | evaluates |
| pycasbin | model file loaded clean | fails on first enforce() | — |
Five results in that table are stated in prose below, because each contradicts something a reader would reasonably assume.
The database catches more than the dedicated engines#
Creating a generated column in a throwaway database and letting the database’s parser judge it rejected both the syntax error and the misspelled column, by name. That is a full type and name check against the actual schema, performed by the actual engine, with no library and no test data. Two of the purpose-built rule engines cannot do the second one at all.
CEL’s type checker is in the specification and not in this implementation#
CEL is designed with a compile phase separate from evaluation, and the language’s whole
proposition rests on being checkable. Measured: cel-python 0.5.0 accepted a misspelled
identifier and a comparison of a number against a string at compile() whether or not the
environment declared its variables — the same result with Environment() and with
Environment(annotations={...}). Only the syntax error was caught.
This is the survey’s cleanest illustration of why “the standard says” and “the library does” have to be separated. A team choosing CEL for its checkability, and reaching it through this Python implementation, does not get checkability. The finding is about the port, not the language — but the port is what runs.
JSONLogic’s failure mode is the worst available#
A misspelled field name is not an error in JSONLogic. var resolves an unknown path to null,
so {">": [{"var": "wieght_kg"}, 20]} evaluated to False, silently, forever. There is no
check phase to add: the format is JSON, JSON always parses, and the operator table is looked
up at evaluation.
A rule that is wrong and raises is a bug. A rule that is wrong and returns a plausible answer is a rule nobody will look at again.
Zen’s validator reports instead of raising#
validate_expression() returns None for a clean expression and a dictionary — {'type': 'parserError', 'source': ...} — for a broken one. A caller wrapping it in try/except
concludes that everything is valid. It also validates a misspelled name clean, because there
is no environment to check it against.
Casbin has no load-time check at all#
Measured: a model file whose matcher referenced p.subject where the policy defines
p.sub loaded without complaint and raised NameNotDefined on the first enforce() call.
The failure surfaces in production traffic rather than at startup or in CI.
Cedar’s validator, and the thing above it#
Cited: Cedar’s validator runs against a schema, in strict mode by default, and catches
unrecognized entity types and actions, principal/resource incompatibility with the action,
unrecognized attributes, access to an optional attribute without a has guard, and
operator type mismatches. It warns on conditions that can never be true. It is an entirely
separate API, invoked when policies are loaded.
Above that sits something no other candidate has. Cedar Analysis (announced 2025-06-16)
compiles policies to SMT formulas and answers questions about all possible inputs: are these
two policy sets equivalent, is this one strictly more permissive than that one, is this
permit shadowed by a forbid, can this condition ever hold. It is implemented in Lean with
soundness and completeness proofs.
That is a different kind of preview from every other entry here. A test suite samples the input space; this reasons over it. For the specific question “what does this edit change”, it is the only tool in the survey that answers without running anything against real data.
OPA’s answer is a test suite and it is the best-equipped one. opa check validates,
opa test discovers test_-prefixed rules, --coverage reports per-file and overall
percentages with covered and uncovered line ranges, --fail-on-empty fails a CI job that
found no tests. That is ordinary engineering practice made available for policy, which is more
than most of this survey offers and less than reasoning over the whole space.
Authoring by something other than a programmer#
Three properties decide whether a language is safe for an analyst or a model to write in.
Small and enclosed. Cedar has no loops and no user-defined functions. CEL is non-Turing-complete by design. JSONLogic’s operators are a fixed table. A decision table has no expressions outside its cells. These are all small enough that a generated rule can be read in full by a reviewer, which is the property that matters — not that generation is safe, but that review is cheap.
Hostile input is refused. Five attacks through each in-process evaluator, under a
five-second clock and a 2 GB cap (measured 2026-09-04, same harness, D4_hostile):
| import | subclass walk | __mro__ | 9**9**9 | 100 MB string | |
|---|---|---|---|---|---|
| cel-python | refused | refused | refused | refused | refused |
| simpleeval | refused | refused | refused | refused (NumberTooHigh) | refused (IterableTooLong) |
| asteval | refused | refused | refused | refused | refused |
| Zen Engine | refused | refused | returned None | refused | refused |
| rule-engine | refused | refused | refused | evaluated | refused |
Two notes. rule-engine computed the exponent bomb that every other evaluator declined —
resource exhaustion is the guard a small language most often forgets, because an expression
made only of permitted operations can still take the process down. And Zen returned None
rather than an error for an attribute walk, which is a null-propagating language behaving
consistently rather than a hole, but it is the same shape as JSONLogic’s silent False.
The rule can be diffed. A decision table diffs by row and an expression diffs by line; both are reviewable. A JDM graph diffs as JSON with node identifiers and positions in it, which is worse. Anything generated and committed should be normalized first.
Why “dry run” means two different things#
The word covers two capabilities and engines advertise one while readers want the other.
Is this rule well-formed? The table at the top of this page. A property of the rule alone.
What would this rule do to my records? Run it over the actual records, in shadow, and count. No engine in this survey provides this, and the reason is structural: it needs the records, so it belongs to whatever holds them.
That is the same conclusion the provenance discriminator reaches from the other direction, and it is the survey’s central practical finding. Derive into a shadow column, compare it against the current one, count the rows that moved. That is a dry run over real data, it works with any engine on this list, and it is the thing a team is actually asking for when they say they want to preview a rule change.
Blocking versus reporting#
The capability#
Run a rule, record what it would have done, and do not do it. Every organization that adopts a rule into an existing system needs this, because the rule is always wrong for the first few weeks and the records that violate it were created before it existed.
Only one engine ships it as a switch, and it is not the engine#
Gatekeeper, OPA’s Kubernetes admission controller, has an enforcementAction field.
Read from the source rather than the documentation, the supported values are:
| value | effect |
|---|---|
deny | reject the request |
dryrun | evaluate, record the violation, admit |
warn | admit with a warning to the client |
scoped | different actions at different enforcement points |
scoped is the interesting one, and it is absent from Gatekeeper’s own documentation page,
which lists only the first three. It routes an action per enforcement point —
validation.gatekeeper.sh (the admission webhook), audit.gatekeeper.sh, gator.gatekeeper.sh
(the CLI), vap.k8s.io — so a policy can be dryrun at admission while deny in audit.
That is the full shape of the capability, and the only place in this survey it exists as a
first-class feature.
Note what it is a property of. OPA itself has no report-only mode; it returns a decision and the caller decides whether to act on it. The switch lives in the enforcement layer, which is the correct place for it, and the general lesson is in the next section.
Everywhere else, it is a property of the caller#
Cedar returns a decision; the service decides what to do with it. Casbin’s enforce_ex
returns (bool, matched_row) and logs the result — at WARNING on a deny, a noisy default the
code comments acknowledge — and acting on it is the caller’s job. Zen returns a result and a
trace. Every in-process evaluator returns a value.
So for fifteen of the sixteen candidates, “report, don’t block” is one branch:
decision = evaluate(rule, record)
record_it(decision)
if MODE `==` "enforce" and not decision:
refuse()
Which is correct, and it is not nothing — it is a deployment flag, a metric, and a decision about who reads the metric. But it is not a feature to shop for, and an engine chosen because it “supports dry run” was chosen on a criterion that does not discriminate.
The database is the exception, in both directions#
Measured (2026-09-04; SQLite 3.45.1;
harness/1-245-rule-policy-evaluation-engines/probe.py, D5_*):
A CHECK constraint is enforce-only and unconditional. An insert violating it was
rejected with CHECK constraint failed. There is no report-only mode; a constraint that
admits violations is not a constraint.
The same predicate as a SELECT is observe-only. SELECT id FROM shipment WHERE needs_signature = 1 returned the three rows that satisfied it and blocked nothing.
So the database gives you both modes and does not give you a switch between them — they are two different statements over one predicate, which means the predicate has to be written once and used twice, and keeping the two copies in agreement is a new job. That is a real cost and it is the mirror image of the property that makes the database strong elsewhere.
Unless the predicate is a generated column, in which case there is one copy: the column is
derived on every write, WHERE flag = 1 observes it, and a CHECK over the same column
enforces it. Measured: a generated column derived correctly for a row inserted by a client
that had never loaded the application (same harness, D5_applies_to_every_writer).
That is the migration path the whole discriminator is about:
- Add the derived column. Nothing is enforced. Everything is visible.
- Query the violations. Fix them, or discover the rule is wrong. This is where most of the value is, and most of the surprise.
- When the count reaches zero, add the constraint.
Three steps, one predicate, no feature flag, and each step is reversible.
What “observe” is worth, and why teams skip it#
The reason to run in observe mode is not caution about the enforcement. It is that the violations are data about the rule. A rule that flags four hundred existing records has almost certainly been written wrong, and the four hundred are the evidence. Turning it on in blocking mode converts that evidence into an incident.
Teams skip the step because observe mode produces a number nobody owns. A count of violations with no assignee is a dashboard, and dashboards decay. The version of this that works attaches the count to the rule’s own record — how many records violate this rule today, trending — which is the provenance discriminator again, from a third angle.
Where this does discriminate#
Two situations, and outside them the answer is “any of them”.
Where the enforcement point is not yours. If the rule is enforced by an admission
controller, a gateway, a proxy or a database constraint — something you configure rather than
call — then whether that thing has a report-only mode is decisive, because you cannot wrap
it in an if. This is why Gatekeeper has the feature and OPA does not.
Where the enforcement is destructive. A rule that refuses a write can be run in shadow by letting the write through and counting. A rule that rewrites a value cannot: applying it is the thing you were trying to preview. That case needs the shadow-column pattern — derive the new value into a second column, diff it against the current one, and swap only when the diff is what you expected — and no engine in this survey does it for you.
What a second rule language costs#
Every candidate except one introduces a dialect alongside whatever a team already writes. This is the largest cost in the category and the one most often left out of the comparison, because it does not appear in any feature matrix and it does not arrive until later.
Here it is priced, per candidate, with the parts that were measured marked as such.
The dialects#
| candidate | the second language | who else speaks it |
|---|---|---|
| OPA | Rego | large; changed incompatibly at v1.0 (2024-12-20) |
| Cedar | Cedar policy language | growing; CNCF Sandbox since 2025-10-08 |
| Casbin | a matcher expression, plus a .conf metamodel | large for the model, tiny for any given matcher |
| CEL | CEL | large — Kubernetes, Envoy, and others |
| DMN | FEEL, plus decision-table semantics | large, standardized, twenty years old |
| Zen | ZEN Expression Language, plus JDM | one vendor |
| JSONLogic | a JSON operator AST | medium, and unspecified |
rule-engine | its own grammar | one project |
| the database | SQL expressions | the whole team, already |
| your own evaluator | a Python expression subset | the whole team, already |
The last two rows are the point. SQL and Python are not second languages for a team that already writes them, which is why the two baselines carry a structural advantage that no feature can offset.
The five costs, in the order they arrive#
1. Learning, the smallest cost and the only one anyone budgets for#
A few days. Real, bounded, and not the problem.
2. Reviewing, which is permanent#
A pull request that changes a Rego policy needs a reviewer who reads Rego. That reviewer is scarcer than the author, because writing a rule is a task and reviewing one is an availability requirement. On a small team the number of people who can review is often one, which converts a rule change into a scheduling problem.
3. Debugging at three in the morning#
The rule denies something it should allow. The engineer on call knows the primary language, possibly not the dialect. Every step of the investigation — read the rule, form a hypothesis, evaluate it against the input — happens in a language they use occasionally.
Where the dialect ships a REPL and a trace, this is manageable: OPA’s opa eval --explain
and Cedar’s Diagnostics.reason exist for this. Where it does not, the engineer
adds print statements to a language that may not have them.
4. The false-friend tax, and it was measured#
The most expensive dialects are the ones that look like a language the team knows.
Measured (2026-09-04; rule-engine 5.0.2, simpleeval 1.0.7;
harness/1-245-rule-policy-evaluation-engines/followup.py, power_associativity): the
expression 9 ** 9 ** 2 evaluates to 1.966271e+77 in Python and in simpleeval, and to
1.500946e+17 in rule-engine. Python’s exponent operator is right-associative; that
grammar binds it left. Same characters, no error, sixty orders of magnitude apart.
A reviewer who knows Python reads that rule and is confident about it. Their confidence is the defect. A dialect that looked unlike Python would have made them check.
The same shape appears at the boundary of every dialect that borrows familiar syntax, and it
is why the null-handling table in d1-rules-as-data.md matters more than it first reads:
four different answers to “what does a missing field mean”, none of them signposted, all of
them written in syntax the reader recognizes.
5. The dialect outliving the choice, and this was observed three times#
The dialect is a bet on a project, and the bet has been called recently.
- Oso / Polar. Deprecated 2023-12-18. Last code commit 2024-06-13. The successor is a hosted service with a differently documented dialect. Polar had no specification and one implementation, so there was nothing to fork toward.
- Rego. OPA v1.0.0 (2024-12-20) made
ifandcontainsmandatory and turned several strict-mode checks on by default. A--v0-compatibleflag exists with no announced removal. The language did not die; it forked in place, and everything written about it before 2025 describes the other one. - Camunda 7’s DMN engine. Community Edition end of life announced for October 2025, final release 2025-10-14, repository archived 2025-11-04. The dialect survived — DMN is an OMG standard with six conforming engines — and the engine did not. This is the single best argument for a standardized dialect over a good one.
Weigh those three against each other. Rego is the largest ecosystem here and it changed under its users. Polar was elegant and is gone. DMN is the clumsiest of the three and is the only one where a dead engine cost its users a migration rather than a rewrite.
The exception: dialects a team already has#
Two rows in the first table are free, and one is nearly free.
SQL. For a team that already has a database, an expression in a generated column
introduces no new language, no new reviewer requirement, and no new debugger. The cost is
narrower and specific: three-valued logic. Measured (same harness, missing_and_null): a
generated column over a NULL input produced NULL, and that row was invisible to both
WHERE flag = 1 and WHERE flag = 0. SQL’s NULLs are common knowledge; what one of them
does to a derived boolean is not.
A Python expression subset. Zero new syntax. The cost is that the subset is undocumented — what a rule may contain is defined by whichever allowlist someone wrote, and it is not written down anywhere a rule author can read.
CEL, partially. Not a language a team already has, but the widest reach of any small expression language here, so the skill is portable and the tooling is somebody else’s problem. Against that, this survey measured its Python implementation accepting a misspelled identifier at compile time, which is the property CEL is chosen for.
Pricing it#
The two-language cost is roughly constant per dialect and independent of how many rules you write in it. So it divides:
- A dozen rules that change twice a year. The dialect costs more than the rules are worth, every time. Use what the team already writes.
- Hundreds of rules that change weekly, authored by people who are not engineers. The dialect is not a cost, it is the product — a decision table is the artifact those people edit, and there was never a version of this where they wrote Python.
- Between the two, which is where most teams sit: the dialect is worth its cost only if it buys something the primary language cannot. In this survey there are exactly three such things — Cedar’s analysis over all inputs, DMN’s seven hit policies, and CEL’s portability across runtimes.
If the answer is not one of those three, the second language is being paid for with nothing bought.
The measurement plan#
What this survey runs, what each level settles, and what it does not run.
The category is unusual in that the questions this category turns on are about capability, not speed. For the record counts these engines are pointed at — thousands, not billions — evaluation is not the cost. Authoring, review, and the day the rule changes are the cost. So every level below is a capability check whose answer is “it returned” or “it raised”, and none of them is timed. That also means nothing here depends on the machine, which matters because a capability result is portable in a way a benchmark is not.
The levels#
| what it settles | cost | rung reachable | coverage | |
|---|---|---|---|---|
| L0 | version, license, release date, maintenance state, download reach | minutes, registry APIs | observed | all 16 candidates |
| L1 | can a rule loaded from a database row be evaluated against another row? | one process | measured-local | 8 |
| L2 | can a rule reach a fact the caller did not hand it? | one process | measured-local | 8 |
| L3 | does the engine report which rule reached a record? | one process | measured-local | 8 |
| L4 | is a rule checkable before it runs — syntax, unknown names, types? | one process | measured-local | 8 |
| L5 | does the evaluator refuse a hostile expression? | one process, guarded | measured-local | 6 |
| L6 | do two engines that look alike agree about the same string? | one process | measured-local | 5 |
| L7 | do the ports of one format agree with each other? | one venv per port | measured-local | 3 |
L1 through L5 are the five checkable discriminators. L6 and L7 exist because L1 produced answers that looked like harness bugs and were not.
The cut line, and where it falls#
Step 3.5’s default is everything that runs in one container, no external service, no second machine. Eight of the sixteen candidates clear it: the ones that are a Python import, plus the database, which is a SQLite file.
Deferred, and why:
- OPA. The sanctioned Python integration is a sidecar over HTTP, which is a second process. The WebAssembly path that would have made it in-process is not viable — its Python package last released in 2022, the Go WASM SDK is archived, and the WASM build does not support the one builtin the I/O question is about.
- Cedar. A Rust crate reached through an unofficial binding that trails the engine by four minor versions. Measuring the binding would report the binding.
- A conforming DMN engine. Every engine that passes the conformance suite is a JVM, a Rust binary or a Go service.
- Soufflé. A C++ compiler toolchain.
For those four, the survey’s capability claims are marked cited and say so on the line. The
distinction is not cosmetic: a cited capability is what a vendor says the engine does, and
three of this survey’s sharpest findings are places where a measurement disagreed with what
the documentation implied.
The cut line was not raised, and here is the reason. The claim that would justify
raising it — “OPA’s http.send makes a decision non-reproducible” — is not in dispute. OPA’s
own documentation says it, warns about it, and ships a cache of nondeterministic builtin
results specifically so a decision can be replayed. Standing up a sidecar to confirm
documentation that already agrees with the finding would buy nothing.
The fact set#
Five shipments: a weight, a destination zone, a declared value, a fragile flag. The rule is “this needs a signature” — over 20 kg, or worth more than 1000, or fragile and going overseas. One row has a NULL zone.
Dull on purpose, for two reasons. A rule anyone can hold in their head makes a disagreement between engines legible rather than arguable. And the NULL row is where the disagreement lives: it is the row on which five engines gave four different answers.
What is not measured, and why#
Throughput. Not reported anywhere in this survey, and no speed claim is made. The
category’s decision does not turn on it, and a rate measured on one laptop against seven
engines with three different execution models would invite exactly the comparison that
docs/map/17-the-evidence-ladder.md says a measurement cannot support.
Rule-authoring time by a non-programmer. The most decision-relevant number in the whole category and it needs people, not a container. Flagged as the gap.
Behavior at policy-store scale. How a thousand stored rules behave — cache invalidation, reload cost, ordering — is real and needs a second machine. Deferred.
Why no floor model#
A Workshop floor model runs the measurement in the reader’s browser. Three of the eight
measured candidates are compiled extensions (zen-engine is a Rust binary, cel-python
depends on a compiled regular-expression library, pycasbin on a compiled glob matcher), and
the database baseline needs a real SQL engine. The subset that would run in a browser is the
pure-Python evaluators, which is the part of the comparison a reader least needs help
believing.
The reproducible artifact is therefore the harness rather than a page: pinned versions, a
committed results/, and a script that prints its JSON to stdout so a re-run can be diffed
against what the survey claims.
What the six discriminators settle#
Two of them do not discriminate, and that is the finding#
Rules as data is universal. Every engine measured evaluated a rule loaded from a database row, and every engine cited documents the same capability. The property separates adopting an engine from the status quo it replaces — a condition written as procedural code inside whichever service needed it first — and every candidate clears that bar equally.
Report-only mode is a property of the caller almost everywhere. Fifteen of sixteen
candidates return a decision and let the caller decide what to do with it, which makes
report-only one branch rather than a feature. The exception is Gatekeeper’s
enforcementAction, and it is an exception for a reason worth generalizing: the switch has
to exist wherever you cannot wrap the decision in an if — an admission controller, a
proxy, a database constraint.
So a chooser who shortlisted on those two criteria has not narrowed anything.
Three of them discriminate sharply#
Whether evaluation can do I/O splits the field into three positions that are design commitments rather than features. Cedar cannot, by construction. Most engines cannot unless the host registers a function, which puts the decision with whoever deploys the process rather than whoever writes the rule. Rego and Zen put I/O in the language itself.
The general rule that follows: purity does not remove the I/O, it moves it to the caller and makes it visible. Cedar’s entity slicing is the fully worked version — the queries still happen, but before the decision, where they can be batched and cached, with a documented guarantee that the sliced decision equals the complete one.
Whether a rule can be checked before it runs is where the largest surprises are, and the ranking is not the one the marketing implies:
- SQL, in a scratch database — the database’s own parser rejects a misspelled column by name.
- Cedar — a validator with a soundness proof, plus SMT analysis that answers questions about all inputs rather than the ones somebody tested.
rule-enginewith a type resolver — catches syntax, unknown names and type mismatches.- OPA —
opa check,opa test, coverage with per-file percentages. - cel-python — syntax only, even with every variable declared, in a language chosen for being checkable.
- Zen — syntax, reported as a return value that a
try/exceptwill miss. - Casbin — nothing; a broken matcher loads clean and fails on the first request.
- JSONLogic — worse than nothing; a misspelled field evaluates to
Falseforever.
What a second dialect costs is the largest item in the category and has no column in any comparison. It is roughly constant per dialect and independent of how many rules you write in it, so it divides cleanly by volume — and for the common case of a dozen rules that change twice a year, it exceeds what the rules are worth.
And one of them is answered outside the engines entirely#
Provenance — which records the rule reached, and which needed a person — is reported by three candidates and is the caller’s job in the rest. But even the three that report it answer a narrower question: why did this one decision go that way, not what is this rule’s coverage across the records it governs.
The per-record account is a schema decision. Derive into a column; write a second column saying where the value came from. It costs one assignment in a loop the caller already writes, it works with every engine on this list and with none of them, and it is the only thing in this survey that makes a rule change reversible — because with the previous derivation stored, an edit produces a diff instead of a new answer.
The verdict#
For most teams, most of the time, a dedicated rules engine does not earn its two-language cost.
Between the database and a small sandboxed expression evaluator, all six discriminators are answered without adding a dialect, a service, or a dependency with a governance risk. The database applies to every writer, catches a misspelled column with the database’s own parser, and gives provenance as a column. A hand-rolled or off-the-shelf expression evaluator gives the parts the database cannot — rules that change faster than migrations, and a rule that is not married to one SQL engine’s syntax. The discipline that matters is one copy of the rule, not which engine evaluates it, and no engine on this list enforces that discipline for you.
The narrow band where a dedicated engine does earn its cost has three entrances, and they are the three things the baselines cannot do:
A non-programmer authors the rules. Then the decision table is the product, not an implementation detail, and there was never a version of this where an analyst wrote SQL. DMN if the domain already thinks in hit policies and a JVM is acceptable; Zen if the rules must be edited and evaluated from Python.
The same rule must give the same answer in more than one runtime. A browser and a backend, a gateway and a service, three languages. CEL or JSONLogic, because the rule is a portable document — with the caveats each profile carries.
The rules are an authorization surface that must be reasoned about rather than tested. Cedar, and specifically its analysis tooling, which answers whether two policy sets are equivalent and whether one is strictly more permissive. Nothing else in this survey reasons over the whole input space, and for an authorization model that is the difference between a test suite and an argument.
Outside those three, the second language is being paid for and nothing is being bought.
The question underneath the survey#
Can a rule be a first-class, evolvable object — versioned, migrated, previewed, reversed, the way a database column is?
The measurements say: partly, and the part that works is not the part the engines provide.
Versioning, addressability and storage are available everywhere and are a schema decision. Preview against real data — “what would this edit do to my records” — is provided by no engine in this survey, because it needs the records and the engines do not have them. The per-record account of what a rule reached is likewise a column, not a feature. Reversibility follows from the two together and from nothing else.
What the engines do provide, and what a baseline cannot, is a smaller list than the category’s framing suggests: a language that is portable across runtimes, a table format a non-programmer can edit, and — in exactly one case — a formal argument about what a policy change does before it is made.
The measurements#
Method, machine, versions, and the raw results the discriminator pages draw on.
Method#
One process, no external service, no second machine. A SQLite database holding five shipment
rows and a rule table; each engine’s rule inserted as a row, selected back at evaluation
time, and run against the shipments. Failures are recorded rather than raised — “this engine
cannot do that” is the result.
The two resource-exhaustion attacks run under a five-second alarm with the process address space capped at 2 GB, so an engine with no guard of its own is recorded as having none rather than taking the run with it.
Machine: aarch64, WSL2, CPython 3.12.3, SQLite 3.45.1. Nothing here is timed, so the machine does not enter any claim.
Versions: cel-python 0.5.0, json-logic-qubit 0.9.1, zen-engine 2.0.2, simpleeval
1.0.7, asteval 1.0.10, rule-engine 5.0.2, pycasbin 2.8.0. Run 2026-09-04. Full pins in
harness/1-245-rule-policy-evaluation-engines/requirements.txt; raw JSON in that directory’s
results/.
The rule and the fact set#
Five shipments with a weight, a destination zone, a declared value and a fragile flag; one with a NULL zone. The rule: over 20 kg, or worth more than 1000, or fragile and going overseas.
Written seven ways — a CEL expression, a JSONLogic document, a JDM decision table, a Python
expression, a rule-engine expression, a Casbin policy row, a SQL generated column — and run
against the same five rows.
L1 — a rule loaded from a database row#
Every engine evaluated all five rows. The results agreed: rows 2 and 4 need a signature, rows 1, 3 and 5 do not.
Zen returned more than a boolean, because a decision table has output columns: for row 2,
{'needs_signature': True, 'matched_rule': 'heavy'}; for row 4, 'high value'; for the
others, 'default'. That is provenance arriving as a side effect of the format rather than as
a feature.
L2 — reaching a fact the caller did not pass#
| engine | host-function hook | result |
|---|---|---|
| cel-python | functions= on the program | a function querying a second table returned True |
| simpleeval | functions= on the evaluator | same |
| asteval | any callable in the symbol table | same |
| Casbin | add_function() | documented; ordinary Python callables |
| Zen | a loader callback | invoked with the decision key; fetched the rule, not a fact |
| SQL | a trigger | read a second table and wrote a derived row, in-transaction |
| JSONLogic | — | no registration point; the operator table is module-level |
rule-engine | — | no host-function API; builtins are fixed |
L3 — which rule reached the record#
Three engines answer. Casbin’s enforce_ex returned (True, ['dispatcher', 'shipment', 'sign']) for an allow and (False, []) for a deny. Zen’s trace named the matched rule’s
identifier, its index, and the input values consulted:
`{'index': 0,
'reference_map': {'weight_kg': 31.5, 'value_usd': 900}`,
'rule': `{'_id': 'r1', 'weight_kg[i1]': '> 20', 'value_usd[i2]': ''}`}
The database’s answer is a derivation column written by the same trigger that computed the
value — ('rule') where a lookup succeeded, ('manual') where the key was NULL.
The other five return a value.
L4 — checking a rule before running it#
Three malformed rules: a truncated expression, a misspelled identifier, and a comparison of a number against a string.
| engine | syntax | misspelled name | type mismatch |
|---|---|---|---|
| SQL, in a scratch database | rejected — syntax error | rejected — no such column: wieght_kg | n/a |
rule-engine, typed | rejected | rejected | rejected |
rule-engine, untyped | rejected | accepted | accepted |
| cel-python, variables declared | rejected | accepted | accepted |
| Zen | reported as an error dictionary | accepted | n/a |
| simpleeval / asteval | rejected by ast.parse | not checked; names extractable from the tree | not checked |
| JSONLogic | n/a | evaluated to False | evaluated |
| Casbin | model file loaded clean; NameNotDefined on first enforce() |
Two of these were unexpected enough to be re-run in isolation.
cel-python does not type-check, with or without declarations. Compiling
wieght_kg > 20.0 and weight_kg > 'twenty' succeeded against both a bare environment and
one declaring every variable’s type. The project’s README states the position outright:
“Rather than try to pre-check types, we’ll rely on Python’s implementation.” The reference Go
implementation combines parse and check and returns type errors.
Zen’s validator reports rather than raises. validate_expression() returns None for a
clean expression and a dictionary for a broken one:
"weight_kg >" -> `{'type': 'parserError', 'source': 'Unexpected end of unary expression at (11, 11)'}`
"(weight_kg > 20" -> `{'type': 'parserError', 'source': "Unexpected token: None at (15, 15); Expected Bracket."}`
"wieght_kg > 20" -> None
A caller wrapping it in try/except concludes everything is valid. The first probe written
for this survey did exactly that.
L5 — hostile expressions#
__import__ | subclass walk | __mro__ | 9**9**9 | 100 MB string | |
|---|---|---|---|---|---|
| cel-python | refused | refused | refused | refused | refused |
| simpleeval | refused | refused | refused | refused NumberTooHigh | refused IterableTooLong |
| asteval | refused | refused | refused | refused | refused |
| Zen | refused | refused | returned None | refused | refused |
rule-engine | refused | refused | refused | evaluated | refused |
The two right-hand columns are the ones a hand-rolled allowlist forgets: an expression built only from permitted operations can still exhaust the process.
L6 — two engines, one string, two answers#
9 ** 9 ** 2:
| value | |
|---|---|
| Python | 1.966271e+77 |
| simpleeval | 1.966271e+77 |
rule-engine | 1.500946e+17 |
Python’s exponent operator is right-associative. That grammar binds it left. No error, sixty orders of magnitude apart, in a language advertised as Python-like.
L6b — missing and NULL#
One rule, “fragile and the zone is overseas”, against a row whose zone is NULL and a row
where the zone key is absent.
| engine | NULL | absent |
|---|---|---|
| simpleeval | False | raises NameNotDefined |
rule-engine | False | raises SymbolResolutionError |
| cel-python | False | raises CELEvalError |
| JSONLogic | False | False |
| SQL generated column | NULL | — |
The SQL row is worth restating: with one NULL-flag row and one true row in the table,
SELECT count(*) FROM t WHERE flag = 0 returned zero. A NULL-derived row is invisible to
both the true and the false query.
L7 — three distributions, one module name#
json-logic, json-logic-qubit and panzi-json-logic all install a top-level Python module
named json_logic. Whichever pip resolves last wins, and nothing warns.
Run in one clean virtual environment each:
| distribution | result |
|---|---|
json-logic 0.6.3 | TypeError: 'dict_keys' object is not subscriptable on every row |
json-logic-qubit 0.9.1 | evaluated all five rows correctly |
panzi-json-logic 1.0.1 | evaluated all five rows correctly |
The source of the first reads op = tests.keys()[0] — Python 2 subscripting of a view object
— and calls reduce as a builtin. It has 2.85 million installs a month.
This result required one environment per distribution to obtain. The first attempt installed all three together and reported that the format worked, because a later install had silently overwritten the broken module. A single-environment comparison of this format gives the wrong answer.
S3: Need-Driven
Seven situations, and what eliminates options in each#
| the situation | the constraint that decides it | what it rules out |
|---|---|---|
| A hand-maintained lookup table has grown past what anyone can check | the rule must reach most rows and name the ones it cannot | anything with no per-record account — which is every engine, so the answer is a column |
| One classification is written by several services and they have drifted | the rule must apply to writers that will never call your library | everything except a database constraint or a generated column |
| An operations analyst has to change a rate table without a deploy | the artifact must be editable by someone who does not write code | every expression language; leaves decision tables |
| The same rule must give the same answer in a browser and a backend | one rule document, several runtimes | SQL, hand-rolled Python evaluators, Casbin |
| An authorization model has grown past what a review can hold | you need to know what a change does before making it, over all inputs | everything except Cedar’s analysis tooling |
| A platform team wants to see policy deviation before blocking it | the enforcement point is not yours to wrap in an if | everything without a report-only mode at the enforcement layer |
| One person maintains the whole system | attention is the scarce resource, not throughput | anything with a second dialect, a service, or an upgrade treadmill |
Two of these seven are answered by the same thing — a derived column with its derivation recorded — and it is not an engine. Two are answered by decision tables. One is answered by Cedar and nothing else. One is answered by Gatekeeper’s enforcement layer. One is answered by refusing to adopt anything.
The pages that follow take each situation in turn: who is in it, what specifically hurts, what they need, and what they give up by choosing that way.
Who should use what#
| situation | first choice | why | give up |
|---|---|---|---|
| A hand-maintained lookup table has grown past checking | a derived column plus a derivation column | the remainder becomes a finite worklist instead of unknown risk | the rule will not reach every row, and someone must look at the rest |
| One classification written by several services, already drifted | a generated column or trigger | the only mechanism that reaches writers who never call your code | rules change at the speed of migrations |
| An analyst must change rates without a deploy | DMN if a JVM is acceptable; Zen if it must be in-process Python | a table is the artifact they already think in | a second dialect, permanently — and here it pays |
| The same rule in a browser and a backend | CEL, through the official binding; JSONLogic if the rule is generated | one document, several runtimes | expressiveness, and a pinning discipline |
| An authorization model too large to review | Cedar | analysis over all inputs, not sampled cases | an unofficial Python binding, or a service boundary |
| Policy deviation must be visible before it blocks | the enforcement layer’s own dry-run; or a generated column promoted to a constraint | the switch must live where you cannot wrap the decision in an if | a violation count that somebody has to own |
| Rules drafted by a model or a query builder | SQL’s own parser; rule-engine typed; simpleeval with a name check | the generator’s characteristic error is naming something that does not exist | review is still required and cannot be skipped |
| One person maintains everything | nothing | attention is the constraint and every dialect spends it | a specification, and portability |
Two answers cover four of the eight situations, and neither is an engine#
A derived column with its derivation recorded answers the lookup table, the drifted duplicate, half of the observe-before-enforce case, and the preview requirement that appears in all of them. It is a schema decision, it costs one column and one assignment, and it is the only thing in this survey that makes a rule change reversible — because with the previous derivation stored, an edit produces a diff rather than a new answer.
Doing nothing answers the single maintainer, and it is the right answer for more readers than the category’s framing suggests.
Three situations need a dedicated engine#
They are the three things the baselines cannot do, and each has one clear answer:
- A non-programmer authors the rules → a decision table, because the table is the product.
- One rule, several runtimes → a portable document, because SQL and Python do not travel.
- An authorization model that must be reasoned about → Cedar, because nothing else reasons over all inputs.
The persona this survey does not serve#
Someone who has read that rules should be data, has not yet met a specific pain, and is choosing an engine in advance. Every table above is keyed on a pain. Without one, the recommendation is to write the rule in the language you already use, store its output with its derivation, and let the pain arrive and name itself — because it names the answer at the same time.
Who Needs This#
An operations or pricing analyst who owns the rules and does not write code. Insurance underwriting bands, freight surcharges, eligibility criteria, discount tiers. They understand the domain better than anyone in engineering and they currently change a rule by filing a ticket.
Why They Need It#
The queue is the problem, not the code.
The turnaround is a sprint and the business needs a day. A surcharge that should have changed on Monday changes on the following Thursday, and the gap is money.
The translation loses things. The analyst describes the rule in a ticket; an engineer implements what they understood; nobody checks the boundary cases because the person who knows them is not reading the diff. The error class is systematic: off-by-one at band edges, inclusive versus exclusive, and the ordering of overlapping rules.
The engineer is the bottleneck for something they do not understand. They cannot sanity- check the rule, so they implement it literally, which means an analyst’s typo ships.
What They Need#
An artifact the analyst edits and reviews directly, in a form that matches how they already think — which for this population is almost always a table of conditions and outcomes, because that is what the policy document looks like.
Three properties, in order:
- Rows and columns. Not an expression language with a friendly name. The analyst’s mental model is a table and every translation away from it reintroduces the loss.
- Defined overlap semantics. Real policy tables have overlapping rows, and what happens then is the substance of the policy. A format that only offers “first match” forces the analyst to encode precedence by row ordering, which is a translation and therefore a place to lose things.
- A preview against real records before the change goes live.
What Fits#
A decision table, and the choice is between the standard and the library.
DMN if the domain already thinks in hit policies. It specifies seven — UNIQUE, ANY, PRIORITY, FIRST, COLLECT, OUTPUT ORDER, RULE ORDER — and PRIORITY in particular is how underwriting and tariff tables are actually written. A conformance suite of 3,391 cases lets you check a vendor’s claim rather than believe it. The cost is that no Python engine conforms and none ever has — every engine that has submitted results is Java, Rust or Go — so the decision service is a service call away.
Zen if the rules must be edited and evaluated from Python. A vendor-published binding, a
per-node trace, an open-source editor component you can embed, and a release cadence nothing
else here matches. The cost is that it has two hit policies, first and collect, so a
domain that thinks in PRIORITY tables must be remodeled — and remodeling a policy to fit a
tool is the translation loss this situation exists to remove.
What They Give Up#
A second dialect, permanently. The cell expressions are ZEN or FEEL, and an engineer debugging a decision at three in the morning is debugging in a language they use occasionally. This is the two-language cost and here it pays, because the dialect is the product: the table is what the analyst edits, and there was never a version of this where they edited Python.
Governance risk, if the format is singly vendored. JDM is documented with a JSON Schema and the engine is MIT, and the vendor states plainly that it intends to keep tight control over the format’s development. That is a legitimate position and it is a different bet from DMN, whose users survived the end of life of the best-known engine because five others read the same XML.
Review discipline. An analyst who can change a rule without an engineer can change a rule without a reviewer. The governance that used to be enforced by the ticket queue has to be rebuilt as a review step in the editing tool, and the version where it is not rebuilt is worse than the queue.
Decision Criteria#
Choose DMN when the rules are the business — hundreds of them, changing weekly, written by people whose job title is the domain — and a JVM or a service boundary is acceptable. The standard’s durability is the reason: engines end, and DMN models outlive them.
Choose Zen when the same is true but the evaluation has to be in-process from Python, and the domain’s tables are first-match. Check that assumption against the actual policy document before committing, because discovering it is a PRIORITY table after the migration is expensive.
Choose neither when the “analyst” is one person who is comfortable editing a YAML file. Then the table is a file, the review is a pull request, and the whole apparatus is unnecessary.
Who Needs This#
A platform or security engineer owning an authorization model that has outgrown a permission column. Multiple tenants, nested groups, resources that inherit access from containers, sharing that can be delegated, and a compliance obligation to explain any individual decision.
Why They Need It#
Nobody can predict what a change does. The model has enough rules that adding one is a gamble: does this grant permit something it should not, is it shadowed by an existing deny, does it interact with the inheritance path in a way anyone has considered. A test suite covers the cases somebody thought of, which is not the question.
Authorization checks are scattered. The decision is made in request handlers, in a query filter, in a background job. Each site is individually reasonable and there is no single statement of the policy.
An auditor asks why a specific request was refused and the answer is a stack trace.
What They Need#
Three things, and the third is the one that decides between candidates:
- The policy as a document, separate from the code that enforces it, so it can be read as a whole.
- A per-decision reason — which rule allowed or denied this, returned with the decision rather than reconstructed from logs.
- The ability to answer questions about the policy itself, over all inputs. Is this new policy strictly more permissive than the old one? Is this permit shadowed? Can this condition ever hold?
What Fits#
Cedar, specifically for the third requirement. Its analysis tooling compiles policies to SMT formulas and answers equivalence, relative permissiveness, shadowed permits and impossible conditions — implemented with soundness and completeness proofs. Nothing else in this survey reasons over the whole input space rather than sampled cases.
The rest of the fit follows: Diagnostics.reason returns the set of policy identifiers that
determined an outcome, so requirement two is the return value rather than a logging project.
The validator runs against a schema in strict mode by default and catches unguarded optional-
attribute access, which is the error class this model produces most. And the semantics are
formalized in Lean with the validator’s soundness proved, while the Rust implementation is
differentially fuzzed against that model.
OPA if the policy covers more than authorization. Rego expresses much more than “may this
principal act on this resource” — admission control, configuration validation, data filtering
— and if the same policy engine must cover several of those, Cedar is the wrong shape. OPA
also has the better test tooling: opa test with coverage reporting is ordinary engineering
practice made available for policy.
Casbin if in-process Python is non-negotiable and the model is simple enough. Policy rows
in a database table, enforce_ex() returning the matched row. Measured: an allow returned
(True, ['dispatcher', 'shipment', 'sign']) (2026-09-04; pycasbin 2.8.0;
harness/1-245-rule-policy-evaluation-engines/probe.py, D3). It has no analysis tooling and
no load-time check at all — measured: a model whose matcher named a nonexistent field
loaded clean and failed on the first request (same harness, D4_compile_check) — so it fits
the first two requirements and not the third.
What They Give Up#
Cedar in Python is an unofficial binding. The maintained third-party wrapper is explicit
in its own README that it is not supported by AWS or the Cedar team, and its current release
embeds an engine version four minor releases behind the crate. The PyPI name cedar-policy is
an inert stub with one 2023 release. This is a real adoption cost and it is the main argument
for reaching Cedar through a service boundary instead.
OPA in Python is a sidecar. There is no official Python SDK; the documented integration is a host-level daemon or sidecar container. The WebAssembly path that would have made it in-process is not viable — the third-party package last released in 2022, the Go WASM SDK is archived, and the WASM build does not support the network builtin. The one in-process option is a Rust interpreter that is not published on PyPI.
Purity means handing over the entities. Cedar cannot fetch mid-decision, so the caller supplies every entity the policy might dereference. Cedar answers with level validation and entity slicing, and the documented guarantee is that a decision on the slice equals a decision on everything — but the slicing queries are yours to write, and the best-practice guidance says so.
Governance, in both directions. Cedar moved toward neutrality: CNCF Sandbox since 2025-10-08, with an RFC process and contributors beyond AWS. OPA’s project governance is CNCF-stable and unchanged, but its commercial vendor was absorbed in August 2025 and the enterprise edition is now an archived repository whose README asks for a maintainer. If the plan involved a supported commercial tier, check what is left of it.
Decision Criteria#
Choose Cedar when the model is large enough that “what does this change do” is the recurring question, and a service boundary between Python and the engine is acceptable. The analysis tooling is the reason and it has no substitute here.
Choose OPA when the same engine must also cover admission control or configuration policy, and when a sidecar is already part of the deployment shape.
Choose Casbin when in-process Python is the constraint and the model is expressible as rows plus a matcher. Budget for building the load-time check yourself, because there is not one.
Choose none of them when the model is one tenant column and three roles. That is a WHERE
clause, and an authorization engine over it is a dialect and a dependency bought for nothing.
Who Needs This#
A team whose service has grown to the point where one classification is computed in several places. An importer sets it, an admin screen sets it, a nightly job sets it, and a long-forgotten migration script sets it. The category feeds something that matters — a report, an invoice, an entitlement.
Why They Need It#
Nobody wrote four implementations on purpose. Each one was the shortest path at the time, and the second was written by someone who did not know about the first.
The pain is specific and it is not “the code is duplicated”:
They have already disagreed and nobody noticed. Duplicated classification logic does not fail loudly. It produces two plausible answers for records that happen to go through different paths, and the discrepancy surfaces months later as a number that does not reconcile.
One writer has a default that the others do not. Somebody’s implementation ends with an
else that assigns an unmatched input to a category, because at the time that seemed safer
than raising. That branch is now silently absorbing every case the rules do not cover, and its
volume is invisible.
Nobody can enumerate the writers. The team can name three and suspects a fourth. A path that nobody remembers is exactly the path that will not be updated when the policy changes.
What They Need#
One place the rule lives, and a guarantee that every writer goes through it.
The second half is what makes this situation different from ordinary de-duplication. A shared function in a shared library is a fine refactor and it does not solve the problem, because a writer that does not import the library is unaffected — and the writers you cannot enumerate are the ones that will not import it. So will the person with a SQL client, and the restore, and the bulk load.
What Fits#
The database, and effectively only the database. A generated column, a CHECK constraint, or a trigger applies to every writer including one that has never seen the application.
Measured: a row inserted by a client with no knowledge of the application code received
the correct derived value from a generated column (2026-09-04; SQLite 3.45.1;
harness/1-245-rule-policy-evaluation-engines/probe.py, D5_applies_to_every_writer). No
other candidate in this survey has that property, and it is the entire requirement here.
The order of operations matters more than the mechanism:
- Derive into a new column and enforce nothing. Now the rule exists and disagrees with reality in public.
- Count the disagreements. This is where the value is. If the derived value differs from the stored one for a large fraction of rows, the rule is wrong, or one of the four implementations was — and either way you have found something before changing anything.
- Find the default. Group by the derived category and look for the bucket that is
suspiciously large. That is the
elsebranch, quantified. - Cut the writers over one at a time, checking the disagreement count after each.
- When the count is zero, add the constraint. Now a fifth writer cannot appear.
What They Give Up#
Rules now change at the speed of migrations. A generated column is DDL, and on a large table the change is a rewrite. If the classification is revised monthly this is the wrong mechanism and the answer is a rule in a table with one caller — which reintroduces the enumeration problem and has to be paid for with discipline instead.
The logic becomes invisible from the application. A trigger executes on a writer that never asked for it, and when something is slow or surprising the trigger is the last place anyone looks. This is a real cost and the mitigation is that the rule is in the schema, which is in the migration history, which is in review.
Portability. Generated-column and trigger syntax differ between databases. A rule written this way is married to the engine.
Decision Criteria#
Choose the database when the failure you are fixing is bypass — writers that do not go through your code. That is the only thing that closes it.
Choose a shared library with one caller when you can enumerate the writers and keep them enumerated, which usually means one service with a code owner. Be clear about which situation you are in: the belief that you can enumerate the writers is what produced four implementations.
Do not choose a dedicated rules engine for this. It has the same bypass problem as a shared library, plus a dialect, and it does not add the one property the situation requires.
Who Needs This#
A team whose rules are written by something other than a person typing them: a language model drafting a condition from a description, a query builder emitting a filter from a form, an importer translating a spreadsheet of criteria.
Why They Need It#
The output has to be reviewable, and review is the bottleneck. A generated rule is cheap to produce and expensive to check. If checking it costs more than writing it by hand, generation has bought nothing.
A wrong generated rule looks exactly like a right one. A person writing a rule hesitates at the boundary case. A generator does not hesitate, so the plausible-but-wrong rule arrives with the same confidence as the correct one and in the same syntax.
The generator has no idea what fields exist. It will produce a rule naming a column that was renamed last quarter, and whether that fails loudly is a property of the language, not of the generator.
What They Need#
Three properties, and they are not the ones usually discussed.
A language small enough to read in full. The value is not that generation is safe — it is that review is cheap. A rule in a language with no loops, no function definitions and a closed operator set can be checked by reading it once. That is the property to shop for.
A check phase that catches an unknown name. This is the single most important requirement here and the one candidates most often fail, because the generator’s characteristic error is naming something that does not exist.
Refusal of hostile input, including resource exhaustion. Not because the generator is adversarial, but because its input might be, and because an accidental construct can be as damaging as a deliberate one.
What Fits#
Ranked by the requirement that matters — does it catch a name that does not exist?
| catches an unknown name | refuses hostile input | |
|---|---|---|
| SQL, checked in a scratch database | yes, by name | yes — no callables, no attribute access |
rule-engine with a type resolver | yes | all but the exponent bomb |
simpleeval / asteval | not built in; names are extractable from the parsed tree | all five attacks refused |
| cel-python | no, even with variables declared | all five refused |
| Zen | no | four of five; an attribute walk returned None |
| JSONLogic | no — evaluates to False | no attack surface; no hook |
All measured 2026-09-04; harness/1-245-rule-policy-evaluation-engines/probe.py,
D4_compile_check and D4_hostile.
Two rows deserve reading twice.
JSONLogic is the worst fit for generated rules and is frequently recommended as the best
one, because it is JSON and a model emits JSON reliably. But a misspelled field name
evaluates to False with no error, forever, and there is no check phase to add — JSON always
parses, there is no environment declaring what fields exist, and the mistake is inside a
string inside a document, invisible to every linter the team already runs. The one situation
where it is right is when the field names are generated from the same schema as the data,
by a query builder rather than a model, so the typo class cannot occur.
simpleeval and asteval refused all five attacks, including the two that a hand-rolled
allowlist forgets: an exponent expression and a hundred-megabyte string, both built entirely
from permitted operations. simpleeval caps exponents and string lengths by default; a first
draft written in an afternoon does not. The library authors’ own posture is the right one to
adopt — their documentation says there is no warranty and that many people consider sandboxing
CPython impossible. These are appropriate for rules whose author you trust and whose output
you review. They are not an answer to a hostile author.
What They Give Up#
A generated rule still needs a human decision, and the review has to be real. The efficiency comes from the draft, not from skipping the check. A team that generates rules and merges them unread has automated the production of plausible errors.
A normalization step, if the rules are committed. A JDM graph diffs as JSON with node identifiers and positions in it; an expression diffs as a line. Anything generated and stored in version control needs canonical formatting first, or the diff is unreadable and the review is theater.
Confidence bounded by the check. If the language cannot catch an unknown name, the review has to catch it, which means the reviewer needs the schema in front of them — and that is the labor the generation was supposed to save.
Decision Criteria#
Choose the database’s own parser when the rule is about rows in a database: create the generated column or constraint in a scratch database and let the database’s own parser judge it. It is the strongest check available and it costs nothing.
Choose rule-engine with a declared type resolver when the rule must be a portable string and
the check matters more than the ecosystem. It is the only pure-Python option measured here
that catches all three malformed cases.
Choose simpleeval or asteval when the rules come from a trusted generator and you want the
smallest possible surface, and add your own name check by walking the parsed tree — the names
a rule needs come out without running it.
Choose JSONLogic only when the generator derives field names from the schema, and pin a conformant implementation on both sides.
Do not choose an engine because a model can emit its syntax. A model can emit anything. The question is what happens when it emits something wrong, and on that question the answers in this survey differ by more than any other.
Who Needs This#
An analyst or data engineer maintaining a lookup table by hand. A few hundred rows mapping something to a category — a product code to a tax band, a station to a service tier, a supplier to a payment term. Somebody filled it in over three years. Nobody now knows which entries were reasoned and which were guessed.
Why They Need It#
The table is past the size where a person can check it, and it is not past the size where a person is expected to.
Three specific pains, in the order they arrive:
New rows are added by copying a similar one. The similar one may have been wrong. Errors propagate by resemblance, which means they cluster and look like a pattern.
Nobody can say what the table means. There is presumably a rule behind it — heavier things go in the higher band, remote stations get the longer term — but the rule was never written down, so the table is the only statement of it, and the table disagrees with itself in places.
A change to the underlying policy cannot be applied. When the bands are revised, somebody has to re-derive several hundred rows by hand, and there is no way to check the result except to re-read every row.
What They Need#
A rule that derives the value, and a record of which rows it reached.
The second half is the part that is usually skipped and it is the part that makes this work. A derivation rule that covers most of the table is useful; a derivation rule that covers most of the table and names the rows it could not decide is a different thing entirely, because the remainder is now a short, finite worklist instead of an unknown risk.
Concretely: a column holding the derived value, and beside it a column saying where the value came from — the rule, or a person. Rows a person supplied are protected from the next re-derivation, which is what stops the rule from quietly overwriting the exceptions somebody negotiated.
What Fits#
A generated column, or a rule evaluated in a loop that writes both columns. The choice
between them is only about how often the rule changes: a generated column is DDL and changes
at the speed of migrations; a rule in a table changes with an UPDATE.
Neither is an engine, and no engine helps with the part that matters. Measured: of the
eight candidates run against this survey’s fact set, three report which rule fired and none
reports which records a rule reached across a set (2026-09-04,
harness/1-245-rule-policy-evaluation-engines/probe.py, D3). The per-record account is a
schema decision available with any of them.
What They Give Up#
The rule will not reach everything, and that has to be acceptable up front. The failure mode here is a team that treats sub-total coverage as a defect and keeps extending the rule until it has special cases for individual rows — at which point the rule is the lookup table again, written less legibly.
Somebody has to look at the remainder. The rows the rule could not decide are the interesting ones and they need judgment, which is work that the automation does not remove. It makes the work finite and visible, which is the whole benefit.
Three-valued logic, if this is a generated column. Measured: a derived boolean over a
NULL input is NULL, and a NULL row is invisible to WHERE flag = 1 and to WHERE flag = 0
alike (2026-09-04, same harness, missing_and_null). Anyone counting coverage with a query
will undercount unless they handle it, and the undercount is silent.
Decision Criteria#
Choose the derived-column approach when the table already exists and is already wrong in places, because deriving into a shadow column and diffing it against the current one is the cheapest audit available — it tells you how far the table and the rule disagree before you commit to either.
Choose to keep the table by hand when it is arbitrary: a set of negotiated values with no rule behind them. Deriving those is inventing a pattern that is not there, and the diff will show it — several hundred rows changing is the signal to stop.
Who Needs This#
A platform team introducing a policy into a system that already has users. Resource limits on a shared cluster, naming conventions, required labels, a data-retention rule. The policy is correct in the abstract and the existing estate does not satisfy it.
Why They Need It#
Turning it on breaks people who did nothing wrong. The workloads that violate the new policy were deployed before the policy existed. Enforcing it on Monday means a queue of incidents on Monday, caused by the platform team.
They do not know how many violations there are. The estimate is somebody’s intuition. It is usually wrong by a factor that matters, in both directions — sometimes there are four violations and the caution was unnecessary, sometimes four hundred and the rule is wrong.
They cannot wrap the decision in a conditional. This is what separates this situation from
most of the survey. The enforcement point is an admission controller, a proxy, or a database
constraint — something configured rather than called. There is no if to put the flag in.
What They Need#
A mode where the policy evaluates, records the outcome, and permits the action anyway — and a way to move between that mode and blocking without rewriting the policy.
The sequence that works:
- Deploy the policy in report-only mode.
- Count the violations. This is where the value is, and it is the step teams skip. A policy that flags a large fraction of the estate has almost certainly been written wrong, and the violations are the evidence.
- Fix the estate, or fix the policy — usually both.
- When the count reaches zero and stays there, switch to blocking.
Each step is reversible and none requires a code change.
What Fits#
Gatekeeper, if the enforcement point is Kubernetes admission. Its enforcementAction
field takes deny, dryrun, warn and scoped — the last routing different actions to
different enforcement points, so a policy can be dryrun at admission while deny in audit.
That fourth value is absent from Gatekeeper’s own documentation page and present in its
source, which is worth knowing before concluding the capability does not exist.
Note what this is a property of. OPA itself has no report-only mode. It returns a decision and the caller decides. The switch lives in the enforcement layer, which is the correct place for it and the general lesson: shop for this feature in the thing that enforces, not in the thing that evaluates.
The database, with the caveat that it does not have a switch either. A predicate is a
CHECK constraint when it should block and a WHERE clause when it should only report.
Measured: the same predicate rejected a violating insert as a constraint, and returned the
violating rows as a query, blocking nothing (2026-09-04; SQLite 3.45.1;
harness/1-245-rule-policy-evaluation-engines/probe.py, D5_enforce_mode and
D5_observe_mode). Both modes exist; moving between them is writing a different statement.
Unless the predicate is a generated column, which collapses the two: the column derives on every write, a query observes it, and a constraint over the same column enforces it. One predicate, one copy, three deployment steps.
What They Give Up#
In the database case, two copies of the predicate — unless you use the generated-column
form. Keeping a CHECK and a monitoring query in agreement is a new maintenance job and
exactly the kind that decays quietly.
A count that nobody owns. Report-only mode produces a number. A number with no assignee is a dashboard, and dashboards decay. The version of this that works attaches the violation count to the policy’s own record and trends it, so the policy is visibly not finished until the count is zero.
A window where the policy is documented and not true. Between step one and step four the system has a written rule it does not enforce, and somebody will read the rule and assume it holds. That gap needs a stated end date or it becomes permanent.
Decision Criteria#
Choose the enforcement layer’s own dry-run mode whenever the enforcement point is not code you call. That is the only place the capability can live.
Choose the generated-column path when the rule is about rows in a database you control. Three steps, one predicate, each step reversible, and no feature flag.
Choose a plain conditional in the caller when the enforcement point is your code, which is most of the time. Report-only is a branch and an engine chosen for supporting it was chosen on a criterion that does not discriminate — every candidate in this survey supports it that way.
Note the one case where none of this works: a rule that rewrites a value rather than refusing a write cannot be previewed by letting the action through, because applying it is the thing you wanted to preview. That needs a shadow column — derive the new value beside the old one, diff them, and swap only when the diff is what you expected — and no engine in this survey does it for you.
Who Needs This#
A team whose validation or eligibility rule has to give the same answer in a browser form and in a backend service. Often a third place too — a mobile client, a partner’s integration, an offline importer.
Why They Need It#
The rule is currently implemented twice, in two languages, by two people, and the second implementation is the one that is out of date.
The client-side copy exists for a reason and cannot be removed. It is what makes the form usable: the field turns red as you type instead of after you submit. Deleting it to have one implementation makes the product worse.
The server-side copy exists for a reason and cannot be removed either. It is the one that is authoritative, because the client can be bypassed.
They disagree at the edges. A boundary condition, a rounding rule, a treatment of an empty field. The user sees a green form and gets a rejection, or the reverse, and the bug report says “it worked in the browser.”
What They Need#
One rule document, evaluated by two runtimes, where the rule itself is data that travels rather than code that is ported.
Two properties decide it:
- The rule must serialize. It has to survive a database column, an HTTP response and a JavaScript bundle without becoming a different rule.
- The implementations must agree, which means either one specification with a conformance suite, or a single implementation compiled for both targets.
What Fits#
CEL, if the rule is one expression and the runtimes include something other than JavaScript. There are four official implementations, versioned specification releases, and conformance test data. The specification commits to keeping the checked-AST protobuf wire-compatible in perpetuity, and Envoy’s configuration accepts either the parsed or the type-checked AST — which is this exact requirement, in production, at scale.
Two cautions. In Python, the pure-Python implementation does not type-check even when the
environment declares its variables (measured 2026-09-04; cel-python 0.5.0;
harness/1-245-rule-policy-evaluation-engines/followup.py), so a rule that the Go
implementation would reject at compile time will reach evaluation. The official Python
binding, released 2026-02-13, declares variable types up front and is the better fit for this
situation — at seven months old and requiring Python 3.11.
JSONLogic, if the runtimes are a browser and a backend and the rule is generated rather than hand-written. A JSON document is the most portable rule representation in this survey and the format is native to the browser.
Its caution is larger. There is no specification — a test fixture is the specification —
and the community suite that measures cross-implementation agreement puts the reference
JavaScript implementation at 68.7% against an expanded case set, with a Rust implementation at
100%. Choose the implementations and pin them, because the agreement you need
is exactly the thing the format does not guarantee. In Python this matters twice over: three
distributions install the same module name, and the most-installed one raises on every call
under Python 3 (measured 2026-09-04, same harness, jsonlogic_isolated.sh).
What They Give Up#
Expressiveness. Both formats are small on purpose. A rule that needs a lookup, a side-effect or an unbounded loop is not this, and the workaround — pushing the missing piece into a host function — reintroduces the divergence, because the host function is implemented twice again.
Type safety at the boundary. The rule travels; the schema does not. Both formats resolve a
missing field to something rather than failing — measured: JSONLogic returned False for a
misspelled field name with no error, and CEL raised only when the field was absent from the
activation entirely (same harness, D4_compile_check and missing_and_null). A field renamed
on the server and not in the rule is a silent behavior change in both.
A pinning discipline you did not have before. Two runtimes evaluating one rule means two library versions that must be upgraded together, and an upgrade on one side alone is a divergence with no test that catches it.
Decision Criteria#
Choose CEL when the rule is authored by engineers, must run in more than two languages, and the check phase matters — and reach it through the official binding rather than the pure-Python one if the Python version allows.
Choose JSONLogic when the rule is generated — from a query builder, a form definition, or a model — so that field names come from the same place the data does and the typo class largely disappears. Then pin a conformant implementation on each side.
Choose neither if “two runtimes” is really one runtime plus a convenience. If the client-side check is a hint and the server is authoritative, two implementations of a simple rule with one shared test corpus is less machinery and fails more visibly.
Who Needs This#
One person maintaining a whole system. A consultant with a client’s application, a founder before the second engineer, an internal tool with a single owner. There are perhaps twenty business rules, they change a few times a year, and the person who wrote them will be the person debugging them at some unhelpful hour.
Why They Need It#
They read an article about rules engines and the argument was persuasive: rules change more often than code, so they should not be code.
The argument is correct and its conclusion does not follow for this situation. The pains that motivate a rules engine are pains of scale and separation — many rules, several authors, a gap between who understands the policy and who can implement it. A single maintainer has none of those. What they have is:
A budget of attention, and it is the binding constraint. Every dependency is something to upgrade, every dialect is something to remember, every service is something that can be down.
No one to hand a dialect to. The reason to teach an organization Rego is that many people will use it. Here, one person will use it occasionally, which is the worst frequency: often enough to matter, rarely enough to have forgotten.
A real risk of adopting something that stops. This survey found, inside a two-year window, one deprecated library, one archived engine, one commercial tier looking for a maintainer, one silently renamed package and one broken most-installed distribution. A team absorbs those. One person does not.
What They Need#
The rules in one place, readable, with a way to see what a change did. That is the entire requirement.
What Fits#
The rule as an expression in the language they already write, stored where it can change without a deploy if it needs to — and derived into a column with its derivation recorded if the rule is about rows.
No new dialect. No service. No dependency worth the name — and if one is wanted, the packaged form of the same pattern has been on PyPI since 2014 and 2012 respectively, with a decade of maintenance behind it.
The properties that make this the right answer here are the ones the two-language page prices: nothing new to learn, nothing new to review, nothing new to debug, and the person on call already speaks the language the rule is written in.
What They Give Up#
A specification. What a rule means is whatever the code does, and if a second person ever arrives there is nothing to hand them but the code. For twenty rules that is fine, and it is worth being clear that it is a real thing given up rather than a thing that does not matter.
Portability. A Python expression is a rule for Python. If the same rule ever has to run in a browser, this decision has to be revisited, and revisiting it is a rewrite.
Anyone else authoring rules. The moment a non-programmer needs to change a rate, this is the wrong answer and the decision-table page is the right one. That transition is predictable — it is what growth looks like — so know the trigger in advance rather than discovering it as a crisis.
Decision Criteria#
Adopt nothing when the rules number in the tens, change a few times a year, and are written and read by the same person. Put them in one module, or one table, derive them into a column with the derivation recorded, and spend the attention elsewhere.
Revisit when one of three things happens, and not before:
- Someone who is not you needs to change a rule. Go to a decision table.
- The same rule has to run somewhere that is not your process. Go to a portable format, with the implementation caveats each profile carries.
- You cannot answer “what would this change do” by looking. That is the signal that the rule set has outgrown one head — and the answer is still a derivation diff before it is an engine.
The most common mistake in this situation is adopting early, on the strength of an article
about a problem the reader does not yet have. The second most common is adopting the
best-known name, which in this category is disproportionately likely to be the stalled one:
the most-installed JSONLogic package for Python has had no stable release since 2015 and
raises on its first call, and the most-recommended Python rules library has had no commit
since 2024 with 267 forks and thirty open issues (measured 2026-09-04; see
01-discovery/S1-rapid/observed-data.md).
S4: Strategic
What decides a five-year answer here#
Three signals, and one of them is unusual to this category.
The specification is the first-order signal#
In most software categories, choosing between two libraries is choosing between two projects, and if one stops you rewrite. Here it is sometimes choosing between two languages, and if the project stops the language may survive it.
That difference has been tested twice inside the survey window, with opposite results, and it is worth more than any comparison of maintenance metrics: an engine’s death costs its users a migration if the language is specified and a rewrite if it is not.
So each viability page below asks, first: does the rule I wrote today exist independently of the thing that runs it?
The vendor is a second-order signal, and it moved a lot#
Three of the four best-known projects here had a governance event in the last two years — absorbed, incubated, or archived. None of the three damaged the project’s code. Two of them changed what a reader would have concluded about support, and all three invalidated existing documentation.
The pattern worth carrying forward: governance events in this category have mostly moved projects toward foundations and away from the companies that built them. Cedar to CNCF Sandbox, Casbin to the Apache Incubator, Styra’s OPA products into the CNCF organization. That is a good direction for durability and a bad one for anyone who was relying on a commercial support tier.
The maintenance signal is read as four states, not two#
Alive and dead are not the categories. A small project with zero open issues and a recent push is finished; a large project with 267 forks and no push in two years is stalled; and the second looks healthier on stars. The ratio that discriminates is inbound patches against merged patches, and this category supplies unusually clear examples of both.
What is not weighed#
Funding and company size. What a vendor raised is not a fact about the software. Where a commercial arrangement changes what a user gets — an archived enterprise edition, a hosted successor replacing an embeddable library — that is reported as a durability signal, which is what it is.
Stars. They measure attention already paid. Every project in the “stalled” group has more of them than the healthiest small project here.
Speed. Nothing in this survey is timed, and no viability argument rests on performance.
The pages#
viability-data.md holds the signals. cel-viability.md, opa-cedar-viability.md,
decision-table-viability.md and baselines-viability.md take the four groups that survive
S3 and ask what a rule written in each is worth in five years. recommendation.md states the
three strategic paths and the conditions that select between them.
The two baselines: five-year outlook#
Neither is a product, so neither has a maintenance risk in the usual sense. They have different risks, and those deserve stating as sharply as any vendor’s.
The database#
What you are depending on#
An organization’s decision to keep running its database. That is the whole dependency, and it is the only candidate in this survey whose five-year risk is a decision the organization controls rather than one it observes.
There is no upgrade treadmill beyond the one already accepted, no governance event to watch,
no package to be renamed, and no maintainer to lose. CHECK constraints and triggers have
been in SQL for decades; generated columns are in every major engine.
The three real risks#
Dialect lock-in. Generated-column syntax, trigger languages and row-level-security models differ between engines. A rule written this way is married to the database, and a database migration becomes a rule migration too. This is the cost that corresponds to a rule engine’s portability, and it is usually a fair trade because database migrations are rarer than library ones.
The rule changes at the speed of migrations. DDL, review, and on a large table a rewrite. Over five years this is the constraint that decides whether the approach holds: a rule set that turns out to change weekly will strain against it, and the escape — a rule in a table with one caller — gives up the property that made the database worth choosing.
Three-valued logic, permanently. Measured 2026-09-04 (SQLite 3.45.1;
harness/1-245-rule-policy-evaluation-engines/probe.py, missing_and_null): a derived boolean
over a NULL input is NULL, and such a row is invisible to WHERE flag = 1 and WHERE flag = 0
alike — a count of rows in both categories returned zero for the NULL row. Every general-purpose
engine in this survey returned False for the same input. This does not decay and it does not
get fixed; it is what SQL means, and it will surprise a new team member every time one arrives.
What it does not give you, and will not later#
Nothing here will grow a decision-table editor, a policy analysis tool, or a way for an analyst to change a rate. Those are not on any roadmap because there is no roadmap. Choosing the database is choosing to solve those problems elsewhere if they arrive.
Verdict#
The safest five-year choice in the survey, for the situations it fits. The correct strategic posture is to be explicit about the trigger that would end it — someone who is not an engineer needing to change a rule, or the same rule needing to run outside the database — so that the transition is a plan rather than a crisis.
Your own minimal evaluator#
What you are depending on#
Either a hundred lines you maintain, or one of two libraries that have been on PyPI since 2014
and 2012 with 12.8 million and 6.2 million installs a month respectively. asteval has zero
open issues and a push this month; simpleeval has eighteen open and a push this month. Both
are in the “finished” state rather than the “abandoned” one, and the distinction is visible in
the tracker rather than in the release date.
Over five years the risk is close to zero and does not depend on anybody’s business model.
The three real risks#
The subset is undocumented. What a rule may contain is defined by whichever allowlist someone wrote. It is not written down anywhere a rule author can read, and in five years the person who wrote it will have left. The mitigation is cheap and almost never done: write the allowlist down as a document, next to the rules.
Nothing enforces purity. Measured 2026-09-04 (same harness,
D2_io_via_host_function): an arbitrary Python callable placed in the evaluator’s namespace
was reachable from a rule in both libraries. Cedar cannot do this by construction; here it is
a convention, and conventions decay. The mitigation is a test that asserts the namespace
contains only what it should.
The rules do not travel. A Python expression is a rule for Python. If the same rule ever has to run in a browser or another service, this decision has to be revisited and revisiting it is a rewrite, because there is no document to hand to another implementation.
The risk that is usually overstated#
Sandbox escape. Both libraries refused all five attacks measured here, including the two resource-exhaustion cases that a hand-rolled allowlist typically misses — an exponent expression and a hundred-megabyte string, both built entirely from permitted operations. That is a better result than one of the dedicated rule engines managed.
The authors’ own posture remains the right one and should be quoted rather than softened: their documentation says there is no warranty and that many people consider sandboxing CPython impossible. These are appropriate for rules written by colleagues and for rules generated by a model whose output you review. They are not an answer to a hostile author, and no five-year plan should assume otherwise.
Verdict#
A durable choice with a documentation debt. The libraries will be here; the definition of your rule language will not, unless someone writes it down. That single act — a page describing what a rule may contain and what happens when a field is missing — converts this from the least-specified option in the survey into a specified one, and it costs an afternoon.
Why the baselines win more often than the category expects#
Both share a property no engine here has: the language is already in the team’s head. The two-language cost is not reduced, it is absent, and it is the largest recurring cost in this category.
Both also share the survey’s central limitation, and it is not a limitation of theirs. No engine here can preview a rule change against real records, because previewing needs the records and the engines do not have them. That capability comes from storing the derivation beside the derived value, which is available with every candidate on this list and with none of them — so the baselines lose nothing by not providing it, and adopting an engine to obtain it buys nothing.
CEL: five-year outlook#
What survives#
The expression. CEL has a versioned specification, four official implementations, conformance test data, and a commitment in the specification itself to keep the checked-AST protobuf wire-compatible in perpetuity. A CEL string written today will be readable and runnable in five years by something.
That is the strongest form of the durability argument in this survey, and it holds independently of any project’s health, which is the point of a specification.
What is uncertain#
Governance is thin, and it is not neutral. The Language Council is four people, three of them at one company. There is no foundation. Changes require a design document and Council review at a meeting held every three weeks — a real process, and one that depends on four people continuing to attend.
Against that: CEL is embedded in Kubernetes at two GA extension points, in Envoy’s RBAC configuration, in protovalidate, and in Google Cloud IAM. That embedding is a stronger guarantee of continuity than the governance document is. A language that Kubernetes evaluates in admission control does not quietly stop.
No 1.0 in eight years. The specification releases regularly — v0.25.3 on 2026-08-13 — and has never declared stability. That has not prevented adoption and it does mean the version number carries no promise.
The claims that will not age well#
“Linear time” is already wrong and will get more wrong. The specification’s own performance section describes macro nesting and chaining as exponential in time and space, and string concatenation as quadratic. The correctly qualified form — linear when macros are disabled — is in the Go implementation’s FAQ and almost never in the places the claim is repeated.
Kubernetes’ response is the evidence to cite: it layers a static cost estimator that rejects expensive rules at admission and a runtime instruction budget that halts an interpreter which executes too many instructions. A platform that trusted the linear-time guarantee would need neither.
“No I/O” is true of the language and false of most deployments. Kubernetes injects an authorizer library so a validation expression can perform an authorization lookup. Any host can do the same. The guarantee is about what the language contains, not about what a rule can reach, and the difference matters exactly when someone is relying on it for a security argument.
The Python risk#
Two implementations, and the choice between them is a five-year decision.
cel-expr-python is official, a wrapper over the C++ implementation, first released
2026-02-13. Seven months old. Requires Python 3.11. Zero dependencies, prebuilt wheels for
three platforms. Its API declares variable types up front, which is what makes a check phase
possible.
cel-python is pure Python, transferred from an individual author into an organization,
174 stars, six dependencies including a compiled regular-expression library. Its release
history is 0.1.x through mid-2021, a three-and-a-half-year gap, then four releases from
2025-02.
Three things decide against it for anything long-lived:
- It does not type-check, by stated design, even when the environment declares every
variable. Measured 2026-09-04 (
cel-python0.5.0;harness/1-245-rule-policy-evaluation-engines/followup.py). The property CEL is chosen for is not present. - Its conformance position is stated and incomplete: it converts the specification’s test data into 2,430 scenarios and skips 1,184 of them by default, with the gaps concentrated in extensions and protobuf handling.
- Its download figure was somebody else’s. protovalidate required it through 1.2.0 and replaced it with a native extension on 2026-08-19. What that does to the project’s visibility, contributor flow and release cadence over five years is the open question, and nobody knows yet.
The strategic reading: CEL the language is a good five-year bet. cel-python the package
is a bet on a small project that just lost its largest consumer, and the official binding is
young enough that its own five-year record does not exist.
What to do about it#
Write the rules as CEL strings and keep them portable. The specification’s stability is the asset; the binding is replaceable, and the whole point of a specified language is that replacing it is a configuration change.
Do not build a check-phase dependency on the pure-Python implementation, because there is no check phase there to depend on. If validation before deployment is part of the design, that selects the official binding, or validation in another language, or a different candidate.
Re-check in nine months, on three specific things: whether the official Python binding has
reached a stable release, whether cel-python’s cadence survived losing protovalidate, and
whether the specification has declared 1.0.
Decision tables: five-year outlook#
The clearest specification-versus-project trade-off in the survey, and it was tested during the survey window.
DMN: the standard outlived its best-known engine#
Camunda 7 Community Edition reached end of life with a final release on 2025-10-14 and its repository archived on 2025-11-04. Maven Central shows the standalone DMN engine artifact frozen at that exact date. Its users’ decision models are DMN XML, and five other conforming engines read the same XML.
That is what a standard is for, demonstrated rather than argued. Set it against Oso — one implementation, no specification, users with nowhere to migrate — and the two events are the same event with the standardization variable flipped.
The evidence is unusually good#
A public conformance suite of 3,391 test cases, community-maintained since 2017, with per-engine scores published and refreshed as vendors resubmit. Nothing else in this survey lets a buyer check a vendor’s claim rather than believe it.
Current scores put two engines above 3,388 and the end-of-life engine at 2,741 — meaning Camunda was never a fully conforming implementation and had not resubmitted since 2024, which was visible in the data before the end-of-life announcement.
Note the suite’s repository carries no license file, which matters if you intend to vendor the test corpus.
The uncomfortable parts#
The open-source flagship is still incubating. Drools, Kogito and OptaPlanner have been consolidated into a single Apache incubating project — the previous repositories are archived and redirect — releasing actively at 6,312 stars. Incubating is not graduated, and a risk-averse five-year plan should say so out loud.
One engine advertises support for a specification that is not final. The consolidated project’s 10.2.0 announcement claims support for “the latest DMN 1.6 specification”; the standards body lists 1.6 as a beta, with 1.5 the current formal version. Implementing a draft is legitimate; describing it as the latest specification is not, and a buyer comparing conformance claims should check which version each engine means.
The enterprise lineage changed hands. The Red Hat decision-management product was transferred to IBM and renamed; the last Red Hat-released version was 7.13. That is a migration for anyone who bought it, and it is the second ownership change in this row of the table.
No Python engine conforms, and none ever has. Every engine that has submitted conformance results is Java, Rust or Go. The available Python library reads decision tables from an Excel workbook rather than DMN XML, is GPLv3 — the only copyleft license in this survey, and a bar to proprietary embedding — is maintained by one person, and depends for its expression evaluation on a package last released 2022-09-18 that implements the simplified expression subset. That places it around conformance level 2 rather than 3.
So the five-year Python position for DMN is: a service boundary, permanently. That is not a defect to be fixed by a future library; it is what twelve years of the ecosystem has produced.
Verdict#
A DMN model is the most durable rule artifact in this survey. Twelve years of formal versions, a conformance suite with published scores, six or more independent engines, and a demonstrated migration when the leading engine ended.
The cost is weight. The engine is a JVM, a Rust binary or a Go service; the tooling that makes DMN worth having — modelers, simulators, coverage — is largely commercial; and the ecosystem is unusually turbulent for a mature standard.
Zen: a well-run project, and a bet on one vendor#
The project’s health is not in question. Seventy-two releases in three years, typically two to four a month, last push 2026-08-25, MIT-licensed engine, a vendor-published Python binding at parity with six other language bindings, and an open-source editor component.
Its 2.0 release on 2026-08-20 is a drop-in upgrade by the vendor’s own changelog, with no migration steps and one Rust-side default-feature change. The 1.0 line never shipped stable — it went from a beta directly to 2.0 — which is a versioning oddity rather than a stability problem.
What you are betting on#
The format is documented and singly governed, and the project says so directly: the JDM standard “is growing and we need to keep tight control over its development.”
That is a defensible position for a young format and it is a different bet from DMN. There is a JSON Schema and an open-source editor, so a fork is technically possible; there is no second implementation, no conformance suite, and no independent body that could keep two implementations in step. If the vendor’s plans change, JDM documents are readable and nothing else reads them.
The survey contains a worked example of how that goes. It is the Oso entry.
The functional ceiling#
Two hit policies, first and collect, against DMN’s seven. A domain that models in
PRIORITY or OUTPUT ORDER tables must be remodeled, and remodeling a policy to fit a tool is
the translation loss that decision tables exist to remove. This is a design choice rather than
a backlog item, so it should be treated as permanent when planning.
Function nodes make the graph impure. A node running TypeScript with an HTTP client and a five-second default timeout is in the language, and there is no documented flag to prohibit one at evaluation time. Over five years the risk is not that someone uses it — it is that someone uses it once, and the decision service quietly acquires a dependency on a third party’s availability that nothing in the deployment describes.
Verdict#
Choose Zen when in-process Python evaluation of analyst-editable tables is the requirement, which is a real requirement that DMN cannot meet. Its execution is good and its cadence is the best in the survey.
Treat the format as a vendor commitment, not a standard. The mitigation is to keep the JDM documents in your own store, know that they are JSON with a published schema, and plan on the basis that a migration away would be a re-authoring rather than a re-import.
Establish a policy on function nodes before the first one is written, because it is much easier to prohibit them than to remove them.
OPA and Cedar: five-year outlook#
Two policy engines whose trajectories crossed during the survey window. One is the incumbent and lost its vendor; the other is the challenger and gained a foundation.
OPA#
The project is safe and the commercial story changed completely#
CNCF Graduated since 2021-01-29, releasing continuously — v1.20.2 on 2026-09-03 — with 12,194 stars and the largest policy ecosystem in the category. Graduated status is the strongest institutional position anything in this survey holds.
What changed is underneath it. On 2025-08-20 the OPA creators and much of Styra’s staff
announced they had joined Apple. The project’s governance and licensing were stated as
unchanged, with the maintainer list altered only by the organization name. Styra’s commercial
products were donated into the CNCF open-policy-agent GitHub organization — and
Enterprise OPA is now an archived repository whose README asks the community for a
maintainer: “If you are interested in maintaining this project, please reach out to the
maintainers over on the OPA Slack.”
Read that carefully. The open-source project is fine. The supported commercial tier is community code looking for an owner. Anyone whose five-year plan included buying support has a different question to ask than they did in 2024.
The language already broke once#
OPA v1.0.0, on 2024-12-20, made if mandatory for every rule definition and contains
mandatory for multi-value rules, and turned several previously optional strict checks on by
default. A --v0-compatible flag exists across eleven subcommands with no announced removal
date.
Nothing broke. What happened instead is subtler and lasts longer: every tutorial, book, conference talk and search result about Rego written before 2025 describes a language that is now rejected by default. For a team learning Rego in 2026, the corpus of available help is split, and nothing on a search result page says which half you are reading.
Rego has no specification and one implementation, which is why this was possible.
The Python position is the strategic risk#
There is no official Python SDK. Official SDKs exist for Go, Java, C#, TypeScript, Swift and JavaScript. The documented integration for everything else is a sidecar or host-level daemon, which is a deployment commitment rather than a library choice.
The in-process paths are worse than they look:
- The WebAssembly route is not viable. The third-party Python package last released 2022-02-11; the Go WASM SDK is archived; the WASM build does not support the network builtin at all.
- The one real in-process option is a Rust Rego interpreter with Python bindings, and it is not published on PyPI — its own packaging declares the name and no releases exist. Adopting it means building from source.
Over five years the sidecar is fine if you already run sidecars and expensive if you do not, and there is no sign of that changing: the direction of travel visible in the organization’s newer SDKs is toward compiled intermediate-representation plans, and no Python one exists.
Verdict#
Choose OPA when a sidecar is already part of the deployment shape and the policy scope goes
beyond authorization. Its test tooling is the best here — opa test with coverage reporting
is ordinary engineering practice made available for policy — and its ecosystem is the largest.
Do not choose it for in-process Python evaluation, and do not choose it on the strength of a commercial support tier without checking what remains of that tier.
Cedar#
It moved toward neutrality while the incumbent’s vendor moved away#
Accepted to the CNCF at Sandbox maturity on 2025-10-08, announced by AWS on 2025-12-15 with the stated vision of foundation stewardship and a vendor-neutral governance model. There is a public RFC process, an active repository (pushed 2026-09-04), and contributors beyond AWS.
Sandbox is the entry maturity level and it is not Graduated. The direction is the signal rather than the level.
The verification is real and must be described in two parts#
The model is verified. Cedar’s semantics are formalized in Lean, and the formalization carries proofs — chiefly validator soundness: a policy the validator accepts cannot produce a type error at evaluation. Carrying out that proof reportedly surfaced real bugs.
The implementation is tested against the model. The Rust engine is continuously differentially fuzzed against the Lean formalization. It is not itself proven.
Saying “Cedar is formally verified” without that split is wrong in a way that matters, because the two halves have different failure modes. But the combination is a stronger evidentiary position than anything else in this survey, and over five years it is the thing most likely to still be true.
Cedar Analysis, announced 2025-06-16, is the capability that has no substitute here: it compiles policies to SMT and answers whether two policy sets are equivalent, whether one is strictly more permissive, whether a permit is shadowed, whether a condition can ever hold — implemented in Lean with soundness and completeness proofs. For an authorization model that is the difference between a test suite and an argument.
The Python position is the strategic risk here too#
The Python binding is third-party and says so: its README states it is not officially supported by AWS or the Cedar team. The project’s own curated list files Python under unofficial next to official Java and Go. There is no Cedar Python repository in the organization.
Two version gaps to watch:
- The binding’s current release embeds engine 4.8.2 against a crate at 4.12.0.
- The managed service documents itself as using Cedar 4.7.
So the language has three versions in circulation and the Python one is not the newest. Note
also that the PyPI name cedar-policy is an inert stub — one 0.0.1 release from 2023 with
project URLs pointing at a nonexistent repository — which is a trap for anyone searching by
name.
The binding itself is well maintained (pushed 2026-09-02, 64 stars, two open issues) and its metadata is incomplete in ways that will confuse a license audit: null license, summary and project URLs on PyPI despite an Apache-2.0 repository.
Verdict#
Cedar is the best five-year bet in the authorization group, on governance direction, evidentiary standard and the analysis tooling. The reservation is entirely about Python: an unofficial binding trailing the engine is acceptable for a service that can be replaced and uncomfortable for a dependency at the center of an authorization model.
The strategic move is to put Cedar behind a service boundary rather than embedding the unofficial binding — which is the same shape OPA forces for a different reason, and makes the choice between them a policy-language decision rather than an integration one.
Three strategic paths#
Conservative — no dedicated engine#
Rules as expressions in the language the team already writes, stored where they can change, derived into a column with the derivation recorded.
This is the right path for most readers and the survey does not soften that. It answers all six discriminators without a dialect, a service, or a project whose governance you have to watch. Its dependency is a database the organization has already decided to keep, or two libraries that have been maintained for over a decade and have zero and eighteen open issues respectively.
Choose it when the rules number in the tens, are written and read by engineers, and run in one place.
It ends when one of three things happens, and knowing them in advance is what makes the transition a plan: someone who is not an engineer needs to change a rule; the same rule needs to run outside your process; or an authorization model grows past what a review can hold.
The debt it accrues is documentation. Write down what a rule may contain and what happens when a field is missing. Nobody does this and it costs an afternoon.
Standards-first — adopt a specified language#
A DMN model, a CEL expression, or a Cedar policy. What these share is that the rule exists independently of the thing that runs it.
The survey window supplied the argument. Camunda 7’s DMN engine reached end of life and was archived; its users’ models run on five other conforming engines. Oso deprecated its library; its users’ Polar policies run nowhere, because the language was the product. Same event, two outcomes, and the variable was the specification.
Choose it when the rules will outlive the current implementation choice — which is a reasonable expectation for anything an organization intends to keep for five years.
Pay attention to where the specification and the implementation disagree. This survey found that gap three times: CEL’s Python implementation does not type-check although the language is designed to be checkable; one DMN engine advertises support for a specification version the standards body lists as a beta; and JSONLogic’s reference implementation scores 68.7% against the community’s own compatibility suite, because a third of the cases test behavior the format never specified.
In Python specifically, this path usually means a service boundary. No conforming DMN engine is Python; Cedar’s Python binding is unofficial and trails the engine; OPA’s sanctioned Python integration is a sidecar. That is a deployment decision as much as a language one, and it should be decided rather than discovered.
Capability-first — adopt for something the baselines cannot do#
Three capabilities in this survey have no substitute. Each selects exactly one answer.
| capability | the only answer | why nothing else does it |
|---|---|---|
| A non-programmer edits the rules | a decision table — DMN or Zen | rows and columns are what the policy document already looks like; every other format is a translation |
| The same rule runs in several runtimes | CEL or JSONLogic | the rule is a document that travels; SQL and Python do not |
| A policy change must be reasoned about over all inputs | Cedar’s analysis tooling | it compiles policies to SMT and answers equivalence and relative permissiveness; everything else samples |
Choose it when you have one of those three, and not otherwise. If the shortlist was made on
“rules as data” or “supports dry run”, it was made on criteria that do not discriminate —
d1-rules-as-data.md and d5-enforce-vs-observe.md show why both columns are nearly all
ticks.
What to re-check, and when#
This category moved eight times in two years. decay_class: fast is not a formality here.
| at | check |
|---|---|
| 9 months | whether CEL’s official Python binding has reached a stable release; whether cel-python’s cadence survived losing its largest dependent; whether Casbin graduated from the Apache Incubator |
| 12 months | whether the DMN 1.6 beta became formal, and which engines resubmitted conformance results against it |
| 18 months | whether Zen still has one implementation; whether Cedar moved past CNCF Sandbox; whether anything replaced the archived OPA enterprise edition |
| any time | before installing, that the package name is the current one — this survey found a silent rename that still resolves, three distributions sharing one module name, and an inert stub occupying an obvious name |
The finding that governs all three paths#
A rule is only an evolvable object if you can see what an edit did to the records it governs. No engine in this survey provides that, because it needs the records and the engines do not have them.
What provides it is storing the rule’s output with a note of where the value came from. That is a schema decision, it works identically under every path above, and it is the single highest-value thing a team in this category can do — more than any engine choice, and cheaper than all of them.
Durability signals#
What happens to a rule you wrote today, five years from now. All figures fetched 2026-09-04.
The events of the last two years#
Six of them, and together they are a better guide to this category than any feature list.
| when | what | who it affected |
|---|---|---|
| 2023-12-18 | Oso deprecated its open-source library | anyone with Polar policies |
| 2024-12-20 | OPA v1.0.0 made if and contains mandatory | anyone with pre-2025 Rego, and every tutorial about it |
| 2025-08-10 | the Casbin PyPI package renamed to pycasbin, old name left published at 1.43.0 with no notice | anyone who runs pip install casbin |
| 2025-08-20 | OPA’s creators and much of Styra’s staff joined Apple; Styra’s products donated to the CNCF organization | anyone relying on a commercial OPA tier |
| 2025-10-14 / 2025-11-04 | Camunda 7 Community Edition’s final release; repository archived | anyone running that DMN engine |
| 2026-02-07 | Casbin entered the Apache Incubator | Casbin’s governance, and every existing URL |
| 2026-06-16 | all official CEL repositories moved to a cel-expr organization | every citation of google/cel-* |
| 2026-08-19 | protovalidate 2.0.0 replaced cel-python with a native extension | cel-python’s download figure |
Two years, eight events. Anything written about this category before 2026 is wrong about at least one of them.
The two outcomes, and what decided which one you get#
Camunda 7’s engine died and its users kept their rules. DMN models are an OMG standard; five other conforming engines read the same XML. A migration.
Oso’s library was deprecated and its users lost their rules. Polar had one implementation and no specification. There was nothing to migrate to, because the language was the product. The documented successor is a hosted service whose dialect is documented separately and is not interchangeable.
Same event, opposite outcomes. The variable is whether the language exists independently of the project.
Governance, ranked by what it protects#
| candidate | governance | what it protects against |
|---|---|---|
| DMN | OMG formal standard since 2015; current 1.5 (August 2024); a 3,391-case public conformance suite with per-engine scores | any single engine ending |
| Cedar | CNCF Sandbox since 2025-10-08; public RFC process; contributors beyond AWS | the original vendor changing direction |
| OPA | CNCF Graduated 2021-01-29; governance stated unchanged after the vendor’s absorption | the project itself stopping |
| CEL | a four-person Language Council, three at Google; versioned spec releases; conformance data; no foundation | divergence between implementations |
| Casbin | Apache Incubator since 2026-02-07 — not graduated; the adapter ecosystem is still outside the Apache organization | eventually, the same as OPA; not yet |
| Zen / JDM | single vendor, stating an intent to keep tight control of the format | nothing, by design — this is a bet on the vendor |
| JSONLogic | none; a test fixture is the specification; a community organization has been drafting one for 21 months without shipping | nothing |
| the database | SQL is an ISO standard; the dialect of generated columns and triggers is not | the vendor; not the syntax |
The maintenance table, read as four states#
Stars are omitted — they measure historical attention, not current effort. What predicts the next two years is inbound work against merged work.
Healthy. OPA (pushed 2026-09-04, 333 open issues against continuous releases), Cedar
(2026-09-04), Casbin (2026-08-13), Zen (2026-08-25, 72 releases in three years), cel-python
(2026-09-01), rule-engine (2026-08-02, three open issues), simpleeval, asteval,
RestrictedPython.
Finished, and easy to mistake for abandoned. asteval at zero open issues with a push
this month. rule-engine at three open against 597 stars. pyDMNrules at one. A small
project with a tended tracker and nothing outstanding is done, not dead.
Stalled — patches arriving, nobody merging. venmo/business-rules: 993 stars, 267 forks,
30 open issues, no push since 2024-08-13. jruizgit/rules: 206 open issues, last release
2020-06-07. CozoDB: no maintainer commit since 2024-12-04, with nine or more unanswered
community pull requests including a silent-data-corruption report, and a README still saying
“we encourage you to try it out.” jwadhams/json-logic-js: 9.5 million npm installs a month
on one substantive commit in four and a half years, with the maintainer’s most recent status
statement dating from 2020.
Over. Oso (deprecated, last code 2024-06-13), camunda/camunda-bpm-platform (archived),
vmware/differential-datalog (archived), experta (no release since 2019),
open-policy-agent/eopa (archived, README asking for a maintainer).
The download figures, and why three of them mislead#
This category decouples popularity from maintenance more sharply than any other in this corpus.
json-logic, 2,849,698 a month, last stable release 2015-12-04, raises on its first call under Python 3.cel-python, 6,455,685 a month, owed almost entirely to one dependent — protovalidate — which replaced it with a native extension on 2026-08-19. Independent dependents are in the single digits. Expect the figure to fall.miniKanren, 1,062,201 a month, moving within 1% of two sibling packages day by day because they install together as a numerical-computing library’s optional extra. Dependent-package counts are effectively zero.
Against these, zen-engine at 475,848 and rule-engine at 421,984 are smaller numbers
that mean more, because they are people who chose the package.
Five-year outlook#
Very likely to be here and readable: DMN models, SQL constraints and generated columns, CEL expressions, Cedar policies. All four are specifications with more than one implementation, and three have conformance evidence.
Likely to be here, possibly changed: Rego (it has already changed once and the compatibility flag has no removal date), Casbin (incubating, with two renames inside eighteen months), Zen’s JDM (a well-run project, singly governed — the risk is not competence).
A coin flip: JSONLogic. The format is too widely embedded to disappear and too unspecified to converge. The most likely outcome is the current one continuing — several implementations that mostly agree, a community suite that measures how much, and no specification.
Do not start here: Oso, business-rules, durable_rules, experta, CozoDB, and the
json-logic PyPI distribution.
The safest thing in the survey is the one that is not a product. A rule written as a SQL expression in a generated column depends on a database the organization already runs and has already decided to keep. It is the only candidate whose five-year risk is a decision the organization controls.