Rule Cascade
ReferenceExample READMEs

Engine clients: Rule Cascade from any language

Rule Cascade has native libraries for Python, TypeScript, Java and Go. A program in any other language uses the universal engine: the single-file command rule-cascade, or the same engine as a WebAssembly module.

Rule Cascade has native libraries for Python, TypeScript, Java and Go. A program in any other language uses the universal engine: the single-file command rule-cascade, or the same engine as a WebAssembly module. Both speak the JSON Lines engine protocol of specification section 13 on standard input and output, and both give the answers the libraries give, because they are the Go library and pass the same conformance suite.

The examples here are small on purpose. Every one does the same thing, so they can be compared line by line:

  1. start the engine once;
  2. send version;
  3. load the payments bundle (conformance/bundles/acme.payments.transfer.bundle.json);
  4. evaluate two requests taken from the golden tests of payments-transfer.ruleset.yaml (examples/contracts/payments-transfer.ruleset.yaml): a domestic transfer that is allowed, and a transfer to a blocked country that is denied;
  5. print the decision and the severity, code and message of every finding;
  6. exit with status 1 when the engine refuses a request, answers out of order, stops, or exits with a non-zero status.

Every example prints exactly expected-output.txt (examples/engine-clients/expected-output.txt):

engine: Rule Cascade 1.0.0, bundle format 1.0.0
loaded: acme.payments.transfer 1.0.0
domestic transfer: allow
  warning ORG-TRF-003 Adding a memo makes this transfer easier to reconcile.
blocked country: deny
  error ORG-TRF-001 Transfers to KP are not permitted.

Which form to use

FormUse it whenWhat it costs
Native library (Python, TypeScript, Java, Go)Your language has oneNothing extra: a function call in your process. The only form that takes custom operators
Command, rule-cascade engineYour language has no library and your program may start a child processOne binary per operating system and processor to ship; one line of JSON each way per request
WebAssembly module, rule-cascade.wasmYou cannot ship or start a native binary (a sandbox, a plug-in host, one artifact for every platform) and you have a WASI preview 1 runtimeSlower than the command (about three times per request in the measurements below) and slower to start

The stateless HTTP rule server in packages/server is a fourth form, for callers that should not run an engine themselves.

The protocol

One JSON object per line in, one JSON object per line out, in the same order. Lines are UTF-8 and end with a line feed. A request has a command (version, load, manifest, evaluate, expression, compile), the members of that command, and an optional id that the response repeats. A response is {"ok": true, "result": ...} or {"ok": false, "error": {"code": ..., "message": ...}}. The engine exits when its input ends.

The fourth request of every example, and its response (each is one line):

{"id":4,"command":"evaluate","ruleset":"acme.payments.transfer","request":{"entity":"Transfer","operation":"create","data":{"id":"t-2","type":"international","amount":500,"currency":"USD","memo":"gift","beneficiary":{"name":"X","country":"KP","swiftCode":"ABCDKPPY"}}}}
{"id":4,"ok":true,"result":{"ruleset":"acme.payments.transfer","version":"1.0.0","checksum":"sha256:c192dd53b5b1d307d52ccbc27fc1674114e8714d53b699b24088a648ae242c7e","decision":"deny","findings":[{"rule":"org.transfer.blocked-country","code":"ORG-TRF-001","severity":"error","message":"Transfers to KP are not permitted.","fields":["/beneficiary/country"],"blocking":true,"status":"open","resolution":"none","source":"acme.org.base@1.2.0"}],"effects":[{"type":"value","field":"/fee","value":7.5,"rule":"transfer.fee.international"}],"commands":[]}}

evaluate uses the server channel unless the request says "channel": "client". The request and the result are those of specification section 8.

What a client has to get right, and what every example here does:

  • One line each way, in turn. Write one request line, flush, read one response line. A client that writes many requests before it reads any response blocks for ever once the pipes are full.
  • No limit on the length of a line. The load request carries the whole bundle on one line. Read with a function that returns a whole line, not with a fixed buffer.
  • UTF-8 without a byte order mark, whatever the locale or the Windows code page.
  • A bundle on one line. Parse the bundle file and write it without indentation, or remove the line breaks from its text: no JSON string contains a raw line break, so the document is unchanged.
  • Check ok. When it is not true, error.code and error.message say why.
  • Leave the engine's standard error alone (inherit it), so the engine never waits on a pipe that nobody reads.

The examples

