Rule Cascade
Reference

JSON Logic and Rule Cascade

JSON Logic is the design Rule Cascade expressions started from: logic written as JSON data, read with var, evaluated by a small interpreter that exists in many languages.

JSON Logic is the design Rule Cascade expressions started from: logic written as JSON data, read with var, evaluated by a small interpreter that exists in many languages. This page explains why Rule Cascade nevertheless has its own expression language, and documents the converter that moves expressions between the two.

Why Rule Cascade does not use JSON Logic

The decision is recorded in ADR 0001. The reasons, with what each one costs:

JSON LogicRule Cascade
Shape{"<=": [a, b]}: the operator is the key{"op": "lte", "args": [a, b]}: the operator is a value
TypesLoose. Any value is a condition; ==, <, + convert their operandsStrict. No conversion; a wrong type is an evaluation error and the rule fails closed (specification 4.1)
ArithmeticBinary floating pointDecimal, 34 digits (specification 4.2)
PatternsNo operator; implementations add one with the regular-expression engine at handmatches with one portable syntax and one meaning (specification 4.4)
FunctionsNoneNamed, reusable expressions (specification 4.5)
DatesNonedaysBetween, yearsBetween on RFC 3339 dates
DataOne objectNamed roots: data, original, actor, ctx, params, item
Defined byA short description and the JavaScript implementationA specification and a conformance suite every runtime runs

Strict typing. A business rule decides whether an operation is allowed. In JSON Logic {"<": [{"var": "amount"}, 100]} is true when amount is missing, because null < 100 is true in JavaScript, and "17" < 18 is true as well. That is convenient for form data and risky for a decision. Rule Cascade refuses to guess: the same comparison raises an evaluation error, the rule produces a blocking finding, and the author sees it in the golden tests.

One meaning in every language. Several JSON Logic operators mean what JavaScript does: == is JavaScript's loose equality, + applies parseFloat, substr counts UTF-16 code units, and a path segment length returns the length of a list or string. A port to Java, Go or Python has to imitate JavaScript to agree, and ports differ at these edges. Rule Cascade needs a browser and a server to agree on every input, so its semantics are written down operator by operator and checked by the conformance suite.

Decimal arithmetic. Rules compare money. 0.1 + 0.2 <= 0.3 is false in binary floating point and true in decimal.

The operator as a value. With {op, args} the operator list is a JSON Schema enum, so a ruleset can be validated before it is loaded, walked for static checks (paths against the API schema, parameters, scopes), mapped to sealed types in typed languages, and produced by a language model under a strict output schema. With the operator in the key each of these needs custom code.

What surrounds the expression. JSON Logic is an expression format. A rule needs more than the expression: an id, a severity, a finding code and message, the field it points at, when it applies, whether a warning must be acknowledged or an error may be accepted with a justification, who owns it in the hierarchy and what a child may override. Rule Cascade defines those, and the expression is the small part.

What JSON Logic has that Rule Cascade does not: brevity, a large number of existing ports and editors, and tolerance for untyped input. When all that is needed is one condition shared between a JavaScript front end and a back end, JSON Logic is a reasonable choice. The converter exists so that choosing one does not lock out the other.

The converter

tools/jsonlogic.py converts in both directions. From the command line:

python tools/rulecheck.py jsonlogic import rule.json            # JSON Logic  -> Rule Cascade
python tools/rulecheck.py jsonlogic export expression.json      # Rule Cascade -> JSON Logic
cat rule.json | python tools/rulecheck.py jsonlogic import -    # - reads standard input

The converted JSON goes to standard output and warnings go to standard error. The exit status is 0 when the input was converted, 1 when it was refused (nothing is printed on standard output) and 2 when the input could not be read.

OptionDirectionMeaning
--root PATHbothThe Rule Cascade path that is JSON Logic's data object. Default data: {"var": "a.b"} is {"var": "data.a.b"}. Use --root original, --root item, --root data.customer
--keep-rootsbothJSON Logic's data object is the whole environment {"data": ..., "ctx": ..., "params": ...}: paths are kept as written
--expand-truthinessimportWrite truthiness tests out exactly instead of requiring booleans (see below)

From Python:

from jsonlogic import to_rule_cascade, to_json_logic, ConversionError

