One rule language for every programming language and operating system
This page explains how Rule Cascade gives the same answer in every language and on every operating system, which form of the engine to use where, and how to add a language that has no runtime yet.
This page explains how Rule Cascade gives the same answer in every language and on every operating system, which form of the engine to use where, and how to add a language that has no runtime yet.
- The design in one paragraph
- What exists today
- Choosing a form
- Why engines disagree, and how each cause is closed
- Operating systems
- Using a language that has no runtime
- Writing a runtime for a new language
- Compatibility promises
The design in one paragraph
Rules are data: a JSON value with a fixed grammar, written in YAML or JSON. A compiler checks
a ruleset once and seals it into a bundle, which is plain JSON. An evaluator is a small pure
function from (manifest, request) to a result; it needs a JSON parser, decimal arithmetic and a
regular-expression engine, and nothing from the operating system. The meaning of every operator is
written down in the specification to the level where two
implementations cannot reasonably differ, and a conformance suite of
hand-written cases and seeded random requests is run against every engine on Linux, Windows and
macOS. An engine that disagrees with the suite is wrong by definition and does not ship.
Three decisions make this work:
- JSON is the only interchange format. YAML is for authors. Nothing that evaluates ever reads
YAML, so no evaluator depends on a YAML library or on how one reads
noor012. - Compile once. Schema validation, inheritance, override policies and the static checks run in one place, usually CI. What ships is the result. A new language therefore needs only an evaluator, which is a few hundred lines, to take part.
- The suite is the product. The Python package is the reference implementation, but what binds a runtime is the suite. Every difference ever found between two engines became a case in it.
What exists today
| Engine | Conformance level | Runs in | Dependencies | Size (non-blank lines) |
|---|---|---|---|---|
Python, rule_cascade (packages/python) | evaluator and compiler; the reference | Python 3.10+ | jsonschema | 1,300 |
TypeScript, @yarlisaisolutions/rule-cascade (packages/typescript) | evaluator and compiler | browsers, Node.js 20+, React, React Native | decimal.js; ajv only to compile | 2,300 |
Java, rule-cascade-core (packages/java) | evaluator and compiler | any JVM, Java 17+ | none | 4,200 |
Go, rulecascade (packages/go) | evaluator and compiler | Go 1.22+ | none | 4,000 |
Command, rule-cascade | evaluator and compiler | Linux, macOS, Windows; x86-64 and ARM64 | none: one static file | built from the Go runtime |
WebAssembly, rule-cascade.wasm | evaluator and compiler | any WASI preview 1 host | none: one file | built from the Go runtime |
| Rule server (packages/server) | evaluator and compiler, over HTTP | a container, or Node.js 20+ | the TypeScript runtime |
The evaluator alone (values, patterns, expressions, evaluation) is about 700 lines in Python and 900 in TypeScript. That is the size of the job for a new language.
The command and the WebAssembly module are the same Go code as the library. They exist so that a language without a runtime is not a language without Rule Cascade: see Using a language that has no runtime.
Choosing a form
| Situation | Use | Why |
|---|---|---|
| Web front end | TypeScript runtime with the client manifest | Evaluates in the browser on every keystroke; no round trip |
| Mobile app with a JavaScript layer (React Native, Ionic) | TypeScript runtime | Same as the web |
| Native mobile or desktop front end (Swift, Kotlin, C#, C++) | The client manifest with the Java runtime on Android, or with the command or the WebAssembly module elsewhere | load takes one manifest; the app never receives server rules |
| Service in Java, Kotlin or Scala | Java runtime | In process, no dependencies, takes custom operators |
| Service in Python | Python runtime | In process |
| Service in Go | Go runtime | In process, no dependencies |
| Service in Node.js | TypeScript runtime | In process |
| Service or tool in C#, Rust, PHP, Ruby, Swift, C++ ... | The command over the engine protocol | One child process, one line of JSON each way, about 0.1 ms per evaluation |
| A sandbox or plug-in host that cannot start processes | The WebAssembly module | One file for every platform |
| Many small clients, or a platform team that wants one place to look | The rule server | HTTP, stateless, horizontally scalable |
| CI | The command (rule-cascade check, compile) or tools/rulecheck.py | No toolchain to install beyond one file |
In every row the rules, the bundle and the answer are the same. Mixing forms is normal: a React front end, a Spring Boot API and a C# back-office tool can evaluate the same bundle.
Custom operators (x-*) are host code and exist only in the native libraries. A ruleset that must
run through the command, the module or the rule server should use functions instead, or keep the
rules that need a custom operator enforcement: server in a service that has a native runtime.
Why engines disagree, and how each cause is closed
Each row is a way two correct-looking implementations give different answers. The right-hand column is the rule that removes the difference; the section numbers refer to the specification.
| Cause | Example | Rule |
|---|---|---|
| Implicit conversion | "17" < 18, null == 0, truthiness | None exists. A wrong type is an evaluation error and the rule fails closed (4.1) |
| Binary floating point | 0.1 + 0.2 <= 0.3 is false in doubles | Arithmetic is decimal, 34 digits, round half even (4.2) |
| How numbers are read | 9007199254740993 is exact in Java and Python, rounded in JavaScript | A JSON number is the double nearest to what was written, everywhere; computed with through its shortest decimal form (4.2) |
| How numbers are written | 1e21, 1E+21, 1000000000000000000000 | Every number leaving the engine is rounded to 15 significant digits; canonical text has no exponent (4.2, 6) |
| String length and slicing | UTF-16 units in Java and JavaScript, bytes in Go, code points in Python | Strings are sequences of code points (4.1) |
| Case mapping | "İ".toLowerCase() depends on the locale and the Unicode version | lower and upper change ASCII letters only (4.3) |
| Whitespace | What trim removes differs between languages | Space, tab, line feed, carriage return (4.3) |
| Regular expressions | \d matches Arabic digits in Python and .NET; $ matches before a final newline in Java and Python; . excludes different line terminators; lookbehind exists in some engines only | One portable subset with one meaning, translated by each runtime to its engine; everything else is rejected at load time and at evaluation time (4.4) |
| Regular-expression limits | RE2 rejects nested counted repetition above 1000 | The limit is part of the subset (4.4) |
| Dates | Lenient parsers accept 2026-2-30, lower-case t, missing offsets | One strict RFC 3339 shape; everything else is an error (4.3) |
| Clocks and time zones | now() differs between a browser and a server | Evaluation never reads a clock; the host passes ctx.now (1) |
| Object member order | Hash maps iterate in different orders | No outcome depends on member order; canonical JSON sorts keys (6, 8) |
| Evaluation order | Short-circuit hides an error in one engine and not another | Laziness is specified per operator; collection operators never short-circuit (4.3) |
| Recursion and resource limits | Stack depth differs | Functions cannot be recursive; calls nest at most 32 deep (4.5) |
| YAML dialects | no is a boolean in YAML 1.1 and a string in YAML 1.2 | Authoring only; scalars that the dialects read differently must be quoted, and tools report them (12) |
| Locale of the machine | Decimal comma, sort order | Nothing in the engine consults the locale |
| Line endings and encodings | CRLF on Windows, code pages | Bundles and the engine protocol are UTF-8 JSON; a carriage return before a line feed is tolerated (13) |
Operating systems
The engine has no operating-system surface: no files, no clock, no network, no environment. What varies between systems is therefore only how an engine is delivered and started.
| System | Native libraries | Command | Tested in CI |
|---|---|---|---|
| Linux x86-64 and ARM64 | all four | rule-cascade-linux-amd64, -arm64 | every runtime, the command, the WebAssembly module, the container image |
| macOS Intel and Apple silicon | all four | rule-cascade-darwin-amd64, -arm64 | every runtime and the command |
| Windows x86-64 and ARM64 | all four | rule-cascade-windows-amd64.exe, -arm64.exe | every runtime and the command |
| Browsers | TypeScript | through Node.js only; nothing runs in a browser in CI | |
| iOS, Android | TypeScript in a JavaScript layer; Java or Kotlin on Android | not yet | |
| Anything with a WASI host | rule-cascade.wasm | Node.js and wasmtime on Linux |
sh packages/go/scripts/build-all.sh builds the six commands and the module and writes their SHA-256
checksums. CI runs the complete conformance suite against each runtime on Linux, Windows and macOS,
both in process and over the engine protocol.
Things that are easy to get wrong on one system only, and what the project does about them:
- Text mode on Windows. An engine writes
\n; a client must read lines in UTF-8 and tolerate\r\n. The conformance driver does, and the protocol says a carriage return before the line feed is tolerated in requests. - Console code pages. The engine protocol is UTF-8 whatever the console is set to. The Java engine sets the encoding of its streams explicitly for this reason.
- Git line endings.
.gitattributeskeeps every text file as LF, so the embedded schema copies are byte-identical on every checkout. - Path separators and globbing. The commands take forward slashes on every system; shells that
do not expand
*.ruleset.yaml(PowerShell, cmd) need the files listed.
Using a language that has no runtime
Start the command once and exchange lines of JSON with it. This is the whole protocol (specification section 13):
→ {"command":"load","bundle":{ ...the bundle... }}
← {"ok":true,"result":{"ruleset":"acme.payments.transfer","version":"1.0.0","checksum":"sha256:c192...","channels":["client","server"],"missingOperators":[]}}
→ {"command":"evaluate","ruleset":"acme.payments.transfer","request":{"entity":"Transfer","operation":"create","data":{...}}}
← {"ok":true,"result":{"decision":"deny","findings":[...],"effects":[...],"commands":[]}}load also takes a single manifest ("manifest":{...} in place of "bundle"). A front end that
must not hold the bundle loads the client manifest that way; channels is then ["client"], and a
request for the other channel is answered with CHANNEL_UNAVAILABLE.
examples/engine-clients has the same client in Python, Node.js,
Ruby, PHP, Java, shell, Rust, C# and PowerShell, each under 130 lines, and two that load the
WebAssembly module in process. They all print the same output, and CI runs them.
Keep one engine process alive for the life of your program. Measured on a two-core virtual machine, an evaluation through the command takes about 0.14 ms once the bundle is loaded; starting a process per request costs about 3.4 ms. One process answers requests in order, so start several for parallelism.
Writing a runtime for a new language
Do this when a language is used enough in your organisation to deserve in-process evaluation, or when it must supply custom operators.
-
Start at the evaluator level. Read a bundle, evaluate. Leave the compiler to the command.
-
Port in this order, running the suite after each step:
- values: equality, canonical JSON, rendering, number rounding (
values.py); - the portable-pattern scanner and its translation to your regular-expression engine;
- expressions (
expressions.py), untilconformance/expressions.jsonpasses; - evaluation (
evaluate.py), until the golden tests andconformance/evaluations.jsonpass.
- values: equality, canonical JSON, rendering, number rounding (
-
Speak the engine protocol (
engine.pyis 175 lines) and let the shared driver judge:python tools/rulecheck.py conformance --engine "your-engine engine --conformance-operators"An evaluator answers
compilewithUNSUPPORTED; the driver skips the compiler cases. -
Add the compiler level only if you need it: schema validation (a small interpreter of the schema file is easier to keep right than a hand-written mirror), inheritance, static checks, checksum.
conformance/load-errors.jsonandrulesets.jsoncover it. -
Fuzz against the reference. The suite's 1000 seeded random requests (
conformance/evaluations.json, generated byrulecheck sync) are a start; generate more of your own and compare your engine's answers with the Python reference. The runtimes in this repository were compared that way, at a much larger scale, while they were written, and the differences found are now cases in the suite. The scripts for those larger runs are not in this repository, so that part is a record of how the ports were built, not a check anyone can repeat.
What each language needs to bring:
| Need | Notes |
|---|---|
| A JSON parser that can hand over number text, or doubles | Numbers are doubles on entry, so a double-based parser is enough |
| Decimal arithmetic with 34 digits and half-even rounding | BigDecimal (Java, Kotlin, Scala), decimal (Python), decimal.js; System.Decimal in .NET has 28 to 29 digits and is not enough, use a big-decimal library; Go and Rust need a small type over big integers (the Go runtime's is 300 lines) |
| A regular-expression engine | Translate the portable subset: "dot matches everything", $ as the end of the input, ASCII \d and \w. In .NET, where \d and \w match Unicode by default, write them as [0-9] and [A-Za-z0-9_], use RegexOptions.Singleline, and write $ as \z |
| Iteration over code points | Not UTF-16 units and not bytes |
| SHA-256 | Compiler level only |
Compatibility promises
- The specification version (
ruleCascade: 1.x) and the bundle format version (ruleCascadeBundle: 1.x) only add within a major version. A runtime rejects a major version it does not implement instead of guessing. - The meaning of an operator never changes. New operators arrive in a new profile.
- A bundle is immutable. Its checksum identifies the exact rules; every evaluation result carries it, so a decision can be traced to the rules that made it, in whatever language it was made.
- Before 1.0 the specification is a draft and may still change between alpha versions. Every change is recorded in the changelog.
Rendered from docs/languages.md in the repository. Edit it there.
Enforcing rules on the UI and the backend: step by step
This guide is for an engineer who adds Rule Cascade to an existing product with a web front end and an API.
Bindings for language models
Two JSON files let a language-model agent work with Rule Cascade without being trusted with the rules. The agent can ask which rules apply, check an operation before it performs it, and draft a new rule.