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 converter
- JSON Logic to Rule Cascade
- Rule Cascade to JSON Logic
- Caveats
- Worked examples
Why Rule Cascade does not use JSON Logic
The decision is recorded in ADR 0001. The reasons, with what each one costs:
| JSON Logic | Rule Cascade | |
|---|---|---|
| Shape | {"<=": [a, b]}: the operator is the key | {"op": "lte", "args": [a, b]}: the operator is a value |
| Types | Loose. Any value is a condition; ==, <, + convert their operands | Strict. No conversion; a wrong type is an evaluation error and the rule fails closed (specification 4.1) |
| Arithmetic | Binary floating point | Decimal, 34 digits (specification 4.2) |
| Patterns | No operator; implementations add one with the regular-expression engine at hand | matches with one portable syntax and one meaning (specification 4.4) |
| Functions | None | Named, reusable expressions (specification 4.5) |
| Dates | None | daysBetween, yearsBetween on RFC 3339 dates |
| Data | One object | Named roots: data, original, actor, ctx, params, item |
| Defined by | A short description and the JavaScript implementation | A 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 inputThe 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.
| Option | Direction | Meaning |
|---|---|---|
--root PATH | both | The 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-roots | both | JSON Logic's data object is the whole environment {"data": ..., "ctx": ..., "params": ...}: paths are kept as written |
--expand-truthiness | import | Write 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 namesEvery construct is in one of three classes:
| Class | Meaning | What the converter does |
|---|---|---|
| Equivalent | The same result for every input | Converts silently |
| Caveat | The same result for well-typed input; different for some other input | Converts and returns a warning that names the place and the difference |
| Refused | No counterpart with the same meaning | Raises 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 Logic | Rule Cascade | Class |
|---|---|---|
{"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, ne | Equivalent |
==, != | eq, ne | Equivalent 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, or | and, or | Equivalent 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, gte | Equivalent 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 arguments | nested add, mul | Caveat binary-float, and numeric-coercion unless the operands are known numbers |
{"+": []} | 0 | Equivalent |
{"-": [a, b]}, {"-": [a]} | sub(a, b), sub(0, a) | As + |
/, % | div, mod | As +, and caveat division-by-zero unless the divisor is a literal other than 0 |
min, max | min, max | Equivalent 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 |
cat | concat; arguments that are not known strings are wrapped in text | Equivalent for known strings; otherwise caveat text-rendering |
{"substr": [s, start]}, {"substr": [s, start, length]} with literal, non-negative whole numbers | substring(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, filter | some, none, filter | Caveat not-a-list unless the list is known; predicate as for ! |
map | map | Caveat 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 condition | or of in(<var>, list(null, "")); for missing_some a count of the properties that are present | Equivalent. See below |
merge of literal lists and scalars | list(...) | 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 Logic | Rule 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 Logic | Why |
|---|---|
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 types | JSON Logic converts (1 == "1"); eq is never true across types |
==, ===, !=, !== when an operand is a known list | JSON 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 arguments | No defined value in JSON Logic |
| A known string, boolean, null or list where a number is needed | JSON Logic converts it or compares strings alphabetically; Rule Cascade compares numbers only. For dates use daysBetween |
>, >= with three arguments | json-logic-js ignores the third |
+ or * with one argument | A conversion to a number, which Rule Cascade does not have |
in when the second argument is neither a known list nor a known string | It means membership or substring depending on the run-time type |
substr with a negative or computed start or length | Negative values count from the end |
reduce other than the sum pattern | No general fold |
missing, missing_some where the list of names is used as a value, or with computed names | No expression returns that list. Write one required rule per field |
merge with an argument that is neither a literal list nor a known scalar | No operator joins lists |
| Any operator with a number of arguments outside the forms above | json-logic-js ignores surplus arguments and reads missing ones as undefined |
Rule Cascade to JSON Logic
| Rule Cascade | JSON Logic | Class |
|---|---|---|
| literal | literal | Equivalent |
{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, not | and, 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]} | |
if | if | |
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, max | min, max | |
concat | cat | |
substring | substr | Caveat 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, filter | some, none, map, filter | |
sum(list, term) | {"reduce": [list, {"+": [{"var": "accumulator"}, term]}, 0]} | Caveat binary-float. {var: item.x} is {"var": "current.x"} |
Refused
| Rule Cascade | Why |
|---|---|
{fn, args} | JSON Logic has no functions. Inline the body first |
x-* custom operators | Supplied by the host application |
matches | JSON Logic has no pattern matching |
round | No rounding, and its arithmetic is binary |
daysBetween, yearsBetween | No date arithmetic |
typeOf | No type test |
text | JSON Logic renders values the way JavaScript does |
lower, upper, trim, startsWith, endsWith | No counterpart |
empty | JSON Logic cannot tell an empty object from one with members |
len of a string or of a value of unknown type | A list can be counted with reduce; a string cannot be measured portably |
eq, ne with a known list; in looking up a known list | JSON 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 operator | JSON Logic sees only the current element there |
| An operator call with the wrong number of arguments, or something that is not an expression | An evaluation error in Rule Cascade |
Caveats
Each warning starts with the paths it applies to ($.and[0].==) followed by one of these texts.
| Name | Direction | The two differ when |
|---|---|---|
loose-equality | import | An operand of == or != is not of the type compared with: JSON Logic converts (1 == "1", 0 == false), eq does not |
truthiness | import | A condition is not a boolean: JSON Logic uses its truthiness, Rule Cascade raises an evaluation error |
numeric-coercion | import | An 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-float | both | A 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-zero | both | The divisor is 0: Infinity or NaN in JSON Logic, an evaluation error in Rule Cascade |
text-rendering | import | An 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-required | import | An operand that must be a string is not: JSON Logic converts it, Rule Cascade raises an evaluation error |
utf16 | both | A string has characters outside the Basic Multilingual Plane: substr counts UTF-16 code units, substring counts code points |
var-default | import | The property is present and null: JSON Logic returns null, coalesce returns the default |
length-segment | both | The 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-index | both | A 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-list | import | The 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-errors | export | Any 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-equality | export | Both 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
- okA 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 default3. 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 matchingRendered from docs/json-logic.md in the repository. Edit it there.
OpenAPI and Rule Cascade
Rule Cascade does not replace an OpenAPI description. The description says what an API accepts; a ruleset says which business rules apply to it.
Architecture
Rule Cascade keeps three things apart: writing a rule, compiling it, and evaluating it. Rules are written once as a ruleset, compiled once into a JSON bundle, and evaluated by whichever engine sits closest to the caller.