@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).
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.
| Entry point | Use it for | Extra dependency |
|---|---|---|
@yarlisaisolutions/rule-cascade | Evaluate manifests and bundles, compile rulesets, read results, the Engine class. Browser-safe | none beyond decimal.js |
@yarlisaisolutions/rule-cascade/react | useRuleEvaluation hook | react |
@yarlisaisolutions/rule-cascade/loader | JSON Schema validation of source documents (Node) | ajv |
@yarlisaisolutions/rule-cascade/cron | The five-field cron schedule the manifest client and the rule server use (parseCron, startCronTimer). Also exported from the main entry | none |
rule-cascade-node engine (dist/cli.js) | The engine protocol on standard input and output | ajv for the compiler level |
In a browser
import { createManifestClient, evaluate, fieldStates, findingsFor } from '@yarlisaisolutions/rule-cascade';
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.)
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'].
Through an outage of the manifest endpoint
createManifestClient keeps the last manifest it fetched for each ruleset. When the server cannot
be reached and a manifest is cached, get returns the cached one instead of throwing, so the form
keeps giving feedback; the server still decides when the operation is submitted.
const manifests = createManifestClient({
baseUrl: '/api/rules',
onStale: ({ rulesetId, error, fetchedAt }) => console.warn(`${rulesetId}: using the rules fetched at ${new Date(fetchedAt).toISOString()}: ${error.message}`),
});
const manifest = await manifests.get('acme.payments.transfer'); // fresh, revalidated (304), or the cached one during an outage
manifests.stale('acme.payments.transfer'); // undefined, or { rulesetId, manifest, error, fetchedAt } when the last get fell back- "Cannot be reached" means: the request failed, or the answer was 408, 429, a 5xx status, or not a client manifest (for example the error page of a proxy).
getthrows when nothing is cached for that ruleset. The cache lives in memory, for the lifetime of the client object: it does not survive a page reload.- Any other 4xx answer is a refusal by the server (the ruleset is gone, or the caller may not read
it).
getthrows and drops the cached manifest, so a later outage does not bring it back. onStaleis called on everygetthat falls back.stale(rulesetId)describes the lastgetfor that ruleset and isundefinedonce a latergetsucceeds.
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 '@yarlisaisolutions/rule-cascade';
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 stringsCaching manifests
createManifestClient revalidates on every get by default. Two other modes trade freshness for
fewer requests, and every mode can keep its cache in localStorage and refresh in the background:
import { createManifestClient, localStorageAdapter } from '@yarlisaisolutions/rule-cascade';
const manifests = createManifestClient({
baseUrl: '/api/rules',
mode: 'ttl', // 'revalidate' (default) | 'ttl' | 'permanent'
ttlMs: 5 * 60_000, // no request while the cached copy is younger; refreshed in the background
refreshCron: '*/15 * * * *', // optional, any mode
storage: localStorageAdapter(), // survives a page reload; does nothing where storage is unavailable
onUpdate: (rulesetId, manifest) => console.info(`${rulesetId} is now ${manifest.checksum}`),
});
await manifests.get('acme.payments.transfer', { checksum }); // permanent: fetch by checksum URL
manifests.close(); // stop the timersConcurrent get calls for one ruleset share a request. staleIfError: false turns off the
fallback to the cached copy. The modes, the server's matching cache headers and CDN guidance are in
docs/caching.md.
In React
import { useRuleEvaluation } from '@yarlisaisolutions/rule-cascade/react';
const { allowed, states, computed, findingsFor } = useRuleEvaluation(manifest, {
entity: 'Transfer', operation: 'create', data: form, actor, resolutions,
});A third argument takes the custom operators the manifest needs. Pass the same object on every
render, for example a module-level constant. examples/frontend-react is a complete form built this way.
In a Node backend
import { readFileSync } from 'node:fs';
import { parse } from 'yaml';
import { loadRuleSet, LoadError } from '@yarlisaisolutions/rule-cascade';
import { createSchemaValidator } from '@yarlisaisolutions/rule-cascade/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 ETagLoading runs every check of specification section 5, with two things to know:
- Without
validateSchemathe document is not validated against the JSON Schema. Omit it only for documents that were validated elsewhere. The validator treats everypatternof the schema as a portable pattern:$is the very end of the string and\dis an ASCII digit. - Without
schemaLoaderthe entity schemas are not read, soPATH_UNKNOWNandSCHEMA_REF_UNRESOLVEDare 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 '@yarlisaisolutions/rule-cascade';
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 '@yarlisaisolutions/rule-cascade';
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 '@yarlisaisolutions/rule-cascade';
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. 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 '@yarlisaisolutions/rule-cascade';
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.123456789012346The 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:
9007199254740993is9007199254740992. 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 thatJSON.parseproduces 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.2","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 '@yarlisaisolutions/rule-cascade';
import { createSchemaValidator } from '@yarlisaisolutions/rule-cascade/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 endsAn 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"Performance
How fast each runtime evaluates, how the rule server behaves under load, and how to measure it on your own hardware.
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.