Rule Cascade
ReferencePackage READMEs

@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 pointUse it forExtra dependency
@yarlisaisolutions/rule-cascadeEvaluate manifests and bundles, compile rulesets, read results, the Engine class. Browser-safenone beyond decimal.js
@yarlisaisolutions/rule-cascade/reactuseRuleEvaluation hookreact
@yarlisaisolutions/rule-cascade/loaderJSON Schema validation of source documents (Node)ajv
@yarlisaisolutions/rule-cascade/cronThe five-field cron schedule the manifest client and the rule server use (parseCron, startCronTimer). Also exported from the main entrynone
rule-cascade-node engine (dist/cli.js)The engine protocol on standard input and outputajv 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).
  • get throws 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). get throws and drops the cached manifest, so a later outage does not bring it back.
  • onStale is called on every get that falls back. stale(rulesetId) describes the last get for that ruleset and is undefined once a later get succeeds.

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 strings

Caching 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 timers

Concurrent 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 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 '@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.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.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 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