Rule Cascade
ReferencePackage READMEs

@rules-cascade/core

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

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). The shared conformance suite checks on every build that it returns what every other runtime returns.

It is one of four packages released together:

PackageWhat it holds
@rules-cascade/commonThe rule, bundle and manifest types, the cron schedule, VERSION
@rules-cascade/core (this one)The evaluator and the compiler
@rules-cascade/clientThe manifest client and the React hook
@rules-cascade/serverThe rule server
Entry pointUse it forExtra dependency
@rules-cascade/coreEvaluate manifests and bundles, compile rulesets, read results, the Engine class. Re-exports everything in @rules-cascade/common. Browser-safenone beyond decimal.js
@rules-cascade/core/loaderJSON Schema validation of source documents (Node)ajv
rule-cascade-node engine (dist/cli.js)The engine protocol on standard input and outputajv for the compiler level

In a browser

import { createManifestClient } from '@rules-cascade/client';
import { evaluate, fieldStates, findingsFor } from '@rules-cascade/core';

const manifests = createManifestClient({ baseUrl: '/api/rules' });
const manifest = await manifests.get('acme.payments.transfer');   // cached; revalidated with the ETag

const request = {
  entity: 'Transfer',
  operation: 'create',
  data: { type: 'international', amount: 12000, currency: 'USD', beneficiary: { name: 'Ana', country: 'ES' } },
  actor: { id: 'u-1', roles: ['teller'] },
};
const result = evaluate(manifest, request);

result.decision;                               // 'deny'
findingsFor(result, '/beneficiary/swiftCode'); // [{ code: 'PAY-TRF-002', severity: 'error', location: { component: 'beneficiary-panel' }, ... }]
fieldStates(result)['/beneficiary/swiftCode']; // { visible: true, required: true }

A request can narrow the evaluation in two ways:

  • trigger: 'change' or 'blur' evaluates only the rules that listen to that moment. With no trigger every client rule runs, which is what you want just before submit.
  • view: { page, screen, section, component } evaluates only the rules of that place. A rule that names a different place is skipped; a rule that names no place always applies.
evaluate(manifest, { ...request, view: { component: 'amount-panel' } }).findings.map((f) => f.code);  // ['PAY-TRF-003']

finding.location holds the page, screen, section and component the rule's target names. It is absent when the target names none. (It replaces the finding.component member of earlier versions.)

Fetching, caching and refreshing manifests, and the React hook, are in @rules-cascade/client.

locale selects the message catalog. Catalogs are merged least specific first: the default locale, then every prefix of the requested tag, so fr-CA reads the default catalog, then fr, then fr-CA, and a regional catalog only holds the messages that differ. Tags are compared exactly, including case. localeChain('en', 'fr-CA') returns ['en', 'fr', 'fr-CA'].

evaluate checks the shape of the request before it evaluates anything (specification section 8): entity and operation are strings; data, original, actor, ctx and view are objects; resolutions is a list; trigger and locale are strings. Optional members may be null, and members that are not part of a request are ignored. A request with another shape is never half-evaluated: evaluate throws a RequestError that says what is wrong.

import { requestProblem, RequestError } from '@rules-cascade/core';

requestProblem({ entity: 'Transfer', operation: 'create', data: [] });   // "'data' must be an object"
requestProblem({ entity: 'Transfer', operation: 'create', data: null }); // undefined: the request is well formed
evaluate(manifest, { entity: 'Transfer', operation: 'create', actor: { roles: 'teller' } });
// throws RequestError: 'actor.roles' must be a list of strings

In a Node backend

import { readFileSync } from 'node:fs';
import { parse } from 'yaml';
import { loadRuleSet, LoadError } from '@rules-cascade/core';
import { createSchemaValidator } from '@rules-cascade/core/loader';

const read = (file: string) => parse(readFileSync(`contracts/${file}`, 'utf8'));
const registry = {
  'acme.org.base': read('acme-org-base.ruleset.yaml'),
  'acme.payments.transfer': read('payments-transfer.ruleset.yaml'),
};

// Throws LoadError (with stable codes) for anything that must not be served.
const rules = loadRuleSet(registry['acme.payments.transfer'], registry, {
  validateSchema: createSchemaValidator(),
  schemaLoader: (file) => read(file.replace(/^\.\//, '')),
});

const result = rules.evaluate({ entity: 'Transfer', operation: 'create', data, actor });  // server channel
const forBrowsers = rules.manifest('client');   // serve this, with rules.checksum as the ETag

Loading runs every check of specification section 5, with two things to know:

  • Without validateSchema the document is not validated against the JSON Schema. Omit it only for documents that were validated elsewhere. The validator treats every pattern of the schema as a portable pattern: $ is the very end of the string and \d is an ASCII digit.
  • Without schemaLoader the entity schemas are not read, so PATH_UNKNOWN and SCHEMA_REF_UNRESOLVED are never reported.

Bundles

A bundle is the compiled form of a ruleset: plain JSON holding the server and the client manifest. Compile once, in CI, and evaluate the same bundle in every runtime.

import { RuleSet } from '@rules-cascade/core';

const bundle = rules.bundle();    // { ruleCascadeBundle: '1.0.0', id, version, checksum, manifests: { server, client } }
writeFileSync('acme.payments.transfer.bundle.json', JSON.stringify(bundle));

// In the evaluating process: no YAML, no schema validation, no inheritance.
const loaded = RuleSet.fromBundle(JSON.parse(readFileSync('acme.payments.transfer.bundle.json', 'utf8')));
loaded.evaluate({ entity: 'Transfer', operation: 'create', data, actor });

RuleSet.fromBundle throws a LoadError with code BUNDLE_UNSUPPORTED for a bundle whose format is not version 1.x, and BUNDLE_INVALID for one without a usable server and client manifest (each with id, version, checksum, rules and its own name as channel). It runs no other check: the compiler already did. loaded.resolved is undefined, because a bundle holds manifests, not the resolved ruleset. The bundle contains server-only rules, so it is never sent to a browser; browsers get manifest('client').

One manifest on its own

A browser or a mobile app receives one manifest, not the bundle. RuleSet.fromManifest reads it:

import { ChannelError, RuleSet } from '@rules-cascade/core';

const rules = RuleSet.fromManifest(clientManifest);   // throws LoadError MANIFEST_INVALID for anything else
rules.channels;                                       // ['client']
rules.evaluate(request).decision;                     // 'deny': no channel given, so the one it has
rules.evaluate(request, 'server');                    // throws ChannelError: this ruleset has no server manifest
RuleSet.fromBundle(bundle).channels;                  // ['client', 'server']

A usable manifest has a string id, version and checksum, a list rules and a channel of server or client. A ruleset read this way has that one channel: a client manifest cannot be evaluated as the server. manifest(channel), evaluate(request, channel) and bundle() throw a ChannelError for a channel the ruleset does not have. Without a channel argument manifest() and evaluate(request) use server when the ruleset has it, and otherwise the channel it has.

Custom operators

A ruleset declares the custom operators it uses under operators, and its rules call them as { op: 'x-<name>', args: [...] }. The host supplies the implementation as a plain function, by name:

import { missingOperators, RuleSet, type Operators } from '@rules-cascade/core';

const operators: Operators = {
  'x-luhn': (text) => {
    if (typeof text !== 'string' || !/^[0-9]{2,}$/.test(text)) return false;
    const digits = [...text].reverse().map(Number);
    const sum = digits.reduce((total, d, i) => total + (i % 2 === 0 ? d : d * 2 > 9 ? d * 2 - 9 : d * 2), 0);
    return sum % 10 === 0;
  },
};

const customer = RuleSet.fromBundle(customerBundle);
customer.manifest('server').operators;                          // ['x-luhn']: what this manifest needs
missingOperators(customer.manifest('server'), operators);       // []: check this when the host starts
customer.missingOperators();                                    // ['x-luhn']: for every channel the ruleset has, with nothing registered

const request = { entity: 'Customer', operation: 'update', data: { loyaltyNumber: '79927398710' }, original: {}, view: { section: 'membership' } };
customer.evaluate(request, 'server', operators).findings[0];    // { code: 'ONB-CUS-001', message: 'This loyalty number is not valid. Check the digits.', ... }
customer.evaluate(request, 'server').findings[0];               // { code: 'RULE-EVALUATION-ERROR', blocking: true, detail: 'custom operator x-luhn is not registered', ... }

The same object is the third argument of evaluate(manifest, request, operators) and of useRuleEvaluation (@rules-cascade/client/react). An operator receives plain JSON values, with computed numbers already rounded to 15 significant digits, and returns a JSON value. It must be synchronous and pure. A rule whose operator is missing, throws, or returns something JSON cannot carry (NaN, an infinity, a promise, a Date) fails closed with a RULE-EVALUATION-ERROR finding.

Expressions

import { evaluateExpression } from '@rules-cascade/core';

evaluateExpression({ op: 'add', args: [0.1, 0.2] });   // 0.3
evaluateExpression(
  { fn: 'vat', args: [{ var: 'data.net' }] },
  { data: { net: 19.99 } },
  { functions: { vat: { params: ['amount'], body: { op: 'round', args: [{ op: 'mul', args: [{ var: 'arg.amount' }, 0.21] }, 2] } } } },
);                                                     // 4.2
evaluateExpression({ op: 'matches', args: ['a b', '\\s'] });   // throws EvalError: pattern "\\s": escape \s is not portable
evaluateExpression({ var: 'data.rate' }, { data: { rate: 0.1234567890123456 } });   // 0.123456789012346

The third argument takes functions (the function table) and operators. patternProblem(pattern) returns why a pattern is outside the portable subset, or undefined when it is inside. A pattern has at most 1000 code points.

An expression is a literal or an object with exactly the members { var }, { op, args } or { fn, args }. Any other object, and a list, is an evaluation error when it is evaluated. A variable path starts at one of the nine roots of the specification; any other root is null.

Numbers:

  • Inside an expression arithmetic is decimal with 34 significant digits; nothing is rounded there.
  • Every number that leaves the engine is rounded half even to 15 significant digits, whether it was computed or only passed through: the result of an expression, computed values, message arguments, command payloads, the arguments of a custom operator.
  • A number whose magnitude is then larger than the largest double is an evaluation error, so the rule fails closed.
  • A JSON number is the double nearest to what was written, in every runtime: 9007199254740993 is 9007199254740992. The specification asks authors to stay within 15 significant digits and to send identifiers as strings. jsonFinite(value) tells whether a parsed value is free of the infinities that JSON.parse produces for a number too large for a double; the engine and the rule server refuse such input.

Engine protocol

dist/cli.js serves the JSON Lines protocol of specification section 13: one request per line on standard input, one response per line on standard output, in order. Installed, the command is rule-cascade-node.

printf '%s\n' '{"id":1,"command":"version"}' '{"id":2,"command":"expression","expr":{"op":"add","args":[0.1,0.2]}}' \
  | node packages/typescript/dist/cli.js engine
# {"id":1,"ok":true,"result":{"engine":"rule-cascade-typescript","engineVersion":"1.0.0-alpha.5","ruleCascade":"1.0.0","bundle":"1.0.0","levels":["evaluator","compiler"],"operators":[]}}
# {"id":2,"ok":true,"result":0.3}

--conformance-operators registers the three operators of the conformance suite (x-test-reverse, x-test-sum, x-luhn); without the flag no custom operator is registered. compile needs ajv. When ajv is not installed the command still runs, reports the level evaluator only and answers compile with UNSUPPORTED.

To embed the protocol, or to give it your own operators, use the class the command is built on:

import { Engine, serve } from '@rules-cascade/core';
import { createSchemaValidator } from '@rules-cascade/core/loader';

const engine = new Engine({ operators, validateSchema: createSchemaValidator() });
engine.handle({ id: 1, command: 'load', bundle });   // { id: 1, ok: true, result: { ruleset, version, checksum, channels, missingOperators } }
engine.handleLine('nonsense');                        // { ok: false, error: { code: 'BAD_REQUEST', message: 'not JSON: ...' } }

await serve(process.stdin, (line) => void process.stdout.write(line), engine);   // answer until the input ends

An Engine without validateSchema is an evaluator.

The source of a manifest or evaluate request is a ruleset id that was loaded, an inline bundle or an inline manifest; a bundle wins over a manifest, and a manifest over a ruleset id. load takes a bundle or a manifest, replaces any ruleset held under the same id, and reports the channels the ruleset has and the missingOperators the engine does not provide. Without a channel a request uses server when the ruleset has it, and otherwise the channel it has; asking for a channel the ruleset lacks is answered with CHANNEL_UNAVAILABLE:

engine.handle({ command: 'load', manifest: clientManifest });   // { ok: true, result: { ..., channels: ['client'], missingOperators: [] } }
engine.handle({ command: 'evaluate', ruleset: 'acme.payments.transfer', channel: 'server', request });
// { ok: false, error: { code: 'CHANNEL_UNAVAILABLE', message: 'ruleset acme.payments.transfer was loaded without a server manifest' } }

The members of a request are checked before a ruleset is looked up or a bundle is read, so a malformed request is always BAD_REQUEST: a bundle, manifest, env, functions, registry or schemaDocuments member that is present must be an object (the last four may be null), a channel must be server, client or null, and the request of evaluate must have the shape requestProblem checks. A line that holds NaN, Infinity or a number too large for a double (1e999) is not JSON and is answered with BAD_REQUEST.

Scripts

npm run typecheck
npm test          # the shared conformance suite in process, and the tests of this package
npm run build     # dist/, including dist/cli.js

# the same suite over the engine protocol, from the repository root
python tools/rulecheck.py conformance --engine "node packages/typescript/dist/cli.js engine --conformance-operators"

On this page