rule-cascade-core (Java)
The Rule Cascade runtime for the JVM. Java 17+, no dependencies: it works on maps and lists, so it sits behind whatever JSON or YAML library your service already uses.
The Rule Cascade runtime for the JVM. Java 17+, no dependencies: it works on maps and lists, so it sits behind whatever JSON or YAML library your service already uses.
It implements both conformance levels of the specification: evaluator (read a bundle and evaluate) and compiler (load source documents and produce bundles).
<dependency>
<groupId>io.github.yarlisaisolutions</groupId>
<artifactId>rule-cascade-core</artifactId>
<version>1.0.0-alpha.2</version>
</dependency>Use
// Once, at startup. Throws LoadException: let it stop the application.
RuleSet rules = RuleSet.load(document, registry, schemaLoader);
// On every state-changing operation.
EvaluationResult result = rules.evaluate(
EvaluationRequest.builder("Transfer", "create")
.data(payload) // Map<String, Object>
.actor(userId, roles)
.resolutions(resolutionsFromRequest)
.build());
if (!result.allowed()) {
throw new RuleViolationException(result); // return result.toMap() as problem details
}
repository.save(transfer);
result.commands().forEach(outbox::publishOnce); // de-duplicate on command.idempotencyKey()document is the ruleset as a Map<String, Object>, already parsed from YAML or JSON. registry
maps ruleset ids to documents so extends can be resolved. schemaLoader returns the document
behind the file part of an entity $ref (an OpenAPI description, usually). When it is null the
PATH_UNKNOWN and SCHEMA_REF_UNRESOLVED checks are skipped; every other load-time check still runs.
A request is checked when it is built, before anything is evaluated: EvaluationRequest throws
IllegalArgumentException without an entity or an operation, when actor.roles is not a list of
strings, or for a resolution without a rule or a type. EvaluationRequest.fromMap applies the full
shape of specification section 8 to a request in its wire form.
| Class | Role |
|---|---|
RuleSet | Compile a ruleset (load), read a bundle (fromBundle) or one manifest (fromManifest); evaluate, manifest(Channel), channels(), bundle(), checksum(), withOperators, missingOperators(), requiredOperators(Channel) |
EvaluationRequest, Resolution, View | The operation to check, the user's acknowledgements, and the place in the UI to evaluate |
EvaluationResult | Decision, findings, effects, commands; toMap() for JSON |
CustomOperator | An x-* operator supplied by the host |
Evaluator | Evaluate a manifest received from elsewhere |
Expressions | Evaluate a single expression |
LoadException, Problem, LoadErrorCode | Why a ruleset or a bundle was refused |
SchemaLoader | Supplies entity schema documents so data paths can be checked at load |
Json | Small JSON reader and writer, and the canonical writer used for checksums |
Engine, Main | The engine protocol: this runtime as a program |
RuleSetHolder, CronSchedule | Hold the rules and reload them on an interval or a cron schedule, keeping the last good ones |
Bundles
A bundle is the compiled form of a ruleset: one JSON document holding the server and the client manifest. Compile once, for example in CI, and ship the bundle; a service that reads it runs no schema validation, inheritance or load-time check and needs neither the source documents nor a YAML parser.
// Where the rules are built.
Map<String, Object> bundle = RuleSet.load(document, registry, schemaLoader).bundle();
String text = Json.write(bundle); // or any JSON library
// Where they are enforced.
@SuppressWarnings("unchecked")
Map<String, Object> parsed = (Map<String, Object>) Json.parse(text);
RuleSet rules = RuleSet.fromBundle(parsed);fromBundle throws a LoadException with BUNDLE_UNSUPPORTED when the bundle format is not 1.x
and BUNDLE_INVALID when the server or the client manifest is missing or lacks id, version,
checksum, rules or its own name as channel. Beyond that a bundle is trusted, so load bundles only from a source you
control. A bundle contains the server manifest: never serve it to a browser. resolved() returns
null for a ruleset read from a bundle, because a bundle does not carry the resolved source.
One manifest
A browser or a mobile app receives one manifest, not the bundle. A JVM client can evaluate it too:
RuleSet rules = RuleSet.fromManifest(clientManifest); // Map<String, Object>, parsed from JSON
List<Channel> channels = rules.channels(); // [CLIENT]
EvaluationResult advice = rules.evaluate(request); // on the channel it hasfromManifest throws a LoadException with MANIFEST_INVALID unless the manifest has id,
version, checksum, rules and a channel of server or client. Such a ruleset has that one
channel: manifest(Channel) and evaluate(request, Channel) throw IllegalArgumentException for
the other one, and bundle() throws IllegalStateException. evaluate(request) without a channel
uses the server channel when the ruleset has one, otherwise the channel it has.
Custom operators
A ruleset may declare operators whose names start with x-. The host supplies them:
Map<String, CustomOperator> operators = Map.of(
"x-starts-with-zero", args -> args.get(0) instanceof String s && s.startsWith("0"));
RuleSet rules = RuleSet.load(document, registry, schemaLoader).withOperators(operators);
List<String> missing = rules.missingOperators();
if (!missing.isEmpty()) {
throw new IllegalStateException("custom operators not registered: " + missing);
}- The arguments arrive as plain values:
null,Boolean,String,BigDecimal(rounded to 15 significant digits),List<Object>orMap<String, Object>. - The result is any value of those types. Any
Numberis accepted and read as its nearest double. - An operator that is not registered, throws, or returns something that is not a JSON value (NaN
and the infinities included, also inside a list or a map) makes the rule fail closed: the rule
produces a blocking
RULE-EVALUATION-ERRORfinding. - An operator must be a pure function of its arguments and must be thread-safe.
missingOperators() lists the operators the rules need, on any channel the ruleset has, that were
not registered with withOperators; requiredOperators(channel) lists everything the rules of one
channel can reach. The runtime does not check them at load time; do it at start-up as shown above.
Places and data types
A rule's target may name a page, screen, section and component. A request can be narrowed to
one place, and a finding says where its rule belongs:
EvaluationResult result = rules.evaluate(
EvaluationRequest.builder("Customer", "update")
.data(payload)
.view(new View("onboarding", "profile", null, null)) // page, screen, section, component
.build(),
Channel.CLIENT);
for (EvaluationResult.Finding finding : result.findings()) {
View location = finding.location(); // null when the rule names no place
}Messages follow the request's locale. Catalogs are merged from the least to the most specific:
the default locale, then every prefix of the requested tag, so fr-CA reads the default catalog,
then fr, then fr-CA. Tags are compared exactly, including case.
A rule that names no place applies in every view. A rule that targets a data type runs once for
every field bound to the type in entities.<Name>.fieldTypes; the finding's fields() holds the
pointer of that field.
Numbers and strings
Numbers enter the engine as doubles (specification 4.2). Whatever the host passes in a ruleset, a
bundle or a request (Integer, Long, Double, BigDecimal, ...) stands for the IEEE 754 double
nearest to it, and the engine computes with the shortest decimal that identifies that double. So
0.1 is exactly one tenth whether Jackson delivered it as a Double or as a BigDecimal;
9007199254740993 is 9007199254740992; a BigDecimal with more digits than a double holds is
read as its nearest double. Keep the numbers in rulesets and requests within 15 significant digits
and nothing is lost. A number too large for a double is refused: Json.parse rejects it, as it
rejects NaN and Infinity, and RuleSet.load reports SCHEMA_INVALID.
Arithmetic and comparisons inside an expression are decimal128 and exact to 34 digits. Every number
that leaves the engine, computed or merely passed through, is a BigDecimal rounded half even to 15
significant digits, the most a JSON number carries through a double unchanged. A number whose
magnitude is then larger than the largest double is an evaluation error; a fraction too small for a
normal double leaves as the nearest double. Canonical JSON, and so every checksum, writes a number
as the shortest decimal of its double.
Strings are sequences of Unicode code points. matches accepts portable patterns only
(specification 4.4): a pattern outside that subset, or longer than 1000 code points, is an
evaluation error even when java.util.regex could run it. java.util.regex recurses once per
repetition of a group, so a subject of a few thousand characters can exhaust the stack of the
calling thread; the search is then repeated on a thread with a 256 MB stack, and the rule fails
closed only if that is not enough.
The engine protocol
The runtime can run as a program that any language drives over standard input and output (specification section 13): one JSON request per line in, one JSON response per line out, UTF-8.
java -cp rule-cascade-core-1.0.0-alpha.2.jar io.github.yarlisaisolutions.rulecascade.Main engineIt reports engine: "rule-cascade-java" and the levels evaluator and compiler, and implements
version, load, manifest, evaluate, expression and compile. load takes a bundle or one
manifest and answers with the channels the ruleset has and the custom operators it is missing;
manifest and evaluate take a loaded id, an inline bundle or an inline manifest, and answer
CHANNEL_UNAVAILABLE for a channel the ruleset does not have. Custom operators cannot cross
a process boundary: started this way the engine has none, and rules that use one fail closed. The
option --conformance-operators registers the three operators of conformance/README.md
(x-test-reverse, x-test-sum, x-luhn) so the conformance driver can certify the engine. To
build an engine with your own operators, call new Engine(operators).serve(reader, writer) from a
main of your own.
Refreshing the rules
RuleSetHolder loads a bundle again on a schedule and swaps it in atomically. When a load fails it
keeps the last good rules; a ruleset with the checksum already held is not swapped in. One daemon
thread runs the schedule.
RuleSetHolder rules = RuleSetHolder.builder(() -> RuleSet.fromBundle(readBundle()))
.every(Duration.ofMinutes(5)) // or .cron("0 * * * *", ZoneId.of("Europe/Paris"))
.onReload(reload -> { if (!reload.ok()) log.warn("rules not refreshed", reload.error()); })
.start(); // loads now; throws when that fails
EvaluationResult result = rules.get().evaluate(request);
rules.refresh(); // load now, outside the schedule
rules.close();CronSchedule.parse("*/5 * * * *").next(instant, zone) is the five-field cron syntax of
docs/caching.md, tested against the table the other runtimes use.
make bench-java measures evaluations per second (docs/performance.md).
Build and test
mvn verify # compiles with all warnings as errors and runs the conformance suite
make java # from the repository root: the same suite with javac only, no networkBoth run ConformanceRunner, which executes every file of conformance/ in process. The same
cases can be run over the engine protocol, from the repository root after make java:
PYTHONPATH=packages/python/src python3 tools/rulecheck.py conformance \
--engine "java -cp packages/java/target/offline io.github.yarlisaisolutions.rulecascade.Main engine --conformance-operators"examples/backend-spring-boot shows the runtime inside a Spring Boot service.
Structural validation
RuleSet.load validates documents against the JSON Schema of the specification. The core has no
schema library; SchemaValidator is a small interpreter for the keywords that schema uses, and it
reads the schema itself from the classpath:
src/main/resources/io/github/yarlisaisolutions/rulecascade/rule-cascade.schema.jsonThat file must stay byte-identical to spec/v1/rule-cascade.schema.json; ConformanceRunner fails
when it is not. Every pattern in the schema is evaluated as a portable pattern. If the schema
starts to use a keyword the interpreter does not implement, loading fails with an
IllegalStateException that names the keyword, instead of silently skipping a constraint.
@yarlisaisolutions/rule-cascade
The Rule Cascade runtime for browsers and Node.js. It implements both conformance levels of the specification: it evaluates bundles and manifests (evaluator) and it loads source documents and produces bundles (compiler).
Rule Cascade for Go
The Rule Cascade runtime for Go, in three forms built from the same code: