Skip to content

RFC-013: Execution Provenance

Status: Draft Date: 2026-04-02 Authors: Eelco Hotting

Context

The engine produces deterministic results: given the same regulation YAML, the same inputs, and the same calculation date, it always produces the same outputs. RFC-006 chose Rust precisely for this guarantee. Determinism within a single execution is necessary but not sufficient. Government agencies must be able to reproduce a specific decision months or years later, with the exact same result.

Dutch administrative law requires this:

  • Awb Art. 3:46 (motivation obligation): every government decision (besluit) must rest on a sound motivation. For algorithmic decisions, the agency must be able to reconstruct how the algorithm reached its result.
  • AERIUS I (ECLI:NL:RVS:2017:1259): the Raad van State ruled that automated government decisions must be transparent and auditable (inzichtelijk en controleerbaar). For rule-based systems (as opposed to ML), full logical reproducibility is the expectation.
  • EU AI Act Art. 12 (mandatory August 2026): high-risk systems must automatically log events including input data and verification results. Logs must be retained for at least 6 months.
  • GDPR Art. 13-15, 22: citizens have the right to "meaningful information about the logic involved" in automated decisions and the right to contest those decisions.

Reproducibility requires pinning three things: the regulation YAML, the schema version it conforms to, and the engine version that executed it. Today, the engine stamps none of these on its output. The regulation carries a $schema URL, but the engine ignores it at execution time. The engine version exists as a Cargo constant but is not included in results. There is no "decision receipt" that ties inputs, outputs, and versions together.

RFC-009 (Multi-Organisation Execution) adds another dimension: a decision may depend on values accepted from other organisations' engines, each potentially running a different engine version. Reproducing that decision requires knowing which engine this organisation ran, and which engine versions produced the accepted values from other organisations.

The schema is a specification, the engine is an implementation

The schema defines the regulation format: what fields exist, what operations are valid, what structures are allowed. The engine interprets and executes regulations that conform to the schema. Think SQL (specification) vs PostgreSQL (implementation), or JSON Schema (specification) vs any validator.

This distinction matters because third-party organisations may build their own engine implementations. A municipality (gemeente) might use a different technology stack. The schema must be precise enough that any conformant engine produces identical results for identical inputs. When two engines disagree, one has a bug. The schema is the arbiter.

Coupling schema and engine version numbers would conflate specification with implementation. A schema change (new field, new operation) is a format change. An engine change (bugfix, performance improvement) is an implementation change. They evolve on different timelines for different reasons.

Decision

Five decisions establish the reproducibility model.

1. Independent versioning with declared compatibility

The schema and engine are versioned independently. Both use semantic versioning.

Schema versions follow the existing convention: immutable directories under schema/ (e.g., schema/v0.5.0/schema.json). A published schema version is never modified. The CI protect-schema job already enforces this.

Engine versions are declared in Cargo.toml and correspond to GitHub Release tags (via release-engine.yml). The engine version is bumped for any change that could affect execution output.

Each engine release declares which schema versions it supports:

toml
# packages/engine/Cargo.toml
[package]
name = "regelrecht-engine"
version = "0.6.0"

[package.metadata.regelrecht]
supported-schemas = ["v0.5.0", "v0.5.1", "v0.6.0"]

The engine validates at startup that loaded regulations reference a supported schema version. A regulation referencing an unsupported schema version is a hard error, not a silent fallback to serde-only validation.

2. Execution Receipt

Every execution produces an Execution Receipt: an output envelope that contains everything needed to reproduce the result.

