Rule Cascade
Reference

Architecture

Rule Cascade keeps three things apart: writing a rule, compiling it, and evaluating it. Rules are written once as a ruleset, compiled once into a JSON bundle, and evaluated by whichever engine sits closest to the caller.

Rule Cascade keeps three things apart: writing a rule, compiling it, and evaluating it. Rules are written once as a ruleset, compiled once into a JSON bundle, and evaluated by whichever engine sits closest to the caller. The design goal is that enforcing a rule never depends on a shared service being up, and that every engine gives the same answer.

This page describes the design. The normative text is the specification; the decisions behind the design are recorded in docs/adr. How to wire enforcement into an application is in the enforcement guide, and what exists for each language is in languages.

Compile once, evaluate anywhere

Diagram, described in Mermaid: flowchart LR src["Source rulesets<br/>*.ruleset.yaml or .json<br/>and the entity schemas"] comp["Compiler<br/>schema, inheritance,<br/>load checks, checksum"] bundle[("Bundle<br/>one JSON file per ruleset<br/>server and client manifest")] subgraph evaluators["Evaluators"] direction TB lib["Native library<br/>Python, TypeScript, Java, Go"] cmd["Command<br/>rule-cascade engine"] wasm["WebAssembly module<br/>rule-cascade.wasm"] server["Rule server<br/>HTTP"] end browser["Browser<br/>TypeScript runtime"] src --> comp --> bundle bundle --> lib bundle --> cmd bundle --> wasm bundle --> server bundle -. "client manifest only" .-> browser
StageInputWorkOutput
AuthorA change requestEdit a ruleset and its golden tests*.ruleset.yaml in a pull request
CompileThe ruleset, its parent, the entity schemasValidate against the schema, resolve extends and overrides, run every load-time check, compute the checksumA bundle: <ruleset id>.bundle.json
PublishThe bundleStore it immutably; hand the server manifest or the bundle to backends and the client manifest to browsersThe same bundle everywhere
EvaluateA manifest and a requestSelect the rules, compute, set field state, validate, decide, return commandsAn evaluation result

Compilation is where a ruleset is checked: a path that is not in the API schema, an override the parent does not permit, a pattern that would not mean the same in every language (specification section 5). A ruleset that fails a check produces no bundle.

A bundle is plain JSON and holds two manifests. The server manifest contains every rule. The client manifest contains only the rules a browser may see, with the functions, parameters and messages those rules use. An evaluator reads a manifest and needs nothing else: no YAML parser, no schema validator, no inheritance (specification section 7).

Evaluation is a pure function of the manifest and the request. An engine reads no clock, network, file or random source while it evaluates; the current time arrives in the request as ctx.now. Two engines given the same bundle and the same request return the same result, down to the order of the findings; only the diagnostic detail of an engine error is specific to a runtime. The conformance suite checks this for every engine on every build, with 1899 cases.

Two conformance levels

LevelThe engine canNeedsIt passes
EvaluatorRead a bundle and evaluateA JSON parser, decimal arithmetic, a regular-expression engineThe expression cases, the golden tests and the evaluation corpus, starting from the published bundles
CompilerAlso load source documents and produce bundlesJSON Schema validation, and a YAML reader if it accepts YAMLAdditionally the load-error cases, and the checksum, bundle and client manifest of each fixture

The split keeps a new language cheap. An evaluator is a small program, and it is all an application needs at run time. Compiling happens in CI, with any compiler: the four runtimes in this repository implement both levels, and the suite requires each of them to compile the fixtures to exactly the published bundles.

The forms an engine takes

FormWhat it isUse it whenCustom operators
Native libraryA package in Python, TypeScript, Java or GoYour language has one. Evaluation is a function call in your processYes: registered by the host
Commandrule-cascade, one static binary for Linux, macOS and Windows, speaking the engine protocol on standard input and outputYour language has no library and may start a child process; also for CI (check, compile)Only those built into the binary
WebAssembly moduleThe same command as a WASI preview 1 module, rule-cascade.wasmYou cannot ship or start a native binary: a sandbox, a plug-in host, one artefact for every platformOnly those built into the module
Rule serverA stateless HTTP service (packages/server)The caller should not run an engine at all, or many teams want one place to look rules upOnly when the server is embedded in a program that registers them

Prefer the form closest to the caller. A native library adds no network hop and no process, and is as available as its host. The command and the module give every other language the same engine through the JSON Lines protocol of specification section 13; the examples in examples/engine-clients drive it from nine languages. The rule server is for callers that cannot do either, and it does not replace enforcement inside the service that owns the data.