expression, warnings = to_rule_cascade({"<": [{"var": "age"}, 18]}, root="data", truthiness="strict")
logic, warnings = to_json_logic(expression, root="data")      # root=None keeps the root names

Every construct is in one of three classes:

ClassMeaningWhat the converter does
EquivalentThe same result for every inputConverts silently
CaveatThe same result for well-typed input; different for some other inputConverts and returns a warning that names the place and the difference
RefusedNo counterpart with the same meaningRaises ConversionError, which lists every refused construct with its path

The converter never returns an expression that behaves differently without a warning. "JSON Logic" means the behaviour of json-logic-js 2.0.5, the reference implementation. tools/tests/test_jsonlogic.py evaluates every mapping with the Rule Cascade reference implementation and compares it with values json-logic-js produced for the same data (tools/tests/fixtures/jsonlogic_expected.json); for each caveat it also holds an input on which the two differ.

Whether a construct is equivalent often depends on what the converter can see. {"<": [1, 2]} compares two numbers and is equivalent; {"<": [{"var": "a"}, 2]} compares a number with a value of unknown type and carries the caveat. The tables say "known" for a type that can be read off the expression: a literal, or the result of an operator that always returns that type.

JSON Logic to Rule Cascade

JSON LogicRule CascadeClass
{"var": "a.b"}{var: data.a.b}Equivalent. Caveat length-segment when a segment is length, leading-zero-index when one is like 01
{"var": ""}{var: data}; inside all, some, none, map, filter: {var: item}Equivalent
{"var": "x"} inside all, some, none, map, filter{var: item.x}Equivalent
{"var": ["a", d]}coalesce(a, d)Caveat var-default. A null default is dropped
===, !==eq, neEquivalent
==, !=eq, neEquivalent when one operand is the literal null or both have the same known type; otherwise caveat loose-equality
{"!": a}not(a)Equivalent when a is a known boolean; otherwise caveat truthiness
{"!!": a}a when it is a known boolean, otherwise and(a)As above. and with one argument returns a boolean unchanged and fails on anything else
and, orand, orEquivalent when the operands are known booleans; otherwise caveat truthiness
{"if": [c, a, b]}, ?:if(c, a, b)Condition as for !
{"if": [c1, a1, c2, a2, ..., e]}if(c1, a1, if(c2, a2, ... e))A missing final else is null, as in JSON Logic
<, <=, >, >=lt, lte, gt, gteEquivalent for known numbers; otherwise caveat numeric-coercion
{"<": [a, b, c]}and(lt(a, b), lt(b, c))As above
{"<=": [a, b, c]}between(b, a, c)As above
+, * with two or more argumentsnested add, mulCaveat binary-float, and numeric-coercion unless the operands are known numbers
{"+": []}0Equivalent
{"-": [a, b]}, {"-": [a]}sub(a, b), sub(0, a)As +
/, %div, modAs +, and caveat division-by-zero unless the divisor is a literal other than 0
min, maxmin, maxEquivalent for known numbers; otherwise caveat numeric-coercion
{"in": [a, [x, y]]} (second argument a known list)in(a, list(x, y))Equivalent
{"in": [a, "text"]} (second argument a known string)contains("text", a)Equivalent for a known string a; otherwise caveat string-required
catconcat; arguments that are not known strings are wrapped in textEquivalent for known strings; otherwise caveat text-rendering
{"substr": [s, start]}, {"substr": [s, start, length]} with literal, non-negative whole numberssubstring(s, start, length)Caveat utf16, and string-required unless s is a known string
{"all": [list, p]}and(not(empty(list)), all(list, p))JSON Logic's all of an empty list is false. Caveat not-a-list unless the list is known; predicate as for !
some, none, filtersome, none, filterCaveat not-a-list unless the list is known; predicate as for !
mapmapCaveat not-a-list unless the list is known
{"reduce": [list, {"+": [term, {"var": "accumulator"}]}, n]}sum(list, term), plus add(n, ...) when n is not 0{"var": "current.x"} is {var: item.x}. Caveats as for + and map
missing, missing_some, or a merge of them, used as a conditionor of in(<var>, list(null, "")); for missing_some a count of the properties that are presentEquivalent. See below
merge of literal lists and scalarslist(...)Equivalent
[a, b] (a list literal)list(a, b)Equivalent

