Rule Cascade
ReferencePackage READMEs

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.

ClassRole
RuleSetCompile a ruleset (load), read a bundle (fromBundle) or one manifest (fromManifest); evaluate, manifest(Channel), channels(), bundle(), checksum(), withOperators, missingOperators(), requiredOperators(Channel)
EvaluationRequest, Resolution, ViewThe operation to check, the user's acknowledgements, and the place in the UI to evaluate
EvaluationResultDecision, findings, effects, commands; toMap() for JSON
CustomOperatorAn x-* operator supplied by the host
EvaluatorEvaluate a manifest received from elsewhere
ExpressionsEvaluate a single expression
LoadException, Problem, LoadErrorCodeWhy a ruleset or a bundle was refused
SchemaLoaderSupplies entity schema documents so data paths can be checked at load
JsonSmall JSON reader and writer, and the canonical writer used for checksums
Engine, MainThe engine protocol: this runtime as a program
RuleSetHolder, CronScheduleHold 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 has

fromManifest 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> or Map<String, Object>.
  • The result is any value of those types. Any Number is 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-ERROR finding.
  • 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 engine

It 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 network

Both 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.json

That 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.

On this page