A browser is a special case of the native library: the TypeScript runtime evaluates the client manifest for immediate feedback. Its answer is advice. The server evaluates again.

Control plane and data plane

Diagram, described in Mermaid: flowchart LR subgraph control["Control plane: when a rule changes"] direction TB author["Authors edit rulesets"] --> pr["Pull request and review"] pr --> ci["CI: rulecheck check,<br/>golden tests, compile"] ci --> store[("Bundle store<br/>immutable, versioned")] end subgraph data["Data plane: on every request"] direction TB backend["Backend service<br/>native library, bundle loaded at start"] other["Other languages<br/>command or WebAssembly module"] ruleserver["Rule server replicas<br/>stateless"] ui["Browser<br/>client manifest"] caller["Batch jobs, agents,<br/>callers without an engine"] caller --> ruleserver end store -- "bundle" --> backend store -- "bundle" --> other store -- "bundle or sources" --> ruleserver backend -- "client manifest, ETag" --> ui
Control planeData plane
RunsWhen a rule changes: a few times a weekOn every request: thousands of times a second
WorkValidate, test, compile, version, publishEvaluate
StateRulesets in version control, bundles in an artefact storeNone between requests
If it is downNobody can change rules; enforcement continuesNot applicable for embedded engines: nothing is shared

Keeping the planes apart lets the slow path be careful and the fast path have no dependencies. The only thing that crosses from one to the other is a file.

Scaling and availability

  • Evaluation is in process and pure. No I/O, no locks, no shared state. It runs where the caller runs and scales with the caller.
  • Bundles are immutable. A ruleset version and its checksum never change, so a bundle or a manifest can be cached without an expiry, in memory, in an artefact store and in the browser. The rule server and the examples use the checksum as the ETag.
  • Bundles are identified by checksum. Every manifest and every evaluation result carries the SHA-256 checksum of the resolved ruleset (specification section 6). An engine does not recompute it: a bundle does not contain the sources. Verifying that a bundle file is the one CI built is the job of the artefact store and the deployment (a digest or a signature on the file).
  • The rule server is stateless. Any replica answers any request. Add replicas behind a load balancer or let an autoscaler do it (deploy/kubernetes).
  • Reload is safe. The rule server builds a complete new snapshot on SIGHUP and swaps it in one step. If any ruleset or bundle fails to load, it keeps serving the previous snapshot. An embedded runtime gets new rules the same way an application gets new code: a rollout.
  • Everything fails closed. A ruleset that fails a load check is not served. A rule that cannot be evaluated produces a blocking finding. A malformed request is refused before anything is evaluated.
  • Cost is bounded by the rules. Expressions have no loops beyond the collection operators over lists in the request, functions cannot recurse and nest at most 32 deep, and patterns are limited in length and repetition (specification 4.4 and 4.5). SECURITY.md lists what the host still has to bound.

What happens when something fails

FailureWhat happens
The repository, CI or the bundle store is unavailableApplications keep evaluating the bundles they already loaded. Nobody can change rules until it is back
A ruleset with an error is proposedrulecheck check fails in CI: the schema, a load check or a golden test. No bundle is produced
A file that is not a usable bundle is deployedThe engine refuses it (BUNDLE_UNSUPPORTED, BUNDLE_INVALID). A service should let that stop its start-up; the rule server exits before it listens, so the new pod never becomes ready and the rollout stops
A reload of the rule server finds a bad fileThe reload is refused and logged; the previous rules keep being served
A rule server replica diesThe others carry on; the disruption budget and the spread across zones keep at least two running
The whole rule server is unreachableServices with an embedded engine are unaffected. A page that already holds its client manifest keeps evaluating; a page loading afresh gets no client-side feedback, and the server-side check still decides
A browser has stale or tampered rulesThe server evaluates again on every state-changing operation and its answer is the one that counts
A rule meets data it was not written forThe rule fails closed: a blocking RULE-EVALUATION-ERROR finding and the decision deny, never a silent pass
A custom operator is not registered in one engineThe rules that call it fail closed in that engine. Hosts compare the manifest's operators list with what they registered at start-up
A request does not have the shape of an evaluation requestIt is refused before anything is evaluated: an error in a library, BAD_REQUEST in the engine protocol, 400 from the rule server
An engine process (command or module) stopsThe client starts a new one and loads the bundle again. Evaluation is pure, so sending the request again is safe