missing returns the names of the missing properties, which no Rule Cascade expression can produce. Where JSON Logic only tests whether that list is empty, the meaning is "is something missing", and that converts exactly: under ! or !!, as the condition of if, as the predicate of filter, all, some or none, and as an operand of and or or in one of those places.

Truthiness

JSON Logic treats null, false, 0, "" and [] as false and everything else as true. By default the converter maps a condition to the strict operator and warns when it cannot see that the condition is a boolean: {"!": {"var": "a"}} becomes not(data.a), which is the same for true and false and an evaluation error for anything else.

With --expand-truthiness (truthiness="expand") the test is written out, which is exact for every value and needs no warning:

JSON LogicRule Cascade
{"!!": a}not(in(a, list(null, false, 0, "", list())))
{"!": a}in(a, list(null, false, 0, "", list()))
{"if": [a, x, y]}if(not(in(a, ...)), x, y)

Use it when the JSON Logic rules being migrated test strings, numbers or lists for presence. and and or return one of their operands in JSON Logic, so expansion applies to them only where the result is itself used as a condition; elsewhere the default mapping and its warning remain.

Refused

JSON LogicWhy
An operator that is not in the table (custom operations, log)No counterpart. log has a side effect; evaluation is a pure function
An object that is not an operation ({}, or more than one member)Rule Cascade has no object literal
var with a computed path, or a segment outside letters, digits, _ and -A Rule Cascade path is a literal with that syntax
==, != between two different known typesJSON Logic converts (1 == "1"); eq is never true across types
==, ===, !=, !== when an operand is a known listJSON Logic compares lists by identity, so the comparison is constant there; eq compares the elements
A known number, string or list as a condition (unless truthiness is expanded)Always an evaluation error in Rule Cascade
and, or with a known non-boolean operand, such as {"or": [{"var": "nickname"}, "anonymous"]}It returns the operand. Use {"var": ["nickname", "anonymous"]}
and, or without argumentsNo defined value in JSON Logic
A known string, boolean, null or list where a number is neededJSON Logic converts it or compares strings alphabetically; Rule Cascade compares numbers only. For dates use daysBetween
>, >= with three argumentsjson-logic-js ignores the third
+ or * with one argumentA conversion to a number, which Rule Cascade does not have
in when the second argument is neither a known list nor a known stringIt means membership or substring depending on the run-time type
substr with a negative or computed start or lengthNegative values count from the end
reduce other than the sum patternNo general fold
missing, missing_some where the list of names is used as a value, or with computed namesNo expression returns that list. Write one required rule per field
merge with an argument that is neither a literal list nor a known scalarNo operator joins lists
Any operator with a number of arguments outside the forms abovejson-logic-js ignores surplus arguments and reads missing ones as undefined

Rule Cascade to JSON Logic

Rule CascadeJSON LogicClass
literalliteralEquivalent
{var: data.a.b}{"var": "a.b"}Equivalent, with the same two path caveats as on import. With --keep-roots: {"var": "data.a.b"}
{var: item.x} inside a collection operator{"var": "x"}Equivalent
and, or, notand, or, !Caveat strict-errors, as for every operator below. and() is true, or() is false
eq, ne===, !==Caveat deep-equality when both operands are of unknown type
lt, lte, gt, gte<, <=, >, >=
between(x, low, high){"<=": [low, x, high]}
in(x, list){"in": [x, list]}Caveat deep-equality when x and the elements are of unknown type
contains(s, t){"in": [t, s]}The import direction refuses this form unless s is a literal, because in alone does not say which meaning is intended
exists(x){"!==": [x, null]}
coalesce(a, b, c){"if": [{"!==": [a, null]}, a, {"!==": [b, null]}, b, c]}
ifif
add, sub, mul+, -, *Caveat binary-float
div, mod/, %Caveats binary-float and, unless the divisor is a literal other than 0, division-by-zero
abs(x){"max": [x, {"-": [x]}]}
min, maxmin, max
concatcat
substringsubstrCaveat utf16
list(a, b)[a, b]
len(x) where x is a known list (list, map, filter){"reduce": [x, {"+": [{"var": "accumulator"}, 1]}, 0]}
all(list, p){"none": [list, {"!": [p]}]}Rule Cascade's all of an empty list is true, JSON Logic's is false; "none fails" says the same thing in both
some, none, map, filtersome, none, map, filter
sum(list, term){"reduce": [list, {"+": [{"var": "accumulator"}, term]}, 0]}Caveat binary-float. {var: item.x} is {"var": "current.x"}