json
{
  "provenance": {
    "engine": "regelrecht",
    "engine_version": "0.6.0",
    "schema_version": "v0.5.1",
    "regulation_id": "wet_op_de_zorgtoeslag",
    "regulation_valid_from": "2025-01-01",
    "regulation_hash": "sha256:a1b2c3..."
  },
  "engine_config": {
    "connectivity": "federated",
    "legal_status": "authoritative",
    "untranslatable_mode": "error",
    "identity": {
      "name": "Dienst Toeslagen",
      "organisation_id": "00000004003214345000",
      "type": "INSTANCE"
    }
  },
  "scope": {
    "sources": [
      {
        "id": "minbzk-central",
        "ref": "v2025.1",
        "tree_hash": "sha256:112233..."
      },
      {
        "id": "amsterdam",
        "ref": "main@abc1234",
        "tree_hash": "sha256:445566..."
      }
    ],
    "loaded_regulations": [
      {
        "id": "wet_op_de_zorgtoeslag",
        "valid_from": "2025-01-01",
        "hash": "sha256:a1b2c3..."
      },
      {
        "id": "regeling_zorgverzekering",
        "valid_from": "2025-01-01",
        "hash": "sha256:b2c3d4..."
      },
      {
        "id": "algemene_wet_inkomensafhankelijke_regelingen",
        "valid_from": "2025-01-01",
        "hash": "sha256:c3d4e5..."
      }
    ],
    "scopes": [
      {"type": "gemeente_code", "value": "GM0363"}
    ]
  },
  "execution": {
    "calculation_date": "2025-01-01",
    "parameters": {
      "bsn": "999993653",
      "berekeningsjaar": 2025
    },
    "reference_date": "2025-06-15"
  },
  "results": {
    "outputs": {
      "heeft_recht_op_zorgtoeslag": true,
      "hoogte_zorgtoeslag": 209692
    },
    "trace": { }
  },
  "accepted_values": [
    {
      "output": "toetsingsinkomen",
      "value": 3200000,
      "authority": "inspecteur",
      "engine": "regelrecht",
      "engine_version": "0.5.1",
      "regulation_id": "wet_inkomstenbelasting_2001",
      "regulation_hash": "sha256:d4e5f6...",
      "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
      "signed": true
    }
  ],
  "timestamp": "2025-06-15T10:30:00Z"
}

The receipt records:

  • Which engine, version, and schema produced the result
  • The engine configuration: connectivity mode (solo/federated), legal status (simulation/authoritative), untranslatable mode (RFC-012), and engine identity (RFC-009). These determine execution behavior: whether cross-org calls are made, whether outputs carry legal weight, and how untranslatable constructs are handled
  • The scope: which corpus sources were loaded (by source id, Git ref, and tree hash), which regulations were loaded (by id, valid_from, and content hash), and which jurisdiction scopes were active
  • The calculation date, parameters, and reference date
  • Outputs and trace tree
  • For cross-organisational executions (RFC-009): the provenance of each accepted value, including the other authority's engine version and regulation hash
  • When the execution occurred

The scope section is what makes the receipt fully reproducible. The engine's output depends on all loaded regulations, not just the one being evaluated. IoC resolution (RFC-003) pulls in implementing regulations. Cross-law references resolve against whatever is loaded. Priority rules (RFC-010) determine which version of a regulation wins when multiple sources provide it. Two engines with different loaded regulations can produce different results for the same parameters.

The loaded_regulations array lists every regulation the engine had available during execution, each with a SHA-256 content hash. The sources array lists each corpus source with its Git ref and tree hash. Together, these allow exact reconstruction of the execution environment.

The scopes array records which jurisdiction scopes were active (per RFC-010). An engine running with scope GM0363 (Amsterdam) loads different municipal regulations than one running with GM0384 (Diemen), which changes IoC resolution for national laws that delegate to municipal ordinances.

