API Stability Contract โ /v1/norms and /v2/norms¶
This document is the binding stability contract for consumers of /v1/norms and /v2/norms. It defines what consumers can rely on, how to detect change efficiently, and what kinds of changes warrant a major version bump.
/v1/norms is deprecated โ use /v2/norms
/v1/norms is deprecated from 1 November 2026 and will be removed on
1 November 2028 (24 months, per the deprecation policy).
Every v1 response carries Deprecation: @1793491200,
Sunset: Wed, 01 Nov 2028 00:00:00 GMT and
Link: </v2/norms>; rel="successor-version". Until the sunset, v1 behaves
exactly as described below.
/v2/norms¶
/v2/norms answers the question consumers actually ask: which norms are in
force on this date? Its full description renders on
API Specification (v2), from /v2/openapi.json.
/v1/norms |
/v2/norms |
|
|---|---|---|
Default cprmv_version |
0.3.0 |
0.4.1 |
| Date parameter | applicable_date: exact match on a period's start date |
valid_on: the norms in force on that date |
| Date omitted | every period of every ruleset | in force today (Europe/Amsterdam) |
dataset_versions |
every record of each ruleset in the response | the records of the period in force |
| Envelope | โ | adds valid_on, the date the response answers for |
Selection. For each ruleset, /v2/norms returns the rules of the latest
period whose start date (applicable_date) is on or before valid_on. A
period stays in force until the same ruleset publishes a later one. Rulesets
whose periods all start after valid_on are absent. A response therefore
mixes periods: on 2026-08-15 it holds BWBR0002471 from 2026-02-21,
BWBR0015711 from 2026-01-01 and the rulesets whose period starts on
2026-07-01.
Parameters. valid_on must be a real calendar date (2026-02-30 is
rejected). applicable_date is refused with a 400 that points to valid_on.
rulesetid, cprmv_version and endpoint behave as on v1; on both versions a
rulesetid or cprmv_version sent as an array (?rulesetid[]=X) is answered
with 400 INVALID_PARAM.
Dataset versions. For 0.3.x rulesets whose records carry no version,
dataset_versions lists the version-less records, and [0] is the most
recently published of them, not necessarily the record of the period in force.
Caching. The ETag is strong and covers the dataset metadata of the
selected periods, the request parameters (endpoint, rulesetid,
cprmv_version and the resolved valid_on, plus a marker that keeps v2 ETags
apart from v1 ones), and a digest of the content of the selected rules. A correction within a period (a higher rulesetid_index)
therefore changes the ETag, and so does midnight, even when nothing was
republished, because a new period may have come into force. Last-Modified is
omitted when it would be later than the response (a period not yet started),
so consumers should revalidate with If-None-Match; when it is sent,
If-Modified-Since is ignored (RFC 9110 ยง13.2.2). There is no pre-query 304 for rulesetid
requests on v2: the 304 check runs after the rules query, because the digest
needs the rules. Without valid_on, max-age never reaches past the next
midnight in Europe/Amsterdam.
Stability. Within v2, the default (0.4.1) response shape, its
predicate URIs and the valid_on semantics do not change; the v2 default
version changes only with a /v3/norms. 0.3.0 and 0.3.2 remain
selectable on v2 without that guarantee. The primary key
(rulesetid, applicable_date, rulesetid_index) and the additive-evolution
rules below hold for v2 as for v1. The OpenAPI description is at
/v2/openapi.json.
Audience¶
This contract is aimed at G2G consumers โ other Dutch government services integrating /v1/norms or /v2/norms to consume cprmv:Rule paths and norms. External consumers can build long-term integrations against this contract without fear of breakage within a major version.
The four versioning layers¶
Each /vN/norms carries four distinct version numbers, each describing a different layer of stability:
| Layer | Where | What it tracks | When it changes |
|---|---|---|---|
| API contract | URL path /v1/, /v2/ |
The schema and shape consumers code against | Breaking changes only (warrants a new major path) |
| Dataset versions | data.dataset_versions envelope map |
Per-rulesetid publication snapshots | Each BWB ruleset on its own cadence; map carries the latest version per rulesetid present in the response |
| CPRMV vocabulary | ?cprmv_version= request param โ data.cprmv_version envelope field |
Which CPRMV vocabulary version the response is requested in and emitted as | Consumer-selected per request (0.3.0, 0.3.2, 0.4.1; default 0.3.0 on v1, 0.4.1 on v2); the namespace of the data follows the selection |
| Backend service | API-Version HTTP header |
The deployed backend code | Each backend release (operational, not a contract signal) |
Only the first three are part of the consumer contract. The API-Version header is informational โ useful for support tickets, not for cache invalidation or schema discrimination.
cprmv_version is now a request input
As of backend v1.9.10, cprmv_version is a negotiable layer: consumers choose the
vocabulary version via the optional ?cprmv_version= parameter (default 0.3.0), and the
envelope cprmv_version echoes the choice. It previously surfaced only "what the backend
speaks". Omitting the parameter keeps the exact prior behaviour, so this is an additive
change within v1.
Stability promise within v1¶
Scope
This section and the ones that follow describe the deprecated /v1/norms. /v2/norms is covered by the /v2/norms section above.
Primary key semantics¶
The tuple (rulesetid, applicable_date, rulesetid_index) is the immutable primary key for any individual rule. Once published, this combination identifies a rule whose values never change.
- Corrections are published as new rows with a higher
rulesetid_index - Law amendments are published as new rows with a new
applicable_date - Old rows remain unchanged and queryable indefinitely
Consumers can cache rows indexed by this tuple permanently. Cache invalidation is not required.
Logical identity across versions¶
The rule_id_path_key field provides a stable identifier for the logical rule across all its versions. To query "the current value of this rule":
- Filter rules by
rule_id_path_key - Pick the row with the latest
applicable_date - Within that, pick the row with the highest
rulesetid_index
The kind of change is explanatory from the content โ a new applicable_date implies a law amendment; a same date with higher rulesetid_index implies a correction. No separate change-kind metadata is published.
Additive evolution¶
Within v1, all changes are additive:
- New fields may appear in the response envelope or rule objects โ consumers must ignore unknown fields gracefully
- New optional query parameters may be added โ consumers may ignore them
- Existing fields, their names, types, and semantics will not change
CPRMV vocabulary version¶
The cprmv_version envelope field echoes the version requested via ?cprmv_version= (one of 0.3.0, 0.3.2, 0.4.1; default 0.3.0). The fully-qualified rule keys (type, id, definition, contains) carry that version's namespace, so the predicate URIs a consumer sees are a function of the version they ask for:
0.3.0/0.3.2โhttps://cprmv.open-regels.nl/<version>/โฆ0.4.1โhttps://standaarden.open-regels.nl/standards/cprmv/0.4.1#โฆ
Stability within v1: the default (0.3.0) response shape and its predicate URIs do not change. Requesting a different cprmv_version is an explicit opt-in to that namespace โ not a breaking change to the default contract. A consumer that never sends the parameter is unaffected by new versions being added to the supported set. A change to the default version, or to the predicate URIs of the default version, needs a new major path: that is /v2/norms, whose default is 0.4.1.
Experimental versions โ 0.3.2 and 0.4.1 are not part of the v1 guarantee
On /v1/norms, only the default 0.3.0 is covered by this stability contract.
0.3.2 and 0.4.1 are experimental / preview on v1: their response shape,
predicate URIs, dataset_versions semantics (e.g. the 0.4.1 cprmv:RuleSet/validFrom
model and its cache caveat) and even their availability on
v1 may change โ or be withdrawn โ without a new major path and outside the
additive-evolution promise above. This applies to v1 only: on /v2/norms, 0.4.1 is
the contract default (see /v2/norms). Build long-term G2G integrations
against /v2/norms.
Per-rulesetid dataset versioning¶
Each BWB ruleset (BWBR0002471, BWBR0004044, โฆ) carries per-ruleset version metadata published by the CPSV editor. Where that metadata lives depends on the requested cprmv_version: 0.3.0/0.3.2 use a distinct cprmv:Dataset resource per ruleset; 0.4.1 has no cprmv:Dataset and the metadata is read from the cprmv:RuleSet (cprmv:validFrom). Either way the envelope shape below is identical. A single ruleset can have multiple records โ different applicable periods of the same law (e.g. the 2025-01-01 and 2026-01-01 editions of the Participatiewet) are concurrent and equally authoritative, not competing versions of each other. Both back rules that consumers may legitimately ask for. A single /v1/norms response can aggregate rules across N rulesets, each carrying M records.
The dataset_versions map¶
The data.dataset_versions envelope field is keyed by cprmv:rulesetId; each value is a list of records:
"dataset_versions": {
"BWBR0015703": [
{
"version": "2026-01-01",
"published_at": "2026-05-15T06:57:21Z",
"title": "Participatiewet"
},
{
"version": "2025-01-01",
"published_at": "2026-05-15T07:45:36Z",
"title": "Participatiewet"
}
],
"BWBR0044894": [
{ "version": null, "published_at": "2026-05-15T07:45:36Z", "title": null }
]
}
The list is pre-sorted: version descending with nulls at the end, ties broken by published_at descending. Element [0] is the most-recent applicable version of that ruleset.
The map contains entries only for rulesetids that have at least one version record (a cprmv:Dataset for 0.3.x, a cprmv:RuleSet for 0.4.1). Rulesetids without one are silently absent (a transitional state for legacy 0.3.x data; 0.4.1 always has a RuleSet per ruleset).
Three per-entry fields:
| Field | Source (0.3.x / 0.4.1) | Notes |
|---|---|---|
version |
dcat:version / cprmv:validFrom |
Present for every ruleset since CPSV editor v1.10.5 โ the editor derives each ruleset's version from the BWB date its own rules carry (their ruleIdPath), so non-primary rulesets are versioned too. null only survives for legacy data published before that change; consumers must still tolerate it. |
published_at |
dct:issued / cprmv:validFrom |
0.3.x: the publication timestamp โ updates on every (re-)publication, the change-detection signal. 0.4.1: there is no dct:issued, so cprmv:validFrom serves as published_at (see the 0.4.1 cache caveat). |
title |
dct:title |
Primary ruleset only. The editor only knows the human title of the service's legalResource.bwbId. null for non-primary rulesets. |
Matching rules to Dataset records¶
A rule with applicable_date: "2025-07-01" is backed by the Dataset record whose version covers that period. The mapping is convention-based, not enforced by the API: the editor publishes Dataset records for the same applicable periods as the rules it generates. Consumers wanting "the Dataset record that backs this rule" can:
- Look up
dataset_versions[<rule.rulesetid>] - Find the entry whose
versionmatches<rule.applicable_date>(when version is known) - Fall through to the latest entry by
published_atwhen version is null
Detecting publications¶
HTTP cache headers¶
When every rulesetid in the response has at least one dataset_versions entry, the response carries:
| Header | Example | Meaning |
|---|---|---|
ETag |
"3c899856" |
Opaque strong validator over every (version, published_at) pair plus filter params. title is intentionally excluded โ informational only. |
Last-Modified |
Fri, 15 May 2026 07:45:36 GMT |
Maximum published_at across all records in the response |
Cache-Control |
public, max-age=3600 |
Biannual data tolerates generous caching |
Consumers should use conditional requests for efficiency:
The server returns 304 Not Modified with no body when nothing in the response has been republished since the last fetch. For single-rulesetid queries (?rulesetid=<id>), the 304 check happens before the expensive rules SPARQL query โ only the cheap (cached) metadata lookup runs.
Why published_at (not version) drives cache validity¶
ETag and Last-Modified are computed from published_at, not version; the version field is informational metadata for human and UI consumption. For 0.3.0/0.3.2, published_at is dct:issued, which updates on every publication event and is therefore a reliable change signal (a legacy null version does not make the data uncacheable).
0.4.1 cache caveat
On /v1/norms with cprmv_version=0.4.1, published_at equals cprmv:validFrom (the applicable date),
because the 0.4.1 RuleSet has no dct:issued. A re-publish that keeps the same
validFrom but corrects rule values does not change ETag/Last-Modified, so a
cached response keeps being revalidated as unchanged on /v1/norms: the ETag does not
change until a later period is published. Consumers needing correction-level freshness on
0.4.1 should use /v2/norms, whose ETag also covers the content of the
selected rules, so a correction within a period changes it. 0.3.x is unaffected
(dct:issued advances on every publish).
Partial-coverage behaviour¶
When any rulesetid in the response lacks a version record (a cprmv:Dataset for 0.3.x, a cprmv:RuleSet for 0.4.1), the response degrades to:
dataset_versionsmap omits the unversioned rulesetid(s)ETagandLast-Modifiedheaders are not setCache-Control: no-cache
Rationale: we cannot reliably detect a change in an unversioned ruleset. Returning a 304 in that case would risk serving stale data, so we tell consumers to always refetch. As more BWB rulesets are published with version metadata, caching kicks in progressively for queries that span only versioned rulesets.
What warrants /v3/norms¶
The following would break the v2 contract and would be released as /v3/norms, with /v2/norms kept alive for a deprecation window. The same rules are why the v1 default (0.3.0) could not change in place and /v2/norms was introduced instead:
- Removing or renaming an existing field
- Changing the type or semantics of an existing field
- Changing the PK semantics of
(rulesetid, applicable_date, rulesetid_index)(e.g., allowing in-place mutation) - CPRMV major version bump that changes predicate URIs the consumer sees
Deprecation policy¶
With the introduction of /v2/norms:
/v1/normsremains available for at least 24 months after/v2/normsis published- During deprecation,
/v1/normsresponses includeDeprecation: @1793491200(1 November 2026) andSunset: Wed, 01 Nov 2028 00:00:00 GMTheaders (Deprecationper RFC 9745,Sunsetper RFC 8594), and aLink: </v2/norms>; rel="successor-version"header - Active consumers will be notified via the IOU Architecture documentation site and the changelog
Quick reference for consumers¶
| Question | Answer |
|---|---|
| Can I cache a rule's values indefinitely? | Yes, keyed by (rulesetid, applicable_date, rulesetid_index) |
| How do I detect new publications efficiently? | Use If-None-Match with the previous ETag โ 304 means nothing changed (on /v1/norms with cprmv_version=0.4.1, a correction within a period is not detected; use /v2/norms) |
Which cprmv_version should I request? |
On /v2/norms, omit it for the stable default (0.4.1). On the deprecated /v1/norms, the default is 0.3.0, where the editor no longer publishes new periods. |
| How do I get the norms in force on a date? | GET /v2/norms?valid_on=YYYY-MM-DD; omit valid_on for today. |
What if a rulesetid is missing from dataset_versions? |
That ruleset has no version record yet (a cprmv:Dataset for 0.3.x / a cprmv:RuleSet for 0.4.1); do not cache |
What does Cache-Control: no-cache mean here? |
At least one rulesetid in your query is unversioned โ refetch every time |
What does version: null mean? |
Legacy data published before CPSV editor v1.10.5 โ its non-primary version was unknown. Current data versions every ruleset, so null is rare. published_at remains authoritative for change detection (with the 0.4.1 caveat). |
Is published_at always a publication timestamp? |
For 0.3.x, yes (dct:issued). For 0.4.1 it is cprmv:validFrom (the applicable date) โ a same-date re-publish won't bump it; see the 0.4.1 cache caveat. |
| Why does a single rulesetid have multiple Dataset records? | Different applicable periods of the same law are concurrent and equally authoritative. The 2025-01-01 and 2026-01-01 editions of Participatiewet both back current rules; both are listed. |
| How do I find which Dataset record backs a specific rule? | Look up dataset_versions[<rule.rulesetid>], find the entry whose version matches <rule.applicable_date>; fall through to the latest by published_at when version is null. |
| How do I find the current value of a rule? | Filter by rule_id_path_key, sort by applicable_date desc then rulesetid_index desc, take first |
| Will new fields appear in responses? | Yes โ additively, never as a breaking change. Ignore unknown fields |
| Are all BWB rulesets on the same publication cycle? | No โ each ruleset has its own cadence; check dataset_versions[<id>][0].published_at individually |