Rule Cascade
ReferencePackage READMEs

@yarlisaisolutions/rule-cascade-server

A stateless HTTP service that implements spec/v1/rule-evaluation.openapi.yaml. Use it for callers that cannot embed a runtime. It keeps nothing between requests, so you run as many replicas as you need behind a load balancer.

A stateless HTTP service that implements spec/v1/rule-evaluation.openapi.yaml (spec/v1/rule-evaluation.openapi.yaml). Use it for callers that cannot embed a runtime. It keeps nothing between requests, so you run as many replicas as you need behind a load balancer.

npm ci && npm run build                                    # at the repository root
RULE_SERVER_TOKEN=$(openssl rand -hex 32) RULES_DIR=examples/contracts node packages/server/dist/main.js
VariableMeaningDefault
RULES_DIRDirectory with the rulesets to serve; see below./rules
PORTListen port8080
RULE_SERVER_TOKENBearer token for evaluations, the server manifest and bundlesrequired
RULE_SERVER_ALLOW_OPEN1 or true: start without a token and serve every endpoint openlyunset
DRAIN_SECONDSTime between "not ready" and shutdown on SIGTERM5
RULES_REFRESHmanual (SIGHUP only), interval or cron: also reload on a schedule when the files changedmanual
RULES_REFRESH_INTERVALWith interval: 30s, 5m, 1h or seconds; at least 1snone
RULES_REFRESH_CRONWith cron: minute hour day-of-month month day-of-weeknone
RULES_REFRESH_TZTime zone of the cron scheduleUTC
RULE_SERVER_WORKERSEvaluate on this many worker threads; 0 evaluates on the event loop0
RULE_EVALUATION_TIMEOUT_MSWith workers: answer 503 and replace the worker after this long5000

Scheduled refresh, the cache policy and the checksum URL are described in docs/caching.md; the worker pool and measurements in docs/performance.md.

The rules directory

The server loads two kinds of files from RULES_DIR:

FileWhat happens at start-up and on reload
*.ruleset.yaml, *.ruleset.yml, *.ruleset.jsonCompiled here: schema validation, inheritance and every load check. Entity schemas are read from the same directory
*.bundle.jsonRead as a precompiled bundle. Only the bundle format is checked; the compiler that produced it already ran the load checks

An optional cache-policy.yaml (or .yml, .json) sets the Cache-Control of client manifests per ruleset: revalidate (the default), ttl or permanent. It is checked with the rulesets; a bad one stops the start or refuses the reload. See docs/caching.md.

A ruleset id may be defined once, by one file. A source ruleset can only extend a parent that is also present as a source document, because a bundle does not hold what inheritance needs.

A bundle carries no title, scope or status. GET /rulesets therefore lists a ruleset that came from a bundle with its id, version and checksum only, and a ?scope= filter does not match it.

Endpoints

Method and pathPurposeNeeds the token
GET /healthzLivenessno
GET /readyzReadiness; 503 while drainingno
GET /rulesets?scope=List rulesets, optionally under a hierarchy pathno
GET /rulesets/{id}/manifest?channel=clientClient-safe manifest, with ETagno
GET /rulesets/{id}/manifest?channel=serverFull manifest for backend runtimesyes
GET /rulesets/{id}/bundleThe compiled bundle, for runtimes that evaluate in process; with ETagyes
GET /rulesets/{id}/rules/{ruleId}One resolved ruleserver-only rules: yes
POST /evaluationsEvaluate an operationyes

The endpoints marked "yes" answer 401 without Authorization: Bearer <token>. The bundle contains the server manifest, so it is protected exactly like it: the token is checked before the ruleset is looked up and before a conditional request is answered. The endpoints marked "no" are public on purpose: browsers fetch the client manifest directly, and load balancers probe the health endpoints.

The server fails closed. Without RULE_SERVER_TOKEN the process logs a fatal line and exits 1 before it loads any rules. To serve every endpoint without a token, which is only acceptable on a private network, set RULE_SERVER_ALLOW_OPEN=1; the server then logs a warn line at start-up and marks its listen line "auth":"open". An empty token counts as unset. Embedded, createRuleServer throws unless it is given token or allowOpen: true.

curl -s localhost:8080/evaluations -H "Authorization: Bearer $RULE_SERVER_TOKEN" -H 'Content-Type: application/json' -d '{
  "ruleset": "acme.payments.transfer", "entity": "Transfer", "operation": "create",
  "data": {"type": "international", "amount": 500, "currency": "USD", "memo": "gift",
           "beneficiary": {"name": "X", "country": "KP", "swiftCode": "ABCDKPPY"}}}'
# {"decision":"deny","findings":[{"code":"ORG-TRF-001","message":"Transfers to KP are not permitted.", ...}], ...}