Refused

Rule CascadeWhy
{fn, args}JSON Logic has no functions. Inline the body first
x-* custom operatorsSupplied by the host application
matchesJSON Logic has no pattern matching
roundNo rounding, and its arithmetic is binary
daysBetween, yearsBetweenNo date arithmetic
typeOfNo type test
textJSON Logic renders values the way JavaScript does
lower, upper, trim, startsWith, endsWithNo counterpart
emptyJSON Logic cannot tell an empty object from one with members
len of a string or of a value of unknown typeA list can be counted with reduce; a string cannot be measured portably
eq, ne with a known list; in looking up a known listJSON Logic compares lists by identity
A path outside the exported root (params.max when exporting data)JSON Logic has one data object. Use --keep-roots
data.*, params.* and other outer paths inside a collection operatorJSON Logic sees only the current element there
An operator call with the wrong number of arguments, or something that is not an expressionAn evaluation error in Rule Cascade

Caveats

Each warning starts with the paths it applies to ($.and[0].==) followed by one of these texts.

NameDirectionThe two differ when
loose-equalityimportAn operand of == or != is not of the type compared with: JSON Logic converts (1 == "1", 0 == false), eq does not
truthinessimportA condition is not a boolean: JSON Logic uses its truthiness, Rule Cascade raises an evaluation error
numeric-coercionimportAn operand of a comparison or of arithmetic is not a number: JSON Logic converts it ("17" is 17, null is 0) or compares two strings alphabetically, Rule Cascade raises an evaluation error
binary-floatbothA result is not exact in binary floating point: 0.1 + 0.2 is 0.30000000000000004 in JSON Logic and 0.3 in Rule Cascade
division-by-zerobothThe divisor is 0: Infinity or NaN in JSON Logic, an evaluation error in Rule Cascade
text-renderingimportAn argument of cat is a list, an object, a number with more than 15 significant digits or one JavaScript prints with an exponent: text renders these differently (specification 8.1)
string-requiredimportAn operand that must be a string is not: JSON Logic converts it, Rule Cascade raises an evaluation error
utf16bothA string has characters outside the Basic Multilingual Plane: substr counts UTF-16 code units, substring counts code points
var-defaultimportThe property is present and null: JSON Logic returns null, coalesce returns the default
length-segmentbothThe value before a segment length is a list or a string: JavaScript returns its length, Rule Cascade looks for a property named length and finds null
leading-zero-indexbothA path has a segment of digits with a leading zero, and the value before it is a list: items.01 is the second element in Rule Cascade (specification 4.1) and nothing in JSON Logic
not-a-listimportThe first argument of a collection operator is neither a list nor null: an empty list to JSON Logic, an evaluation error in Rule Cascade
strict-errorsexportAny operand has the wrong type: Rule Cascade raises an evaluation error and the rule fails closed, JSON Logic converts and returns a value. Reported once for an expression with operators
deep-equalityexportBoth sides are lists or objects: eq, ne and in compare contents, JSON Logic compares identity

One difference has no warning because the converter cannot see it: json-logic-js resolves a path through any JavaScript property, so {"var": "name.0"} returns the first character of a string, where Rule Cascade returns null. Of these properties only length is detected.

Worked examples

Long lines of console output are wrapped on this page.

1. Importing an eligibility rule

{ "and": [
  { ">=": [ { "var": "age" }, 18 ] },
  { "in": [ { "var": "country" }, [ "US", "CA", "GB" ] ] },
  { "===": [ { "var": "status" }, "active" ] }
] }
$ python tools/rulecheck.py jsonlogic import eligibility.json
warning: $.and[0].>=[0]: JSON Logic converts these operands to numbers ("17" is 17, null is 0) and
compares two strings alphabetically. Rule Cascade accepts only numbers and raises an evaluation
error otherwise.