"Verified here" means: run on Linux x86-64 against rule-cascade 1.0.0-alpha.2, with the output compared byte for byte with expected-output.txt. The process/ examples were also run with a bundle of 400 KB on one line and against test engines that answer with lines of more than 300 KB, with text outside ASCII, with a refusal, with the wrong id, that stop in the middle, that exit with a non-zero status, and that do not exist. The wasm/ examples were also run with the 400 KB bundle, with a bundle the engine refuses, and without a module.

Nothing was run on macOS or Windows. The versions in the "Language" column are those of the language features an example uses; only the versions in the last column were run.

process/: the engine as a child process

FileLanguageNeedsVerified
client.py (examples/engine-clients/process/client.py)Python 3.7 or laterstandard libraryyes (Python 3.13)
client.mjs (examples/engine-clients/process/client.mjs)Node.js 18 or laterstandard libraryyes (Node.js 22)
client.rb (examples/engine-clients/process/client.rb)Ruby 2.7 or laterstandard libraryyes (Ruby 3.3)
client.php (examples/engine-clients/process/client.php)PHP 7.4 or laterstandard libraryyes (PHP 8.3)
Client.java (examples/engine-clients/process/Client.java)Java 17 or laterstandard libraryyes (OpenJDK 21)
client.sh (examples/engine-clients/process/client.sh)bash 3.2 or later, or a POSIX shjq, mkfifoyes (bash 5.2 and dash, jq 1.7)
client.rs (examples/engine-clients/process/client.rs)Rust 1.70 or laterserde_json (Cargo.toml (examples/engine-clients/process/Cargo.toml))yes (Rust 1.97, serde_json 1.0.151)
Client.cs (examples/engine-clients/process/Client.cs)C# on .NET 8 or laterstandard library (Client.csproj (examples/engine-clients/process/Client.csproj))in CI (the .NET SDK of the ubuntu-latest runner)
client.ps1 (examples/engine-clients/process/client.ps1)Windows PowerShell 5.1, PowerShell 7nothingin CI (PowerShell 7 of the ubuntu-latest runner)

Notes:

  • The engine is the program named by the RULE_CASCADE_ENGINE environment variable, by default rule-cascade on the PATH. The bundle is the first argument.
  • The JDK has no JSON reader, so Client.java carries a small one (about 40 of its 120 lines) and writes its requests as text. In a project, use the JSON library you already have.
  • The Rust standard library has no JSON either. client.rs uses serde_json, which was downloaded from crates.io when the example was verified.
  • client.sh connects the engine with two named pipes and uses nothing outside POSIX sh (it was run with dash as well as bash), so it does not need the coprocesses of bash 4; the bash 3.2 of macOS should run it, which was not tested. It is for Linux, macOS and WSL; on Windows use client.ps1.
  • Client.cs and client.ps1 were written against the documented behaviour of .NET and PowerShell and have not been executed. run-all.sh runs them where dotnet, pwsh or powershell exists.

wasm/: the WebAssembly module inside the client's own process

FileLanguageNeedsVerified
client.mjs (examples/engine-clients/wasm/client.mjs)Node.js 20 or laterstandard library (node:wasi)yes (Node.js 22)
client.py (examples/engine-clients/wasm/client.py)Python 3.8 or laterwasmtime (pip install wasmtime)yes (Python 3.13, wasmtime 49.0.0)

The module is the file named by RULE_CASCADE_WASM, by default rule-cascade.wasm in the current directory. The engine gets no directory and no environment variable: it sees nothing of the host but its standard input and output.

These two run the requests as one batch. A WASI command module runs from its first input line to its last on the calling thread, so the examples write every request line to a temporary file, give it to the module as standard input, and read the response lines from the file that was its standard output. The evaluate requests can name the ruleset before load has answered, because its id is in the bundle. A request that depends on an earlier response needs a second run.

For a conversation with the WebAssembly module, one request at a time, run it as a child process under any WASI runtime and use a process/ client unchanged. RULE_CASCADE_ENGINE names one program, so the runtime goes in a two-line wrapper:

#!/bin/sh
exec node /path/to/packages/go/wasi/run.mjs /path/to/rule-cascade.wasm "$@"

process/client.py and process/client.rb were verified here against this wrapper.

Run them

Build the engine once (Go 1.22 or later), or take the binary for your platform from packages/go/dist after sh packages/go/scripts/build-all.sh:

cd packages/go
go build -o dist/rule-cascade ./cmd/rule-cascade
GOOS=wasip1 GOARCH=wasm go build -o dist/rule-cascade.wasm ./cmd/rule-cascade

Everything, on Linux and macOS

sh examples/engine-clients/run-all.sh

runs every example whose language is installed, compares each output with expected-output.txt, prints PASS, FAIL or SKIP for each, and exits with a non-zero status when one fails:

