RFC-021: Date Comparison and Difference Operations

AcceptedImplemented
Depends on
RFC-004 (Uniform Operation Syntax)

Context

Laws constantly reason about dates relative to a peildatum (the reference date a law is evaluated as of). A deadline expires a number of weeks after a decision. Information older than five years triggers a heavier duty to motivate. A request counts as timely when it arrives on or before a cutoff. Authors writing machine-readable logic for these rules hit two walls.

First, the comparison operators reject dates. GREATER_THAN, LESS_THAN, GREATER_THAN_OR_EQUAL, and LESS_THAN_OR_EQUAL route every operand through numeric coercion, which errors on a date string. So $peildatum > $grensdatum does not work, even though the peildatum is already available to any law as $referencedate. To compare two dates today an author has to detour through AGE and compare the resulting integer, which only works when the question happens to be about whole years.

Second, there is no way to ask for the span between two dates. AGE answers “how many whole years”, and DATE_ADD shifts a date by an offset, but nothing reports “how many days” or “how many months” lie between two dates. The arithmetic to do this already exists in the engine (the year and month difference helpers behind AGE), but it is not reachable from a law.

The engine already carries most of the machinery: four date operations (AGE, DATE_ADD, DATE, DAY_OF_WEEK), a date parser that accepts ISO 8601 strings and reference-date objects, and the peildatum injected as $referencedate. What is missing is narrow and additive.

Decision

Add date support along two paths, each matched to the problem it solves.

Route A: type-safe comparison operators

Make the ordered comparison operators dispatch on operand type instead of forcing numbers:

  • both operands numeric: numeric comparison (unchanged)
  • both operands parse as ISO 8601 dates: chronological comparison
  • anything else (including a number compared against a date): a type error, as before

No new operator names. $peildatum > $grensdatum simply works, the same way $bedrag > 1000 does. EQUALS is made date-aware for exactly one pair of forms: a date string against the reference-date object, so "2025-01-01" equals {iso: "2025-01-01", ...}. Two strings or two objects keep structural equality, which for canonical date strings already coincides with chronological equality. Combined with the canonical-form requirement on date strings (see Implementation Notes), EQUALS and the ordering operators can never disagree on the same pair of dates.

operation: GREATER_THAN_OR_EQUAL subject: $referencedate.iso # peildatum, .iso because $referencedate is an object value: $indieningsdatum # a date parameter is already a string

A note on .iso. The peildatum is injected as a structured object ($referencedate has .iso, .year, .month, .day), so its date string is reached with $referencedate.iso. A parameter declared type: date, like $indieningsdatum, is already a plain string and takes no suffix. This is the only reason the two sides differ; the operator treats both the same. Both forms also work without the suffix, since every date operation (including EQUALS) accepts the object form, but .iso reads more clearly as “the date”.

Route B: an explicit DATE_DIFF operation

Add one operation for the span between two dates, with the unit as a required argument:

operation: DATE_DIFF from: $indieningsdatum to: $referencedate.iso # peildatum (see the note on .iso above) in: days # one of: days | months | years

It returns an integer, positive when to is on or after from and negative otherwise. months and years count complete calendar units, reusing the same arithmetic as AGE (so Jan 31 to Feb 28 is one whole month, and a Feb 29 birthday lands on Feb 28 in a non-leap year, per BW art. 1:2).

The name pairs with the existing DATE_ADD: one adds an offset to a date, the other measures the offset between two dates. The operand fields from and to make the direction, and therefore the sign, obvious from the YAML. The unit field is named in so the operation reads as a single clause: “date diff from X to Y in days”.

Why