The result, written the way a ruleset would hold it:

assert:
  op: and
  args:
    - { op: gte, args: [ { var: data.age }, 18 ] }
    - { op: in, args: [ { var: data.country }, { op: list, args: [US, CA, GB] } ] }
    - { op: eq, args: [ { var: data.status }, active ] }

in with a literal list and === are equivalent and convert silently. The warning is about age: JSON Logic accepts "21" and treats a missing age as 0, Rule Cascade does neither. In a rule that is handled by a guard, so that a missing age is reported by its own required rule:

when: { op: eq, args: [ { op: typeOf, args: [ { var: data.age } ] }, number ] }

2. Importing presence tests

{ "if": [
  { "missing": [ "firstName", "lastName" ] }, "incomplete",
  { "!": { "var": "termsAccepted" } }, "terms",
  "ok"
] }

missing is the condition of if, so it converts exactly: a property is missing when it is null (or absent) or "". The chain becomes nested ifs.

op: if
args:
  - op: or
    args:
      - { op: in, args: [ { var: data.firstName }, { op: list, args: [ null, "" ] } ] }
      - { op: in, args: [ { var: data.lastName }, { op: list, args: [ null, "" ] } ] }
  - incomplete
  - { op: if, args: [ { op: not, args: [ { var: data.termsAccepted } ] }, terms, ok ] }
warning: $.if[2].!: JSON Logic accepts any value as a condition (null, false, 0, "" and [] count as
false). Rule Cascade requires a boolean and raises an evaluation error otherwise.
truthiness='expand' (--expand-truthiness) converts the test exactly.

If termsAccepted is always a boolean, the conversion is finished. If the form leaves it out until the box is ticked, not(null) is an evaluation error where JSON Logic answered "terms". With --expand-truthiness there is no warning and the last branch reads:

- op: if
  args:
    - { op: in, args: [ { var: data.termsAccepted }, { op: list, args: [ null, false, 0, "", { op: list, args: [] } ] } ] }
    - terms
    - ok

A related rule is refused, and the message says what to write instead:

$ echo '{"or": [{"var": "nickname"}, "anonymous"]}' | python tools/rulecheck.py jsonlogic import -
error: cannot convert: 1 unsupported construct(s)
  $.or[1]: or returns one of its operands in JSON Logic, here a string; Rule Cascade's or takes and
  returns booleans. For a fallback value use var with a default

3. Exporting a rule about a list

"The shares of the splits add up to 100 and each is positive", as a Rule Cascade expression:

op: and
args:
  - { op: eq, args: [ { op: sum, args: [ { var: data.splits }, { var: item.share } ] }, 100 ] }
  - { op: all, args: [ { var: data.splits }, { op: gt, args: [ { var: item.share }, 0 ] } ] }
$ python tools/rulecheck.py jsonlogic export shares.json
warning: $: Rule Cascade raises an evaluation error on operands of the wrong type, and the rule fails
closed. JSON Logic coerces them and returns a value.
warning: $.args[0].args[0]: Rule Cascade computes in decimal (0.1 + 0.2 is 0.3); JSON Logic computes
in binary floating point (0.30000000000000004). Results can differ in the last digits.
{ "and": [
  { "===": [ { "reduce": [ { "var": "splits" },
                           { "+": [ { "var": "accumulator" }, { "var": "current.share" } ] }, 0 ] }, 100 ] },
  { "none": [ { "var": "splits" }, { "!": [ { ">": [ { "var": "share" }, 0 ] } ] } ] }
] }

sum becomes the reduce idiom, and all becomes "none fails" so that an empty list means the same in both. The second warning matters here: shares of 33.4, 33.3 and 33.3 add up to exactly 100 in Rule Cascade and to 99.99999999999999 in json-logic-js.

An expression that uses what JSON Logic lacks is refused, with every reason:

$ python tools/rulecheck.py jsonlogic export swift.json
error: cannot convert: 2 unsupported construct(s)
  $.args[0].args[0]: empty: JSON Logic cannot tell an empty object from one with members
  $.args[1]: matches: JSON Logic has no pattern matching

Rendered from docs/json-logic.md in the repository. Edit it there.

On this page