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
| Stage | Input | Work | Output |
|---|---|---|---|
| Author | A change request | Edit a ruleset and its golden tests | *.ruleset.yaml in a pull request |
| Compile | The ruleset, its parent, the entity schemas | Validate against the schema, resolve extends and overrides, run every load-time check, compute the checksum | A bundle: <ruleset id>.bundle.json |
| Publish | The bundle | Store it immutably; hand the server manifest or the bundle to backends and the client manifest to browsers | The same bundle everywhere |
| Evaluate | A manifest and a request | Select the rules, compute, set field state, validate, decide, return commands | An 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
| Level | The engine can | Needs | It passes |
|---|---|---|---|
| Evaluator | Read a bundle and evaluate | A JSON parser, decimal arithmetic, a regular-expression engine | The expression cases, the golden tests and the evaluation corpus, starting from the published bundles |
| Compiler | Also load source documents and produce bundles | JSON Schema validation, and a YAML reader if it accepts YAML | Additionally 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
| Form | What it is | Use it when | Custom operators |
|---|---|---|---|
| Native library | A package in Python, TypeScript, Java or Go | Your language has one. Evaluation is a function call in your process | Yes: registered by the host |
| Command | rule-cascade, one static binary for Linux, macOS and Windows, speaking the engine protocol on standard input and output | Your language has no library and may start a child process; also for CI (check, compile) | Only those built into the binary |
| WebAssembly module | The same command as a WASI preview 1 module, rule-cascade.wasm | You cannot ship or start a native binary: a sandbox, a plug-in host, one artefact for every platform | Only those built into the module |
| Rule server | A stateless HTTP service (packages/server) | The caller should not run an engine at all, or many teams want one place to look rules up | Only 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
| Control plane | Data plane | |
|---|---|---|
| Runs | When a rule changes: a few times a week | On every request: thousands of times a second |
| Work | Validate, test, compile, version, publish | Evaluate |
| State | Rulesets in version control, bundles in an artefact store | None between requests |
| If it is down | Nobody can change rules; enforcement continues | Not 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
SIGHUPand 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.mdlists what the host still has to bound.
What happens when something fails
| Failure | What happens |
|---|---|
| The repository, CI or the bundle store is unavailable | Applications keep evaluating the bundles they already loaded. Nobody can change rules until it is back |
| A ruleset with an error is proposed | rulecheck 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 deployed | The 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 file | The reload is refused and logged; the previous rules keep being served |
| A rule server replica dies | The others carry on; the disruption budget and the spread across zones keep at least two running |
| The whole rule server is unreachable | Services 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 rules | The server evaluates again on every state-changing operation and its answer is the one that counts |
| A rule meets data it was not written for | The 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 engine | The 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 request | It 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) stops | The 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
- The browser gets the client manifest and evaluates locally as the user types. A request may
name a
trigger(change,blur) and aview(page, screen, section, component), so only the rules for that moment and that place run. - On submit the browser sends the operation and the user's resolutions: acknowledged warnings and accepted risks.
- The backend evaluates the server manifest, which includes the server-only rules, with the
actor from its own authentication and
ctx.nowfrom its own clock. - On
denyit refuses and returns the findings; the examples answer422with problem details. Each finding carries field pointers and, when its rule names one, alocationin the user interface. - On
allowit persists, then runs the commands the action rules returned, de-duplicated onidempotencyKey.
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
| Artefact | Contains | Who may have it |
|---|---|---|
| Source rulesets | Everything, including overrides, golden tests and bindings | The repository; reviewed like code |
| Bundle | The server and the client manifest | Backends and the build pipeline. Never a browser |
| Server manifest | Every rule and parameter, including enforcement: server rules and action rules | Backends. The rule server requires its token for it |
| Client manifest | Rules with enforcement: client or both that are not action rules, and the functions, parameters and messages they use | Anyone: it is public by design |
| Evaluation request | Entity state, resolutions, and the actor | The 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 abothrule reveals something it should not. The authoring guidelines say what must never beclientorboth. - 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 says | A child may |
|---|---|
locked | Change nothing |
tighten-only | Raise a rule's severity, require an acknowledgement, withdraw an acceptance; move a numeric parameter in the stricter direction |
open | Change 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
operatorslist of the manifest with what the host registered, and refuse to start when one is missing. - Keep clocks out. Pass
ctx.nowfrom 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.
JSON Logic and Rule Cascade
JSON Logic is the design Rule Cascade expressions started from: logic written as JSON data, read with var, evaluated by a small interpreter that exists in many languages.
Architecture decision records
Why Rule Cascade is designed the way it is: one record per decision, with its context and consequences.