Benefits

  • The peildatum-versus-date comparison that authors reach for most often now works directly, with no detour through AGE.
  • Route A introduces no new vocabulary. There is nothing new to learn or document for comparison; the existing operators just accept a wider operand type.
  • DATE_DIFF removes the unit ambiguity at the call site. The author states whether they mean days, months, or years, so the result is never silently the wrong granularity.
  • The change is additive and non-breaking for every law in the corpus. No value type is added, no existing law changes meaning, and every published schema version keeps validating the laws that reference it. One sharpening does reach the existing date operations: the canonical-form requirement below also makes AGE, DATE_ADD, and DAY_OF_WEEK reject a non-zero-padded literal like 1990-1-1 that the lenient parser used to accept. That failure is loud, and no law in the corpus contains such a literal.
  • Both routes reuse arithmetic that the engine already ships and tests for AGE, so the calendar edge cases (leap years, end-of-month) are handled consistently.

Tradeoffs

  • Comparison operators now have two type behaviors. A reader of GREATER_THAN has to know it dispatches on operand type. This is the same polymorphism the operators already have between integers and floats, so it is a small extension of an existing idea rather than a new one.
  • Dates remain strings at runtime rather than a first-class type. The type safety is enforced at the operation boundary (both operands must parse as dates), not by the value system. This keeps the change small at the cost of not catching a malformed date until execution.

Alternatives Considered

Alternative 1: overload SUBTRACT for dates

Let SUBTRACT of two dates yield their difference. Rejected because a bare subtraction cannot express the unit, and the unit is exactly the ambiguous part. “400 days” is also “1 year” and “13 months”; the operation has to be told which one. DATE_DIFF with an in argument makes that explicit, where SUBTRACT would have to pick a default and surprise half its callers.

Alternative 2: a native date value type

Add Date to the value system and type operands statically. Rejected for now as far more invasive than the problem warrants. It would touch serialization, equality, taint propagation, and every operation’s type handling, and it would risk changing the meaning of existing laws that treat dates as strings. The boundary-check approach in Route A gets the type safety where it matters without that blast radius. A native date type remains open as future work if static typing becomes a broader goal.

Alternative 3: specific date comparison operators (DATE_BEFORE, DATE_AFTER)

Add named operators for date ordering. Rejected for comparison because it duplicates operators that already express the intent; DATE_BEFORE is just LESS_THAN with a narrower operand type. Reserving the explicit-operator approach for DATE_DIFF, where the unit argument genuinely needs a home, keeps the surface area minimal.

Implementation Notes

  • The sign convention follows the existing difference helpers, which are positive when their first argument is the later date. DATE_DIFF(from, to) passes (to, from) to those helpers so the result is positive when to is on or after from.
  • Route A only takes the date path when both operands fail numeric coercion and both parse as dates. A number compared against a date stays a type error rather than coercing one side, so mistakes surface loudly.
  • Like the other date operations, DATE_DIFF must be nested inside a value and is not valid directly at the action level.
  • EQUALS gains the date fallback only when the operands are not structurally equal and form the mixed pair of a date string and an {iso} object. Two objects stay structurally compared, so values that merely carry the same iso field do not become equal, and plain string and numeric equality keep their existing behavior. Membership (IN) is untouched and stays structural: a date string is EQUALS to its object form but not IN a list that contains only the object form. That asymmetry is deliberate, because making membership date-aware would change list semantics well beyond dates.
  • The in unit is normally a literal (days, months, years), and the schema enumerates those. It may also be a variable that resolves to one of those strings, so the schema accepts a variable reference alongside the enum.
  • Date parsing requires canonical YYYY-MM-DD form. The underlying parser is lenient and would accept 2025-1-1, but such a literal would compare chronologically under >/< while being inequal under EQUALS (which compares strings). To keep the operators consistent, a non-zero-padded date is rejected at parse time by the ordering operators, DATE_DIFF, and the existing date operations (AGE, DATE_ADD, DAY_OF_WEEK). EQUALS is the one place that stays quiet: its date fallback simply does not fire when a side fails to parse, so a non-canonical literal falls back to plain string equality instead of raising an error. The form the engine produces for $referencedate.iso is already canonical.

References

RegelRecht

An exploration by Bureau Architectuur of the Dutch Ministry of the Interior into the possibilities of transparent, executable legislation.

Links

GitHub repository
How it works
Stay informed
Documentation

Contact

regelrecht@minbzk.nl

Part of

Bureau Architectuur
Ministry of the Interior and Kingdom Relations