API Stability Contract β /v1/norms¶
This document is the binding stability contract for consumers of /v1/norms. It defines what consumers can rely on, how to detect change efficiently, and what kinds of changes warrant a major version bump.
Audience¶
This contract is aimed at G2G consumers β other Dutch government services integrating /v1/norms to consume cprmv:Rule paths and norms. External consumers can build long-term integrations against this contract without fear of breakage within v1.
The four versioning layers¶
/v1/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/ |
The schema and shape consumers code against | Breaking changes only (warrants /v2/) |
| 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 | data.cprmv_version envelope field |
Which vocabulary the data uses | Vocabulary version bumps (usually additive) |
| 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.
Stability promise within v1¶
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 surfaces which CPRMV vocabulary the response data uses (currently "0.3.0"). A bump within the 0.x line is treated as additive vocabulary growth (new predicates, new optional fields). A major bump that changes predicate URIs the consumer sees would be released as /v2/norms.
Per-rulesetid dataset versioning¶
Each BWB ruleset (BWBR0002471, BWBR0004044, β¦) is published as a distinct cprmv:Dataset resource in TriplyDB by the CPSV editor. A single ruleset can have multiple Dataset 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. Non-primary rulesets (where the editor doesn't know dcat:version) fall through to pure published_at desc ordering.
The map contains entries only for rulesetids that have at least one cprmv:Dataset record. Rulesetids without one are silently absent (transitional state during rollout).
Three per-entry fields, with two of them nullable:
| Field | Source | Always present? |
|---|---|---|
version |
dcat:version |
Primary ruleset only. The CPSV editor only knows the version of the service's legalResource.bwbId β the law explicitly entered in the Legal tab. For other rulesets that enter the service via cprmv:Rule references, the version is unknown and emitted as null. |
published_at |
dct:issued |
Always present. Timestamp of when this cprmv:Dataset record was published. This is the meaningful signal for change detection β it updates on every (re-)publication regardless of whether the BWB's own version is known. |
title |
dct:title |
Primary ruleset only. null for non-primary rulesets, for the same reason as version. |
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¶
A null version field doesn't mean the data is uncacheable β it just means the BWB's own version label is unknown. The actual change signal is published_at (dct:issued), which is always present and updates on every publication event. ETag and Last-Modified rely on published_at; the version field is informational metadata for human and UI consumption.
Partial-coverage behaviour¶
When any rulesetid in the response lacks a cprmv:Dataset record (an unversioned ruleset), 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 cprmv:Dataset metadata, caching kicks in progressively for queries that span only versioned rulesets.
What warrants /v2/norms¶
The following would break the v1 contract and would be released as /v2/norms, with /v1/norms kept alive for a deprecation window:
- 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¶
When /v2/norms is eventually introduced:
/v1/normsremains available for at least 24 months after/v2/normsis published- During deprecation,
/v1/normsresponses includeDeprecation: <date>andSunset: <date>headers per RFC 8594 - 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 |
What if a rulesetid is missing from dataset_versions? |
That ruleset has no cprmv:Dataset record yet; 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? |
The BWB's own version is unknown (this ruleset isn't the primary law of any service that publishes it). published_at is still authoritative for change detection. |
| 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 |