Every parameter, input, output and definition in a law declares a type
(string, number, boolean, amount, date, array, object) and may
carry a type_spec with a unit, a precision and bounds. Until now those
declarations were documentation. just validate checked the shape of the YAML
against the JSON schema and nothing else; the engine is dynamically typed and
looks at the value it receives, never at the declaration; min and max have
been “parsed metadata, not yet enforced” since they were added.
RFC-036 made the cost visible. It introduced a run-time distinction between an
absent value (null) and an unknown one, and a rule that calculating with an
absence is an error. That error is correct, and it arrives too late: a
register delivers null for a field the law never expected to be absent, the
value travels through two cross-law calls, and article 3 of a correct law is
told to add an absence test. Review of that RFC put the finger on it: whether a
value may be absent belongs in the type, where the validator can see it, not
in the data, where only the engine sees it.
Adding one flag does not need a type checker. Enforcing it does: to refuse
EQUALS $huur null on a field that is not nullable, the validator has to know
what $huur refers to and what its declaration says. Once that environment
exists, the rest of the declarations can be checked at the same cost.
The engine gets a static type checker over the expressions of a law. It runs
in two places with one implementation (packages/engine/src/typecheck.rs): in
the validate binary after the schema check, so just validate and the
pre-commit hook enforce it on every law in the corpus, and in
LawExecutionService::load_law, so a law that fails the check cannot be
loaded, by the editor’s WASM engine or anyone else. A finding names the law,
the article, the output or action, and the rule.
Environment. For each article the checker knows the declared type and
nullability of every parameter, input and output, the type of every definition
from its literal, and that $referencedate is a non-nullable date. Property
paths into objects ($record.field) and FOREACH bindings have no known type.
The checker never reports on what it does not know: a false positive on a
correct law is a bug in the checker. It also understands the engine aliases
NOT_EQUALS, IS_NULL, NOT_NULL and NOT_IN, which the schema does not
admit; just validate refuses those before the checker runs, so they only
reach it through the loader.
Rules for absence (the rules of RFC-036, run here):
null needs a nullable field on the
other side. EQUALS $huur null on a non-nullable huur cannot be true and
is rejected. On a nullable field that the enclosing condition already
established present the test is redundant, and the message says so.null appears only as the value of a nullable output,
directly or as a branch of an IF that is that output’s value, or as an
element of a LIST, of the values of IN, or the body of a FOREACH
without combine (an array of absences is a value, and IN $x [null, 1]
is a membership test for absence). As an operand, or as the value of a
non-nullable output, it is rejected.IF without default yields null when no case matches. That is
allowed only as the value of a nullable output; nested inside an operation,
or as the value of a non-nullable output, it is rejected.AND, OR,
NOT, an IF condition or a date operation, or is assigned to a
non-nullable output (directly, value: $x, or as a branch of the output’s
IF), only on a path where it is known to be non-null. The subject of IN
is exempt (membership of an absence is structural under RFC-036 and cannot
fail), and so is a FOREACH collection (a null collection iterates
nothing). A path establishes presence in the then of
NOT(EQUALS $x null), in the cases after EQUALS $x null and in the
default, in the conditions of an AND after NOT(EQUALS $x null) (of an
OR after EQUALS $x null), in the body of a FOREACH whose filter
tested it, and through a boolean output that is itself such a test: an
earlier action heeft_x = NOT(EQUALS $x null) makes when: $heeft_x (and
NOT $heeft_x, and $heeft_x inside an AND) carry the fact, which is the
corpus idiom (heeft_partner). A guard on a field, NOT(EQUALS $rec.a null), establishes the record $rec too: a field of no record is no
record. Elsewhere the variable is rejected. Passing it on to another law,
into EQUALS, or to a nullable output is fine. The checker does not
express correlated presence beyond this (inputs keyed on $partner_bsn
being present exactly when partner_bsn is); a law states that with a
guard on the field it calculates with, or with the heeft_x idiom.null (RFC-036). A property path $rec.bsn as argument
counts with its record’s nullability; whether the field itself may be null
on a present record is not known. And an article that implements a
required open term without a default may not declare its output for the
term nullable: the engine takes that output as the term’s value, and the
delegating law reads the term as never absent.Load order. The set of loaded laws is well-typed whatever order the laws
arrive in. load_law checks the new law against the laws already loaded, and
then checks again every loaded law that references the new one (an input with
source.regulation, an implements of one of its terms), with the new law in
view. A finding there refuses the new law and names the consumer (“loading B
makes A ill-typed: A’s input x takes B.y, which may be null”). The demo and the
editor therefore cannot end up with a set that one load order accepts and
another refuses. What a well-typed set can no longer reach at run time (a
skipped call or a null output into a non-nullable input) stays in the engine
as defence in depth, with direct tests.
Rules for types:
number or amount operands; ADD
also joins two strings or two arrays (RFC-007), ordering also compares two
dates (RFC-021).AND, OR, NOT and an IF condition take boolean operands.EQUALS between two known, different types is rejected; number
against amount is allowed, and date against string, because dates
arrive as ISO strings.Each rule applies only where the operand types are known. Rules that fire on
a correct law in corpus/regulation or corpus/demo are narrowed until they
do not; the integration test that runs the checker over both corpora and
expects zero findings is what keeps the corpus typed from here on.
just validate while writing the
law, with the field named, instead of from a run-time error in another law.null (absent: null in the demo’s bindings.yaml) and a law that says the
field is never absent now contradict each other visibly, and a test can say
so.EQUALS … null on a field nobody declared nullable does not reach the corpus.min, max and units are next. The environment the checker builds is
what those need too; they are out of scope here and named as the next
candidates.MAX, a property inside an object, a null handed
in by a caller under an input’s name).null gains a
nullable: true. That is an honest annotation, not a change in logic, and it
was done in the same change.An IF_ABSENT operation (subject: $x, value: <fallback>, typed as
nullable T to T) would give the checker one place where a nullable value
becomes a value, and would read like the legal phrase “bij gebreke van”.
RFC-004 adds an operation when a real law needs it. The laws in both corpora
test absence as a boolean (“heeft partner”, “is beëindigd”) and rarely fall
back to a value, so the flow rule N4 over the existing IF and EQUALS … null covers them. The operation is added the day a legal text says “bij
gebreke van X geldt Y”, not before.
null for
a non-nullable input when it resolves it, and nothing more. That catches the
data error at the boundary but leaves EQUALS $huur null on a non-nullable
field as a comparison that is always false, silently. The validator has to
read the expression.type: number? or a union with
null). Equivalent in power; the flag keeps the type enum and the
type_spec machinery untouched and is one line in the schema.packages/engine/src/typecheck.rs; called from bin/validate.rs
and from load_law, which also re-checks the loaded laws that reference the
new one; NullForNonNullable and NullOutput errors at run time (RFC-036).nullable on baseField in v0.5.8 (see RFC-036).null; the
integration test keeps both corpora at zero findings.null cell only for nullable columns;
type-check errors from loadLaw are shown to the author.min/max (issue #444) and unit consistency (RFC-023) as rules of the
same checker.An exploration by Bureau Architectuur of the Dutch Ministry of Economic Affairs and Climate Policy into the possibilities of transparent, executable legislation.
GitHub repository
How it works
Stay informed
Roadmap (Dutch)
Documentation
Research
Bureau Architectuur
Ministry of Economic Affairs and Climate Policy