Rule Cascade
Reference

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

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.

Compile once, evaluate anywhere

Three decisions make this work:

  1. 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 no or 012.
  2. 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.
  3. 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

EngineConformance levelRuns inDependenciesSize (non-blank lines)
Python, rule_cascade (packages/python)evaluator and compiler; the referencePython 3.10+jsonschema1,300
TypeScript, @yarlisaisolutions/rule-cascade (packages/typescript)evaluator and compilerbrowsers, Node.js 20+, React, React Nativedecimal.js; ajv only to compile2,300
Java, rule-cascade-core (packages/java)evaluator and compilerany JVM, Java 17+none4,200
Go, rulecascade (packages/go)evaluator and compilerGo 1.22+none4,000
Command, rule-cascadeevaluator and compilerLinux, macOS, Windows; x86-64 and ARM64none: one static filebuilt from the Go runtime
WebAssembly, rule-cascade.wasmevaluator and compilerany WASI preview 1 hostnone: one filebuilt from the Go runtime
Rule server (packages/server)evaluator and compiler, over HTTPa 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

SituationUseWhy
Web front endTypeScript runtime with the client manifestEvaluates in the browser on every keystroke; no round trip
Mobile app with a JavaScript layer (React Native, Ionic)TypeScript runtimeSame 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 elsewhereload takes one manifest; the app never receives server rules
Service in Java, Kotlin or ScalaJava runtimeIn process, no dependencies, takes custom operators
Service in PythonPython runtimeIn process
Service in GoGo runtimeIn process, no dependencies
Service in Node.jsTypeScript runtimeIn process
Service or tool in C#, Rust, PHP, Ruby, Swift, C++ ...The command over the engine protocolOne child process, one line of JSON each way, about 0.1 ms per evaluation
A sandbox or plug-in host that cannot start processesThe WebAssembly moduleOne file for every platform
Many small clients, or a platform team that wants one place to lookThe rule serverHTTP, stateless, horizontally scalable
CIThe command (rule-cascade check, compile) or tools/rulecheck.pyNo 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.

CauseExampleRule
Implicit conversion"17" < 18, null == 0, truthinessNone exists. A wrong type is an evaluation error and the rule fails closed (4.1)
Binary floating point0.1 + 0.2 <= 0.3 is false in doublesArithmetic is decimal, 34 digits, round half even (4.2)
How numbers are read9007199254740993 is exact in Java and Python, rounded in JavaScriptA JSON number is the double nearest to what was written, everywhere; computed with through its shortest decimal form (4.2)
How numbers are written1e21, 1E+21, 1000000000000000000000Every number leaving the engine is rounded to 15 significant digits; canonical text has no exponent (4.2, 6)
String length and slicingUTF-16 units in Java and JavaScript, bytes in Go, code points in PythonStrings are sequences of code points (4.1)
Case mapping"İ".toLowerCase() depends on the locale and the Unicode versionlower and upper change ASCII letters only (4.3)
WhitespaceWhat trim removes differs between languagesSpace, 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 onlyOne 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 limitsRE2 rejects nested counted repetition above 1000The limit is part of the subset (4.4)
DatesLenient parsers accept 2026-2-30, lower-case t, missing offsetsOne strict RFC 3339 shape; everything else is an error (4.3)
Clocks and time zonesnow() differs between a browser and a serverEvaluation never reads a clock; the host passes ctx.now (1)
Object member orderHash maps iterate in different ordersNo outcome depends on member order; canonical JSON sorts keys (6, 8)
Evaluation orderShort-circuit hides an error in one engine and not anotherLaziness is specified per operator; collection operators never short-circuit (4.3)
Recursion and resource limitsStack depth differsFunctions cannot be recursive; calls nest at most 32 deep (4.5)
YAML dialectsno is a boolean in YAML 1.1 and a string in YAML 1.2Authoring only; scalars that the dialects read differently must be quoted, and tools report them (12)
Locale of the machineDecimal comma, sort orderNothing in the engine consults the locale
Line endings and encodingsCRLF on Windows, code pagesBundles 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.

SystemNative librariesCommandTested in CI
Linux x86-64 and ARM64all fourrule-cascade-linux-amd64, -arm64every runtime, the command, the WebAssembly module, the container image
macOS Intel and Apple siliconall fourrule-cascade-darwin-amd64, -arm64every runtime and the command
Windows x86-64 and ARM64all fourrule-cascade-windows-amd64.exe, -arm64.exeevery runtime and the command
BrowsersTypeScriptthrough Node.js only; nothing runs in a browser in CI
iOS, AndroidTypeScript in a JavaScript layer; Java or Kotlin on Androidnot yet
Anything with a WASI hostrule-cascade.wasmNode.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. .gitattributes keeps 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.

  1. Start at the evaluator level. Read a bundle, evaluate. Leave the compiler to the command.

  2. Port in this order, running the suite after each step:

    1. values: equality, canonical JSON, rendering, number rounding (values.py);
    2. the portable-pattern scanner and its translation to your regular-expression engine;
    3. expressions (expressions.py), until conformance/expressions.json passes;
    4. evaluation (evaluate.py), until the golden tests and conformance/evaluations.json pass.
  3. Speak the engine protocol (engine.py is 175 lines) and let the shared driver judge:

    python tools/rulecheck.py conformance --engine "your-engine engine --conformance-operators"

    An evaluator answers compile with UNSUPPORTED; the driver skips the compiler cases.

  4. 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.json and rulesets.json cover it.

  5. Fuzz against the reference. The suite's 1000 seeded random requests (conformance/evaluations.json, generated by rulecheck 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:

NeedNotes
A JSON parser that can hand over number text, or doublesNumbers are doubles on entry, so a double-based parser is enough
Decimal arithmetic with 34 digits and half-even roundingBigDecimal (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 engineTranslate 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 pointsNot UTF-16 units and not bytes
SHA-256Compiler 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.

On this page