The request body is the evaluation request of the specification plus ruleset, and optionally version and channel. The body is checked before the ruleset is looked up or anything is evaluated, with the shape check every runtime applies (specification section 8). A malformed body is answered with 400 and a problem document whose title says what is wrong, for example 'view' must be an object; an unknown ruleset or version is 404. Optional members may be null; a null channel is the default channel, server. A body with a number too large for a double (1e999) is refused with 400 as well. A body larger than 1 MiB (maxBodyBytes when embedded) is 413, and a path with malformed percent-encoding is 400.

trigger, view, resolutions and locale are passed to the runtime as given. A view narrows the evaluation to one place in the user interface, and a finding reports the place its rule names as location:

curl -s localhost:8080/evaluations -H "Authorization: Bearer $RULE_SERVER_TOKEN" -H 'Content-Type: application/json' -d '{
  "ruleset": "acme.payments.transfer", "entity": "Transfer", "operation": "create",
  "view": {"component": "beneficiary-panel"},
  "data": {"type": "international", "amount": 500, "currency": "USD", "memo": "gift",
           "beneficiary": {"name": "X", "country": "ES"}}}'
# {"decision":"deny","findings":[{"code":"PAY-TRF-002", ..., "location":{"component":"beneficiary-panel"}}],"effects":[],"commands":[]}

Without the view the same request also returns the fee that a rule of the amount-panel computes.

A runtime without a compiler downloads the bundle and evaluates it in process:

curl -s -H "Authorization: Bearer $RULE_SERVER_TOKEN" localhost:8080/rulesets/acme.payments.transfer/bundle -o transfer.bundle.json
# ETag: "sha256:c192...2c7e-bundle"; send it back as If-None-Match and the answer is 304 until the ruleset changes

Custom operators

A ruleset may use custom operators (x-*), which are functions of the host application. The rule-cascade-server command registers none: it serves such a ruleset, logs a warning that names the missing operators at start-up and after every reload, and the rules that need them fail closed (the evaluation denies with a RULE-EVALUATION-ERROR finding).

To supply operators, embed the server in a program of your own:

import { createRuleServer, RuleStore } from '@yarlisaisolutions/rule-cascade-server';

const store = new RuleStore('./rules');
const { server, missingOperators } = createRuleServer(store, {
  token: process.env.RULE_SERVER_TOKEN,   // required; createRuleServer throws without it unless allowOpen: true
  operators: { 'x-luhn': (text) => typeof text === 'string' && luhn(text) },
});
missingOperators();   // {} when everything is registered, otherwise { '<ruleset id>': ['x-...'] }
server.listen(8080);

An operator receives plain JSON values and returns a JSON value. It must be synchronous and pure. Every runtime that evaluates the ruleset needs the same operators: a browser that evaluates the client manifest registers them too.

Behaviour that matters in production

  • Fails fast. If any ruleset fails a load check, or any bundle is unusable, the process exits before it listens.
  • Fails closed. Without RULE_SERVER_TOKEN (or the explicit RULE_SERVER_ALLOW_OPEN=1) the process exits 1.
  • Scheduled refresh. With RULES_REFRESH=interval or cron the server also checks the directory on a schedule and reloads, with the same safety, only when a file changed. Reloads and refusals are logged with their trigger; unchanged checks are not.
  • Safe reload. SIGHUP reloads the directory. If the new rules fail to load, the server logs reload refused, still serving the previous rules with the reason and keeps serving the previous ones; /readyz keeps reporting the previous loadedAt.
  • Graceful shutdown. On SIGTERM (or SIGINT) readiness turns 503 at once; after DRAIN_SECONDS the server stops accepting connections, in-flight requests finish, and the process exits 0.
  • Cacheable manifests and bundles. The ETag is the ruleset checksum plus the channel, or plus bundle; revalidation returns 304. If-None-Match may list several entity tags, use weak validators (W/"..."), or be *. Client manifests follow the cache policy; server manifests and bundles are always private, no-cache. By default the client manifest is sent with Cache-Control: public, max-age=0, must-revalidate, so every use is revalidated. A browser that uses createManifestClient of the runtime keeps evaluating its last manifest while this endpoint is unreachable; the evaluation on the server remains the decision.
  • Checksum URLs. GET /rulesets/{id}/manifest?channel=client&checksum=sha256:... answers the manifest only when that is the checksum being served (in permanent mode with Cache-Control: public, max-age=31536000, immutable), and 409 with Cache-Control: no-store and the served checksum in the problem body otherwise. This parameter is an extension of this server; it is not in the OpenAPI description.
  • Worker threads. With RULE_SERVER_WORKERS=N evaluations run on N worker threads with the same results; one that outlives RULE_EVALUATION_TIMEOUT_MS is answered 503 and its worker replaced. Embedded, pass workers, evaluationTimeoutMs and, for custom operators, operatorsModule (an ES module exporting them) to createRuleServer, and call terminateWorkers() on shutdown.
  • Structured logs. One JSON line per request; health probes are not logged.

Container and Kubernetes files are in Dockerfile (packages/server/Dockerfile) and deploy/kubernetes.

On this page