@rules-cascade/core
v1.0.0-alpha.11
Published
Rule Cascade core for browsers and Node.js: compile YAML/JSON business rule contracts to bundles and evaluate them identically to every other runtime.
Maintainers
Readme
@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 shared conformance suite checks on every build that it returns what every other runtime returns.
It is one of four packages released together:
| Package | What it holds |
|---|---|
| @rules-cascade/common | The rule, bundle and manifest types, the cron schedule, VERSION |
| @rules-cascade/core (this one) | The evaluator and the compiler |
| @rules-cascade/client | The manifest client and the React hook |
| @rules-cascade/server | The rule server |
| Entry point | Use it for | Extra dependency |
|---|---|---|
| @rules-cascade/core | Evaluate manifests and bundles, compile rulesets, read results, the Engine class. Re-exports everything in @rules-cascade/common. Browser-safe | none beyond decimal.js |
| @rules-cascade/core/loader | JSON Schema validation of source documents (Node) | ajv |
| 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 } 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 stringsIn 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 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.
Enforcing in one line
rules.enforce(request) returns an allowed result and throws RuleViolationError for a denied
one; error.problem() is the HTTP answer (RFC 9457 problem details, status 422, the same in every
Rule Cascade runtime). @rules-cascade/core/http wires it into Node.js frameworks, with no
framework as a dependency:
import { rulesMiddleware, rulesErrorHandler, rulesPreHandler } from '@rules-cascade/core/http';
app.post('/orders', rulesMiddleware(rules, { entity: 'Order', operation: 'create' }), handler); // Express, Connect
app.use(rulesErrorHandler()); // RuleViolationError -> 422, RequestError -> 400
fastify.post('/orders', { preHandler: rulesPreHandler(rules, { entity: 'Order', operation: 'create' }) }, handler);The request body is the entity by default; data, original, actor (default req.user),
resolutions and ctx (default { now }) take functions of the request. The allowed result is on
res.locals.ruleEvaluation (Fastify: request.ruleEvaluation). Walkthrough:
https://rulescascade.com/get-started/first-app/
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, BUNDLE_INVALID for one without a usable server and client manifest (each
with id, version, checksum, rules and its own name as channel), and then
VERSION_UNSUPPORTED for a manifest whose ruleCascade this runtime does not implement (an absent
one is 1.0.0; this runtime implements 1.0 and 1.1). 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, VERSION_UNSUPPORTED for a later ruleCascade
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.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]}}' \
| rule-cascade-node engine
# {"id":1,"ok":true,"result":{"engine":"rule-cascade-typescript","engineVersion":"1.0.0-alpha.11","ruleCascade":"1.1.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 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.
Caches
Evaluation keeps three caches. They never change a result.
- Rule selections, per ruleset and channel: the rules of an entity and an operation, sorted. Only
names the ruleset uses are keys; every entity or operation it does not know shares one entry, so
requests cannot fill the cache. At most
DEFAULT_SELECTION_CACHE_SIZE(256) entries. Each evaluation first checks the manifest's rules against what the selections were built from and starts again when a rule was added, removed or changed, so a manifest changed by the host evaluates as it is now. A manifest with a malformed rule (operations not a list of strings, an entity not a string, a priority not a number) is not cached at all. - Variable paths, split at their dots, for the whole process: at most
DEFAULT_PATH_CACHE_SIZE(4096), each at mostDEFAULT_MAX_CACHED_PATH_LENGTH(256) UTF-16 code units long; a longer path is split every time. When it is full, it starts again. - Compiled
matchespatterns, for the whole process: at mostDEFAULT_PATTERN_CACHE_SIZE(2048); when it is full, it starts again.
A size is a whole number; 0 turns that cache off. An explicit setting wins over the environment variable, which wins over the default:
| Cache | Option | Environment variable |
|---|---|---|
| Selections | RuleSet.fromBundle(bundle, { selectionCacheSize }), also fromManifest and loadRuleSet options | RULE_CASCADE_SELECTION_CACHE_SIZE |
| Paths | setPathCacheSize(n) | RULE_CASCADE_PATH_CACHE_SIZE |
| Longest path kept | setMaxCachedPathLength(n) | RULE_CASCADE_MAX_CACHED_PATH_LENGTH |
| Patterns | setPatternCacheSize(n) | RULE_CASCADE_PATTERN_CACHE_SIZE |
Any other value is a TypeError. For an option or a setter it shows the value
(setPathCacheSize is "-1": expected a whole number, 0 or more (0 turns the cache off)); for an
environment variable it never does (RULE_CASCADE_PATH_CACHE_SIZE is not valid: expected a whole number,
0 or more (0 turns the cache off)). A variable must be 1 to 19 ASCII digits: an empty value (VAR=)
is refused too, as in the other runtimes. Environment variables are read once, and only where there is
a process (not in a browser). Loading a ruleset (fromBundle, fromManifest, loadRuleSet) checks
all four, so a misconfigured process fails at start-up, never during an evaluation;
checkCacheSettings() runs the same check on its own, and the rule server runs it before it listens.
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.jsThe 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"