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:
- start the engine once;
- send
version; loadthe payments bundle (conformance/bundles/acme.payments.transfer.bundle.json);evaluatetwo requests taken from the golden tests ofpayments-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;- print the decision and the severity, code and message of every finding;
- 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
| Form | Use it when | What it costs |
|---|---|---|
| Native library (Python, TypeScript, Java, Go) | Your language has one | Nothing extra: a function call in your process. The only form that takes custom operators |
Command, rule-cascade engine | Your language has no library and your program may start a child process | One binary per operating system and processor to ship; one line of JSON each way per request |
WebAssembly module, rule-cascade.wasm | You 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 runtime | Slower 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
loadrequest 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 nottrue,error.codeanderror.messagesay 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
| File | Language | Needs | Verified |
|---|---|---|---|
client.py (examples/engine-clients/process/client.py) | Python 3.7 or later | standard library | yes (Python 3.13) |
client.mjs (examples/engine-clients/process/client.mjs) | Node.js 18 or later | standard library | yes (Node.js 22) |
client.rb (examples/engine-clients/process/client.rb) | Ruby 2.7 or later | standard library | yes (Ruby 3.3) |
client.php (examples/engine-clients/process/client.php) | PHP 7.4 or later | standard library | yes (PHP 8.3) |
Client.java (examples/engine-clients/process/Client.java) | Java 17 or later | standard library | yes (OpenJDK 21) |
client.sh (examples/engine-clients/process/client.sh) | bash 3.2 or later, or a POSIX sh | jq, mkfifo | yes (bash 5.2 and dash, jq 1.7) |
client.rs (examples/engine-clients/process/client.rs) | Rust 1.70 or later | serde_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 later | standard 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 7 | nothing | in CI (PowerShell 7 of the ubuntu-latest runner) |
Notes:
- The engine is the program named by the
RULE_CASCADE_ENGINEenvironment variable, by defaultrule-cascadeon thePATH. The bundle is the first argument. - The JDK has no JSON reader, so
Client.javacarries 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.rsusesserde_json, which was downloaded from crates.io when the example was verified. client.shconnects the engine with two named pipes and uses nothing outside POSIXsh(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 useclient.ps1.Client.csandclient.ps1were written against the documented behaviour of .NET and PowerShell and have not been executed.run-all.shruns them wheredotnet,pwshorpowershellexists.
wasm/: the WebAssembly module inside the client's own process
| File | Language | Needs | Verified |
|---|---|---|---|
client.mjs (examples/engine-clients/wasm/client.mjs) | Node.js 20 or later | standard library (node:wasi) | yes (Node.js 22) |
client.py (examples/engine-clients/wasm/client.py) | Python 3.8 or later | wasmtime (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-cascadeEverything, on Linux and macOS
sh examples/engine-clients/run-all.shruns 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 skippedIt 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 $BUNDLEOne 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 $bundleclient.sh needs named pipes: run it in WSL.
Performance
- Keep one engine process alive. Start it when your program starts,
loadeach bundle once, and send every later request to the same process. A process started for each request pays the start and theloadevery 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
loadagain. 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 load | Each evaluate, engine kept alive | Each evaluate, new process per request | |
|---|---|---|---|
| Command (native) | 4 ms | 0.14 ms (about 7,000 per second) | 3.4 ms |
| WebAssembly module as a child process under Node.js 22 | 130 ms | 0.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
operatorsmember of theversionresult lists what an engine has), so those rules fail closed: a blocking finding with codeRULE-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.50becomes120.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.