PASS  process/client.py
PASS  process/client.mjs
PASS  process/client.rb
PASS  process/client.php
PASS  process/Client.java
PASS  process/client.sh
PASS  process/client.rs
SKIP  process/Client.cs (the .NET SDK is not installed)
SKIP  process/client.ps1 (PowerShell is not installed)
PASS  wasm/client.mjs
PASS  wasm/client.py
9 passed, 0 failed, 2 skipped

It takes the engine from RULE_CASCADE_ENGINE, then packages/go/dist/rule-cascade, then the PATH; and the module from RULE_CASCADE_WASM, then packages/go/dist/rule-cascade.wasm. Set RULE_CASCADE_REQUIRE to a list of examples (or all) to turn a skipped example into a failure. On Windows it runs in Git Bash or WSL.

One example, on Linux and macOS

From examples/engine-clients:

export RULE_CASCADE_ENGINE=$PWD/../../packages/go/dist/rule-cascade
export RULE_CASCADE_WASM=$PWD/../../packages/go/dist/rule-cascade.wasm
BUNDLE=../../conformance/bundles/acme.payments.transfer.bundle.json

python3 process/client.py $BUNDLE
node process/client.mjs $BUNDLE
ruby process/client.rb $BUNDLE
php process/client.php $BUNDLE
java process/Client.java $BUNDLE
bash process/client.sh $BUNDLE
cargo run --quiet --manifest-path process/Cargo.toml -- $BUNDLE
dotnet run --project process/Client.csproj -- $BUNDLE
pwsh -NoProfile -File process/client.ps1 $BUNDLE

node wasm/client.mjs $BUNDLE
python3 wasm/client.py $BUNDLE

One example, on Windows (PowerShell)

Build rule-cascade.exe with go build -o dist\rule-cascade.exe .\cmd\rule-cascade in packages\go, then from examples\engine-clients:

$env:RULE_CASCADE_ENGINE = (Resolve-Path ..\..\packages\go\dist\rule-cascade.exe).Path
$env:RULE_CASCADE_WASM = (Resolve-Path ..\..\packages\go\dist\rule-cascade.wasm).Path
$bundle = (Resolve-Path ..\..\conformance\bundles\acme.payments.transfer.bundle.json).Path

powershell -NoProfile -ExecutionPolicy Bypass -File process\client.ps1 $bundle
dotnet run --project process\Client.csproj -- $bundle
python process\client.py $bundle
node process\client.mjs $bundle
ruby process\client.rb $bundle
php process\client.php $bundle
java process\Client.java $bundle
cargo run --quiet --manifest-path process\Cargo.toml -- $bundle

node wasm\client.mjs $bundle
python wasm\client.py $bundle

client.sh needs named pipes: run it in WSL.

Performance

  • Keep one engine process alive. Start it when your program starts, load each bundle once, and send every later request to the same process. A process started for each request pays the start and the load every time.
  • One process answers one request at a time, in the order it reads them. A client with several threads must put a lock around "write a line, read a line", or give each thread its own engine.
  • For parallel work, start a pool: several engine processes, each with the bundles loaded, and one request in flight per process. The engine keeps no state between requests other than the loaded rulesets, so any process of the pool can answer any request.
  • When the engine stops (the response is missing), start a new one and load again. Evaluation is a pure function: sending the request again is safe.

Measured here on a virtual machine with two processors, with a Python client and the second request of the examples; the numbers show proportions, not what your hardware will do:

Start and loadEach evaluate, engine kept aliveEach evaluate, new process per request
Command (native)4 ms0.14 ms (about 7,000 per second)3.4 ms
WebAssembly module as a child process under Node.js 22130 ms0.48 ms (about 2,100 per second)260 ms

Inside a process, wasm/client.mjs takes 0.3 s for the whole batch. wasm/client.py takes 2.9 s, almost all of it wasmtime compiling the module: a program that runs more than one batch compiles the module once (wasmtime.Module) and creates a new store and instance for each run.

Limitations

  • Custom operators cannot cross a process boundary. A ruleset that uses a custom operator is evaluated correctly only by an engine that has the operator built in. The command and the WebAssembly module have none of yours (the operators member of the version result lists what an engine has), so those rules fail closed: a blocking finding with code RULE-EVALUATION-ERROR. Use a native library, or a build of the command that registers your operators.
  • A request and its response are whole lines held in memory. A bundle of some megabytes is fine; this is not a streaming interface.
  • The in-process WebAssembly examples run a batch, as described above.
  • Numbers. A client that parses a bundle and writes it again may change how a number is written (120.50 becomes 120.5). This does not change the result: a number in a bundle or a request is the IEEE 754 double nearest to what was written.

On this page