Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"a11y": "astro build && node scripts/check-mermaid-alt.mjs && node scripts/check-heading-order.mjs && node scripts/check-links.mjs && node scripts/gen-pa11yci.mjs && start-server-and-test 'astro preview --port 4173' http://localhost:4173 'pa11y-ci'"
"check:rfc-coverage": "node scripts/check-rfc-coverage.mjs",
"a11y": "astro build && node scripts/check-rfc-coverage.mjs && node scripts/check-mermaid-alt.mjs && node scripts/check-heading-order.mjs && node scripts/check-links.mjs && node scripts/gen-pa11yci.mjs && start-server-and-test 'astro preview --port 4173' http://localhost:4173 'pa11y-ci'"
},
"dependencies": {
"@astrojs/mdx": "^6",
Expand Down
67 changes: 67 additions & 0 deletions docs/scripts/check-rfc-coverage.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Assert the documentation-coverage matrix lists every Accepted RFC.
//
// `reference/documentation-coverage.md` is a hand-maintained table claiming,
// per Accepted RFC, where its prose coverage lives (or that it is on the
// backlog). A hand-maintained coverage claim rots the moment a new RFC is
// accepted and nobody updates the table: it then asserts coverage that does
// not exist while looking authoritative. This check makes the table fail CI
// when an Accepted RFC is missing from it, so the matrix stays honest as the
// RFC set grows. It reads source files (frontmatter + the markdown table),
// not the build, so it runs without `astro build`. Non-zero exit fails the gate.

import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

const RFC_DIR = fileURLToPath(new URL('../src/content/rfcs', import.meta.url));
const COVERAGE = fileURLToPath(
new URL('../src/content/docs/reference/documentation-coverage.md', import.meta.url),
);

// RFC-000 is the process itself; it documents itself and the coverage page says
// so in prose rather than the table. Any other Accepted RFC must appear.
const EXEMPT = new Set(['RFC-000']);

// Pull the `status:` value out of YAML frontmatter (first --- ... --- block).
function frontmatterStatus(src) {
const fm = src.match(/^---\n([\s\S]*?)\n---/);
if (!fm) return null;
const line = fm[1].match(/^status:\s*(.+)$/m);
if (!line) return null;
return line[1].trim().replace(/^['"]|['"]$/g, '');
}

// Every Accepted RFC id, from frontmatter — the source of truth, not a list to
// keep in sync by hand.
const acceptedRfcs = [];
for (const entry of readdirSync(RFC_DIR)) {
const m = entry.match(/^(rfc-\d+)\.md$/);
if (!m) continue;
const status = frontmatterStatus(readFileSync(join(RFC_DIR, entry), 'utf8'));
if (status === 'Accepted') acceptedRfcs.push(m[1].toUpperCase());
}
acceptedRfcs.sort();

// Every RFC id mentioned anywhere in the coverage page (table rows or the
// Backlog prose both count as "tracked").
const coverageSrc = readFileSync(COVERAGE, 'utf8');
const tracked = new Set(coverageSrc.match(/RFC-\d+/g) ?? []);

const missing = acceptedRfcs.filter((id) => !EXEMPT.has(id) && !tracked.has(id));

if (missing.length) {
console.error(
`RFC coverage check FAILED: ${missing.length} Accepted RFC(s) absent from ` +
`documentation-coverage.md:`,
);
for (const id of missing) console.error(' ' + id);
console.error(
'Add a row to the coverage table (or a Backlog entry) for each, then re-run.',
);
process.exit(1);
}

console.log(
`RFC coverage check passed: all ${acceptedRfcs.length - acceptedRfcs.filter((id) => EXEMPT.has(id)).length} ` +
`Accepted RFC(s) (excluding ${[...EXEMPT].join(', ')}) are tracked.`,
);
43 changes: 43 additions & 0 deletions docs/src/content/docs/concepts/competent-authority.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
title: "Competent Authority"
description: "How an article records the bevoegd gezag: the authority whose execution produces a binding decision, as a named organization or a category resolved per context."
---

A *beschikking* is only binding when the right body issues it. Income is assessed by the Tax Authority, but a long-term care indication is the CIZ's to decide, and social assistance is granted by a municipal college. An article records which body that is in `competent_authority`, the *bevoegd gezag*. This is what lets the engine tell a binding decision apart from a calculation anyone may run (see [Multi-Org Execution](./multi-org-execution)), and it is the anchor for deriving who may authoritatively annotate a law (see [Notes and Annotations](./notes-and-annotations)).

## The two forms

`competent_authority` sits in an article's `machine_readable` section and takes one of two shapes.

A named authority, when the body is fixed:

```yaml
machine_readable:
competent_authority:
name: Belastingdienst
type: INSTANCE
```

`type` is either `INSTANCE` or `CATEGORY`, defaulting to `INSTANCE`:

- **`INSTANCE`** is one specific organization, named outright. Belastingdienst, CIZ, Minister van Volksgezondheid, Welzijn en Sport.
- **`CATEGORY`** is a categorical role that resolves to a different concrete body depending on context. "College van burgemeester en wethouders" names a kind of authority; which municipal college is meant depends on the municipality in scope. The distinction tells the engine whether the authority is settled by the article alone or still needs a contextual fact to pin down.

A reference to a computed output, when the authority itself depends on the case:

```yaml
machine_readable:
competent_authority: '#bevoegd_gezag'
```

The `#` form points at an output computed elsewhere in the same law. The Wet langdurige zorg uses this: which body is competent (`2_1_3_bevoegd_gezag`) is itself a rule, so the article references the output rather than hard-coding a name.

## One law, several authorities

A single law can name a different authority per article, because different articles describe different acts. In the Wet langdurige zorg the indication decision is the CIZ's, while later articles assign other steps to the Zorgkantoor and the Wlz-uitvoerder. Each carries its own `competent_authority`. The authority is a property of the act in the article, not of the law as a whole.

## Further reading

- [Multi-Org Execution](./multi-org-execution) - how the competent authority decides execute-vs-accept
- [Notes and Annotations](./notes-and-annotations) - how note authority is derived from the competent authority
- [RFC-002: Bevoegdheid](/rfcs/rfc-002) - full specification
2 changes: 2 additions & 0 deletions docs/src/content/docs/concepts/cross-law-references.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,6 @@ The engine detects circular references (law A needs law B which needs law A) and

- [Law Format](./law-format) - full structure of a law YAML file
- [Inversion of Control](./inversion-of-control) - a different pattern for cross-law values: delegation
- [Temporal Validity and Dates](./temporal-and-dates) - what happens when a reference points at a law that has ended
- [Traceability](./traceability) - a real cross-law chain shown in an execution trace
- [RFC-007: Cross-Law Execution](/rfcs/rfc-007) - the full design specification
Original file line number Diff line number Diff line change
Expand Up @@ -151,5 +151,7 @@ The engine yields between stages, returning accumulated outputs and indicating w

- [Cross-Law References](./cross-law-references) - how laws reference each other explicitly
- [Inversion of Control](./inversion-of-control) - how higher laws delegate to lower regulations
- [Traceability](./traceability) - how hook and override nodes appear in an execution trace
- [Temporal Validity and Dates](./temporal-and-dates) - the date arithmetic behind the objection-deadline chain
- [RFC-007: Cross-Law Execution](/rfcs/rfc-007) - hooks and overrides specification
- [RFC-008: Bestuursrecht/Awb](/rfcs/rfc-008) - the administrative procedure model
6 changes: 2 additions & 4 deletions docs/src/content/docs/concepts/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,10 @@ On the data side, 342 municipalities, 12 provinces, and 21 water boards all prod

## Traceability

Every execution produces a trace tree. The trace shows which articles were applied, which inputs were fetched and from where, which operations ran, and what each step produced. Think of it as an explanation of the legal reasoning in structured form.

Traces show cross-law references ("income came from Awir article 8"), IoC resolution ("standard premium came from Regeling standaardpremie"), and organizational boundaries ("income accepted from Tax Authority"). Citizens can request their trace from each contributing organization.
Every execution can produce a trace tree showing which articles applied, which inputs were fetched and from where, which operations ran, and what each step produced: the legal reasoning behind a number, in structured form. A trace makes a cross-law chain, an IoC resolution, and the Awb hooks that fired on a *beschikking* all visible in one tree. See [Traceability](./traceability) for how to read one, with a real worked example.

## Temporal versioning

Laws change over time. The standard premium was different in 2024 than in 2025. A calculation for January 2025 must use the rules and values in effect on that date. The engine selects the law version where `valid_from <= reference_date`.

The corpus contains both `regeling_standaardpremie/2024-01-01.yaml` and `regeling_standaardpremie/2025-01-01.yaml`. A calculation with `reference_date: 2024-06-15` automatically uses the 2024 value.
The corpus contains both `regeling_standaardpremie/2024-01-01.yaml` and `regeling_standaardpremie/2025-01-01.yaml`. A calculation with `reference_date: 2024-06-15` automatically uses the 2024 value. See [Temporal Validity and Dates](./temporal-and-dates) for how a law expires with `valid_to`, what happens to a reference into an ended law, and how dates are compared and subtracted inside the rules.
1 change: 1 addition & 0 deletions docs/src/content/docs/concepts/inversion-of-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,5 @@ Cross-law references and IoC both let laws use values from other laws, but they

- [Cross-Law References](./cross-law-references) - the other pattern for inter-law values
- [Hooks and Reactive Execution](./hooks-and-reactive-execution) - yet another pattern: laws that fire automatically
- [Traceability](./traceability) - how an open-term delegation appears in an execution trace
- [RFC-003: Inversion of Control](/rfcs/rfc-003) - the full design specification
2 changes: 2 additions & 0 deletions docs/src/content/docs/concepts/multi-org-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ A citizen requesting their trace can see: "your income was determined by the Tax

## Further reading

- [Competent Authority](./competent-authority) - the `competent_authority` field that decides execute-vs-accept
- [Traceability](./traceability) - how the organizational boundary appears in a real trace
- [Hooks and Reactive Execution](./hooks-and-reactive-execution) - how Awb rules apply across organizations
- [Federated Corpus](./federated-corpus) - how different organizations maintain their own law files
- [RFC-009: Multi-Org Execution](/rfcs/rfc-009) - the full design specification
68 changes: 68 additions & 0 deletions docs/src/content/docs/concepts/notes-and-annotations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
title: "Notes and Annotations"
description: "Stand-off notes that anchor to legal text by quote rather than position, so they survive renumbering and minor edits, and how their authority is derived."
---

People need to write things about a law: a comment explaining a term, a link from a sentence to the rule that executes it, a flag on an ambiguous norm. The hard part is that law text changes. Articles get renumbered, sentences get reworded, and a note pinned to "article 2, character 140" breaks the moment an article is inserted above it.

RegelRecht keeps notes **stand-off**: stored separately from the law, never embedded in it, so the verbatim source text stays untouched. A note anchors to the text by quoting it, following the W3C Web Annotation Data Model.

## Anchoring by quote

A note targets text through a `TextQuoteSelector`: the exact quote, plus a little prefix and suffix for context. From a real note on the Wet op de zorgtoeslag (`corpus/annotations/wet_op_de_zorgtoeslag/annotations.yaml`):

```yaml
- type: Annotation
motivation: linking
creator: Dienst Toeslagen
target:
source: regelrecht://wet_op_de_zorgtoeslag
selector:
type: TextQuoteSelector
exact: zorgtoeslag
prefix: 'heeft de verzekerde aanspraak op een '
suffix: ' ter grootte van dat verschil'
body:
type: SpecificResource
source: regelrecht://wet_op_de_zorgtoeslag/hoogte_zorgtoeslag
purpose: linking
```

Because the anchor is the content, not a line number, the note follows its text. Insert a new article 1a and renumber the target to 4a, and the note still lands on 4a. The resolver (`packages/engine/src/annotation/`) reports one of three outcomes (`MatchStatus`):

- **Found**: exactly one location matches.
- **Ambiguous**: the quote occurs more than once with no context to separate the hits (the common word "verzekerde" three times in a sentence). Adding a prefix or suffix disambiguates.
- **Orphaned**: the text is gone. The note is not silently dropped; it is marked orphaned so a human can re-anchor it.

When wording drifts slightly (a Staatsblad amendment swaps a few words), exact matching fails but **fuzzy matching** recovers it: normalized Levenshtein similarity above a threshold (currently 0.7) resolves as a fuzzy match with a confidence below 1.0; a wholesale rewrite falls below the threshold and orphans rather than mis-anchoring. A note may also carry an optional article hint to try first; an outdated hint falls back to a full search. The behavior is pinned by `features/notes.feature`.

## What a note says

The `motivation` field is the kind of note. The W3C model defines thirteen motivation values (Web Annotation Data Model, §3.3.5); four matter for law work, and three of those are in use in the corpus today:

- **linking** ties a sentence to the `machine_readable` element that executes it, making the chain from text to logic auditable.
- **commenting** is a plain explanation for readers.
- **questioning** flags an open or ambiguous norm. These draw their term from a controlled vocabulary (`corpus/annotations/_vocabulary/ambiguity.yaml`) and move through a small workflow: an open question becomes `resolved` once the implementing regulation is found and modelled.

The fourth, **tagging** (classification), is available but not yet used in the corpus.

## Storage, federation, and authority

Notes live in sidecar YAML keyed by the law's `$id`, not its file path (`corpus/annotations/{law_id}/annotations.yaml`). They follow the same [federated](./federated-corpus) model as the corpus: any organization can keep notes on a law in its own repository, and the editor's write path is append-only so parallel edits do not clobber each other.

A note's **authority is derived at display time**, not declared. The resolver compares the note's `creator` against the article's [competent authority](./competent-authority):

- **authoritative** when the creator is the competent body itself (Dienst Toeslagen on its own act).
- **advisory** when the creator is another government organization.
- **generated** when tooling produced it.
- **personal** when an individual wrote it.

The same note text means something different depending on who wrote it, and the model makes that explicit instead of treating every note as equal.

The engine exposes resolution to the browser through the WASM bindings `resolveNote` and `resolveNotes` (`packages/engine/src/wasm.rs`), so the editor anchors notes against the live text on screen.

## Further reading

- [Competent Authority](./competent-authority) - the basis for a note's derived authority
- [Federated Corpus](./federated-corpus) - how notes are distributed
- [RFC-005: Stand-off Notes](/rfcs/rfc-005) and [RFC-018: Note Infrastructure](/rfcs/rfc-018) - full specifications
63 changes: 63 additions & 0 deletions docs/src/content/docs/concepts/temporal-and-dates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
title: "Temporal Validity and Dates"
description: "How the engine selects the law version in force on a date, how it reports references to expired laws, and how dates are compared and subtracted."
---

A calculation always happens *as of* a date. The rules and amounts in force on 15 June 2024 are not the ones in force on 15 June 2025, and a faithful answer uses the version that applied on the day in question. Two mechanisms make this work: version selection by date, and date operations inside the rules.

## Which version is in force

A law version carries `valid_from`, and optionally `valid_to`: the first and last calendar days on which it is in force, both inclusive. The engine selects the version where `valid_from <= reference_date <= valid_to`, where the reference date is the calculation date supplied by the caller.

`valid_to` is what lets a law expire without a successor. A version with `valid_to: 2024-12-31` resolves on its last day:

```gherkin
Given the calculation date is "2024-12-31"
When the law "test_einddatum" is executed for outputs "normbedrag"
Then the execution succeeds
And the output "normbedrag" is "500"
```

and the day after, it is gone. Selection does **not** fall through to an older version once the in-force one has ended; an expired law is expired, not replaced by its predecessor. A reference to a law that has ended fails with the data fact rather than a vague "no rule found":

```gherkin
Given the calculation date is "2025-06-01"
When the law "test_einddatum" is executed for outputs "normbedrag"
Then the execution fails with
"No version of law 'test_einddatum' in force on 2025-06-01; last in force until 2024-12-31"
```

The same applies across a cross-law reference: a law that reads an ended law reports which law ended and when, instead of computing on no-longer-valid rules. The selection outcome is one of in force, not yet in force, or ended on a date (`SelectionReason` in `packages/engine/src/resolver.rs`), and both `valid_from` and `valid_to` are recorded in the [Execution Receipt](./execution-provenance) so the choice is reproducible. The scenarios above come from `features/einddatum.feature`.

## Comparing and subtracting dates

Deadlines and durations need arithmetic on dates, not just on numbers. Two routes cover it.

**Comparison operators dispatch on operand type.** `GREATER_THAN`, `LESS_THAN`, `LESS_THAN_OR_EQUAL` and the rest compare numbers when both operands are numeric, and compare chronologically when both are ISO 8601 dates. So `$indieningsdatum <= $peildatum` works directly, with no detour through `AGE`. `EQUALS` gains a date fallback for the mixed case (a date string against the `{iso, year, month, day}` object form of `referencedate`).

**`DATE_DIFF` measures the span between two dates** with an explicit unit:

```yaml
operation: DATE_DIFF
from: $indieningsdatum
to: $referencedate
in: days # or: months, years
```

It is signed: positive when `to` is on or after `from`, negative otherwise. For a request filed on 2025-01-01 against a peildatum of 2025-07-01, the span is `181` days; flip the two dates and it is `-181`. Months and years count whole calendar units, reusing the same arithmetic as `AGE` (BW art. 1:2), so end-of-month and leap-year cases stay consistent: 31 January to 28 February is one whole month, because January has no 31st counterpart in February.

```gherkin
Given the calculation date is "2025-02-28"
And a query with the following data:
| indieningsdatum | 2025-01-31 |
When the law "test_date_operations" is executed for outputs "doorlooptijd_maanden"
Then the output "doorlooptijd_maanden" is "1"
```

Dates must be in canonical `YYYY-MM-DD` form, zero-padded; `2025-1-1` is rejected rather than guessed. These scenarios come from `features/date_operations.feature`, and the related operations `AGE`, `DATE_ADD`, `DATE`, and `DAY_OF_WEEK` are listed in the [Law Format](./law-format) operation table.

## Further reading

- [Cross-Law References](./cross-law-references) - how an expired reference surfaces in a chain
- [Execution Provenance](./execution-provenance) - validity windows in the receipt
- [RFC-019: Law End Dates](/rfcs/rfc-019) and [RFC-021: Date Comparison](/rfcs/rfc-021) - full specifications
Loading
Loading