Request flow

A user interface and its API

Sequence diagram, described in Mermaid: sequenceDiagram autonumber participant U as User participant B as Browser, client manifest participant S as Backend, server manifest participant D as Database participant Q as Event bus B->>S: GET client manifest, If-None-Match checksum S-->>B: 304, or the client manifest U->>B: edits the form B->>B: evaluate on change and blur, for this view Note over B: field states, computed values, findings U->>B: submit, with acknowledgements B->>S: POST the operation S->>S: evaluate again, actor from authentication alt decision is deny S-->>B: refuse, with the findings else decision is allow S->>D: persist S->>Q: run the returned commands, once per idempotency key S-->>B: success, with the non-blocking findings end
  1. The browser gets the client manifest and evaluates locally as the user types. A request may name a trigger (change, blur) and a view (page, screen, section, component), so only the rules for that moment and that place run.
  2. On submit the browser sends the operation and the user's resolutions: acknowledged warnings and accepted risks.
  3. The backend evaluates the server manifest, which includes the server-only rules, with the actor from its own authentication and ctx.now from its own clock.
  4. On deny it refuses and returns the findings; the examples answer 422 with problem details. Each finding carries field pointers and, when its rule names one, a location in the user interface.
  5. On allow it persists, then runs the commands the action rules returned, de-duplicated on idempotencyKey.

An API without a user interface

A service, a batch job or an agent has no client step. It evaluates on the server channel before it changes anything: in process with a native library, through the command or the WebAssembly module over the engine protocol, or with POST /evaluations on the rule server. Steps 3 to 5 are the same. Working examples: a service on node:http and on Go's net/http, a batch job that streams a file through any engine, and an agent's tool host over the rule server.

Trust boundaries

ArtefactContainsWho may have it
Source rulesetsEverything, including overrides, golden tests and bindingsThe repository; reviewed like code
BundleThe server and the client manifestBackends and the build pipeline. Never a browser
Server manifestEvery rule and parameter, including enforcement: server rules and action rulesBackends. The rule server requires its token for it
Client manifestRules with enforcement: client or both that are not action rules, and the functions, parameters and messages they useAnyone: it is public by design
Evaluation requestEntity state, resolutions, and the actorThe actor is set by the host from its own authentication, never taken from the caller

Three rules follow from the table.

  • The server decides. A client evaluation is advice for the user. Every state-changing operation is evaluated on the server, whatever the client reported (specification section 9).
  • What must stay secret is enforcement: server. A rule, a parameter or a message that reaches the client manifest can be read by any user. The compiler removes from the client manifest everything only server rules use; it cannot know that a both rule reveals something it should not. The authoring guidelines say what must never be client or both.
  • A bundle is trusted input. An engine checks the format of a bundle and nothing else, because the compiler ran the checks. Load bundles only from a source you control.

Custom operators are the one place where host code takes part in an evaluation. They are functions of the host, registered by name in every engine that evaluates the ruleset. They cannot cross a process boundary, so the command, the module and the stock rule server have none (ADR 0008).

Multi-level configuration

A ruleset sits at one point in the hierarchy and extends exactly one parent, so the levels form a chain, for example enterprise, organization, business unit, application, feature. Each level adds rules and may adjust what it inherited, within what the level above permits.

Parent saysA child may
lockedChange nothing
tighten-onlyRaise a rule's severity, require an acknowledgement, withdraw an acceptance; move a numeric parameter in the stricter direction
openChange severity, acknowledgement, acceptance and enablement, or the parameter value, freely

Inheritance is resolved by the compiler into one flat ruleset. A bundle has no hierarchy left to walk, so depth costs nothing per request, and an evaluator does not need the parents at all (ADR 0004).

Operating it

  • Roll out rules like code. A ruleset version is immutable. Publishing is a pull request, CI, a bundle, and a rolling deployment or a reload.
  • Log the checksum. Every evaluation result carries the checksum of the ruleset that produced it. Log it with the decision and you can always tell which rules were in force.
  • Alert on RULE-EVALUATION-ERROR. It means a rule met data it was not written for, or an operator is missing. It blocks the operation, so treat it as an incident.
  • Check custom operators at start-up. Compare the operators list of the manifest with what the host registered, and refuse to start when one is missing.
  • Keep clocks out. Pass ctx.now from the host. A result is then reproducible from the bundle and the request alone, which makes disputes and audits answerable.

Rendered from docs/architecture.md in the repository. Edit it there.

On this page