The accepted_values section captures the cross-organisational provenance chain. When engine A accepts a value from engine B, A records B's provenance metadata. To reproduce the full execution, you need A's receipt (which tells you B's engine version and regulation hash) and B's receipt (which B stores in its own Logboek Dataverwerkingen per RFC-009).

3. Schema URL immutability

Regulation YAML files currently reference schemas via GitHub main branch URLs:

yaml
$schema: https://raw.githubusercontent.com/MinBZK/regelrecht/refs/heads/main/schema/v0.5.0/schema.json

This URL is mutable. A force-push or file edit on main would silently change what the URL resolves to. For a system that must reproduce decisions years later, that is unacceptable.

Schema URLs switch to Git tag references:

yaml
$schema: https://raw.githubusercontent.com/MinBZK/regelrecht/refs/tags/schema-v0.5.0/schema/v0.5.0/schema.json

When a schema version is published, a Git tag schema-vX.Y.Z is created. GitHub tag protection rules prevent deletion or modification.

The engine embeds schema files at compile time (via include_str! in validate.rs) and validates by content, not by URL fetch. The URL is a pointer for humans and tooling. The content hash in the Execution Receipt is the integrity anchor.

4. Multi-organisation reproducibility

RFC-009 defines two resolution modes: Execute (compute locally) and Accept (take another organisation's authoritative determination). For reproducibility, the Accept path must capture enough provenance to reconstruct the full decision chain.

Inter-engine protocol versioning

The inter-engine protocol (RFC-009 §5) is a public API contract between organisations. It must be versioned independently from the engine. An explicit api_version field in every request and response lets organisations running different engine versions communicate:

json
{
  "api_version": "1.0",
  "law_id": "wet_op_de_zorgtoeslag",
  "article": "2",
  "output": "toetsingsinkomen",
  ...
}

Within a major API version, backward compatibility is guaranteed. An engine at v0.5.1 and an engine at v0.8.0 both speak api_version: "1.0". Breaking protocol changes require a major API version bump and a migration period where engines support both versions.

Value representation on the wire is pinned: currency values are integers in cents, dates are ISO 8601 strings, booleans are JSON booleans. Internal representation of Value may differ between engine implementations.

Extended provenance in the inter-engine response

This RFC extends the RFC-009 §5 response with engine provenance:

json
{
  "api_version": "1.0",
  "value": 32000,
  "type": "currency",
  "authority": {
    "name": "inspecteur",
    "organisation_id": "00000001003214345001"
  },
  "provenance": {
    "engine": "regelrecht",
    "engine_version": "0.6.0",
    "schema_version": "v0.5.1",
    "law_id": "wet_inkomstenbelasting_2001",
    "article": "2.18",
    "regulation_hash": "sha256:d4e5f6...",
    "computed_at": "2025-06-15T10:30:00Z",
    "mode": "authoritative",
    "has_upstream_accepts": true
  },
  "signature": "eyJ..."
}

New fields: engine, engine_version, schema_version, regulation_hash, and has_upstream_accepts. The last signals whether this value depended on accepted values from yet another organisation, meaning the provenance chain is deeper than two hops.

Sealed receipt reproduction

When reproducing a cross-org decision, the engine uses the sealed accepted values stored in the Execution Receipt. It does not call other organisations' engines again.

Why not re-call? The other organisation may now be running a different engine version with different corpus. Re-calling would produce today's answer, not last year's. And legally, a beschikking (individual decision) stands once issued. The competent authority's determination at the time is a legal fact. Reproducing Org A's decision means re-executing A's computation with A's engine version, A's regulation, and the accepted values A received at the time.

Reproduction steps:

  1. Read engine A's Execution Receipt (contains A's engine version, scope with all loaded regulations and their hashes, parameters, and the accepted values with B's provenance)
  2. Obtain engine A's exact binary (from GitHub Release by version tag)
  3. Reconstruct the corpus scope: obtain all regulation YAMLs listed in scope.loaded_regulations (verify each content hash)
  4. Re-execute in replay mode: the engine loads the same set of regulations, uses sealed accepted values from the receipt instead of making inter-engine calls
  5. Verify the outputs match the original receipt

For full chain verification (optional, useful for audit or dispute resolution):

  1. Obtain engine B's receipt from B's Logboek Dataverwerkingen (linked by trace_id)
  2. Re-execute B's computation independently using B's engine version and regulation hash
  3. Verify the accepted value matches what B originally provided

Step 5 proves A's computation is reproducible. Steps 6-8 prove B's determination was correct. Step 5 alone satisfies the legal requirement: A is responsible for A's computation, using B's determination as a legal fact.

Version divergence between organisations

Different organisations will run different engine versions. This is expected and acceptable if:

  • Each engine version passes the conformance test suite for the schema versions it supports
  • The inter-engine protocol is versioned independently and backward-compatible within a major version
  • Accepted values include engine provenance in their response

Two engine implementations that correctly implement the same schema version must produce identical outputs for identical inputs. When they diverge, one has a bug. A future RFC will define a language-agnostic conformance test suite that formalizes this contract, providing a shared set of input/output test cases any engine implementation can run to prove correctness per schema version.

5. Compliance requirements as machine-readable regulations

The compliance requirements in this RFC (provenance fields, schema immutability) are themselves rules about how the system must behave. In a platform that executes machine-readable law: can these compliance rules be expressed as regelrecht regulations?

The engine's operation set is designed for computation over citizen data (arithmetic, comparison, conditional logic). It cannot introspect the execution environment. A compliance regulation could define what fields a Execution Receipt must contain, but the engine cannot verify its own output structure or inspect other regulations' metadata. The compliance YAML would need external inputs (engine_version_present: true/false supplied by a test harness) to produce a verdict.

This is useful in two ways:

  1. The compliance requirements, expressed as a regulation, become a versioned, machine-readable document. Any engine implementation can evaluate it with a test harness that bridges output metadata back into the regulation's inputs.
  2. The system that executes Dutch law expresses its own compliance obligations in the same format.

Runtime self-enforcement (the engine checking its own compliance during execution) would require engine context variables ($engine.version, $regulation.hash) in the execution scope. That is a future extension.

6. Per-output provenance in receipts

The results section of the Execution Receipt includes an output_provenance map that tags each output with how it was produced:

json
{
  "results": {
    "requested_outputs": ["minister_is_bevoegd"],
    "outputs": {
      "minister_is_bevoegd": true,
      "motivering_vereist": true,
      "bezwaartermijn_weken": 4
    },
    "output_provenance": {
      "minister_is_bevoegd": { "type": "Direct", "law_id": "vreemdelingenwet_2000", "article": "14" },
      "motivering_vereist": { "type": "Reactive", "law_id": "algemene_wet_bestuursrecht", "article": "3:46", "hook_point": "preactions" },
      "bezwaartermijn_weken": { "type": "Reactive", "law_id": "algemene_wet_bestuursrecht", "article": "6:7", "hook_point": "postactions" }
    }
  }
}

This serves two purposes:

  • Audit trail: which mechanism produced each output (direct execution, hook, or lex specialis override)
  • Privacy-by-design filtering: the multi-output API uses provenance to decide what to include — requested outputs plus causally-entailed hook/override outputs, but never unrelated outputs from other articles

See RFC-007 for the hook provenance model.

Why

Benefits

  • Execution Receipts satisfy Awb Art. 3:46 (motivation), AERIUS (auditability), and EU AI Act Art. 12 (logging). The receipt is the archivable artifact that proves how a computation was performed.
  • Independent versioning lets other organisations build their own engine on their own release cadence.
  • Sealed receipt reproduction makes cross-org decisions traceable and reproducible without requiring cooperation from other orgs at verification time.
  • Versioning the inter-engine protocol separately from the engine lets organisations upgrade at their own pace.
  • Receipts are additive — they extend ArticleResult without changing execution logic. Schema enforcement and CI checks can be rolled out phase by phase.
  • The engine declares which schemas it supports. Adding a schema version doesn't break old regulations. Dropping support is an explicit, versioned decision.

Tradeoffs

  • Every execution-affecting change must bump the engine version. This requires CI enforcement and team discipline. Without it, reproducibility is impossible.
  • Including provenance, trace, and accepted values makes receipts larger than bare outputs. Trace depth is configurable; receipts can be archived and referenced by hash.
  • Reproducing engine B's computation requires access to B's receipt (via Logboek Dataverwerkingen) and B's engine binary (via its release). If B does not publish releases or retain receipts, full chain reproducibility is limited to A's portion.
  • The schema URL format change (refs/heads/main to refs/tags/schema-vX.Y.Z) requires a one-time migration of all corpus YAML files.

Alternatives Considered

Alternative 1: Coupled schema + engine versions

  • Schema v0.6.0 always pairs with engine v0.6.0. The $schema field implicitly identifies the engine.
  • Rejected because it conflates specification with implementation. Prevents third-party engines from using their own version numbers. Forces engine releases for schema-only changes and vice versa.

Alternative 2: Engine backward compatibility (support all schema versions forever)

  • A single engine binary can execute any historical regulation regardless of schema version.
  • Rejected because it accumulates compatibility shims indefinitely. After 20 schema versions, the engine is mostly compatibility code.

Alternative 3: Content-addressed schema references (SRI hashes in YAML)

  • Add $schema_integrity: sha256-<hash> alongside the $schema URL.
  • Deferred. Adds complexity to every YAML file and every tool that reads them. Immutable Git tags + compile-time embedded schemas + content hashes in Execution Receipts provide equivalent integrity without burdening the regulation format.

Implementation Notes

Phase 1: Execution Receipt and engine version stamping

  • Add engine_version (from CARGO_PKG_VERSION) and schema_version (from the regulation's $schema field) to ArticleResult
  • Add regulation_hash (SHA-256 of YAML content) to ArticleResult
  • Extend the inter-engine protocol response (RFC-009 §5) with engine_version, schema_version, regulation_hash, has_upstream_accepts, and api_version
  • Define the ExecutionReceipt struct as the top-level output envelope
  • Add replay mode: engine can re-execute from sealed accepted values in a receipt

Phase 2: Schema version enforcement

  • Add supported-schemas metadata to Cargo.toml
  • Validate at regulation load time that $schema references a supported version
  • Error on unknown schema versions in validate.rs (fix the current silent fallback)
  • Migrate corpus $schema URLs from refs/heads/main to refs/tags/schema-vX.Y.Z
  • Create schema-vX.Y.Z Git tags for all published schema versions
  • Update schema/latest symlink (currently stale: points to v0.5.0, should be v0.5.1)

Phase 3: CI enforcement

  • CI check: engine source changes require version bump (compare Cargo.toml version between PR and main)
  • CI check: every schema/vX.Y.Z/ directory is registered in validate.rs
  • CI check: every $schema URL in corpus YAML references an existing schema version
  • CI check: schema/latest symlink points to the highest semver directory
  • Add rust-toolchain.toml with pinned stable version for reproducible builds
  • Add --locked to all CI cargo commands (currently only release builds use it)

Affected components

FileChange
packages/engine/src/engine.rsArticleResult gains provenance fields
packages/engine/src/service.rsLawExecutionService populates provenance on execution
packages/engine/src/article.rsfrom_yaml_str computes and stores content hash
packages/engine/src/lib.rsRe-export ExecutionReceipt type
packages/engine/src/bin/validate.rsError on unknown $schema, verify all schemas registered
packages/engine/Cargo.tomlsupported-schemas metadata, version bump
packages/shared/src/provenance.rsNew: ExecutionReceipt, ExecutionReceiptProvenance types
corpus/regulation/**/*.yaml$schema URL migration to tag-based refs
schema/latestFix symlink: v0.5.0 to v0.5.1
.github/workflows/ci.ymlVersion bump check, schema registration check, --locked
rust-toolchain.tomlNew: pinned Rust toolchain version

References