ADR-0174: Release versions are two numbers tied to the API version, and SemVer is not claimed
Date: 2026-08-03 Status: Accepted Deciders: Adrian (project owner), Claude Opus 5, Codex (review, PR #1030)
Numbered 0174, not 0173. PR #1014 claimed 0173 first for the mandatory-monitoring-checks decision, and its number is cited in twelve places across
monitoring_schedule_service.pyand its tests. Both branches were open at once; the cheaper rename was this one. Recorded because a silently renumbered ADR is indistinguishable from a missing one.
Context
git tag was empty across 3,282 commits spanning 2026-02-20 to 2026-08-03,
while backend/app/main.py declared version="0.1.0" from inception. That
string named nothing: no tag ever carried it, no build was ever cut against it,
and no artifact could be resolved from it.
That is fine for a repository nobody outside the team consumes. It stops being fine the moment an OEM instrument has to name a thing. A licence grant says "the Software, version X". An escrow agreement deposits a build. An IP warranty attaches to a state of the code. An SBOM describes a release. None of those draft against a moving branch, and a contracts team reaches the definitions clause and stops.
So a version scheme was needed. Choosing one is a project-wide decision — it
fixes what MAJOR means, when MINOR advances, whether a compatibility promise is
being made, and what relationship the tag bears to the code. Those consequences
outlive the person who picked them, which is what an ADR is for. This one is
written after the implementation was drafted and before it merged; Codex flagged
its absence on PR #1030 (AGENTS.md:L56-L57).
Two facts about this codebase constrain the choice:
-
There is a published interface — what is missing is any PROMISE about it. An earlier draft of this ADR said no published interface exists. That is false and was corrected in review:
docusaurus/trust-relay/docs/api/holds fourteen reference pages, andapi/webhooks.mddescribes/v1/peppol/*endpoints explicitly "designed for external partner integration", with API-key auth.The interface is documented. What does not exist is a compatibility or deprecation promise attached to it: nothing states which parts are stable, nothing defines a notice period, and no customer integration constrains a change. That is the fact this decision rests on, and it is the one a future maintainer needs — being told the documented interface does not exist would send them looking for something they can see in their own repository.
-
API versioning is not uniform. THREE routers carry a version prefix —
/v1/peppol,/api/v1/inhoudingsplicht, and/api/v1/lex(an/apimount over the router's own/v1/lex, easy to miss because neither half looks versioned alone). Every other router sits under a bare/api, and none of the three is governed by a deprecation policy. An earlier draft of the changelog asserted there was no/v1/prefix at all — refuted bymain.pyin review, and corrected rather than restated.
Decision
Release versions are MAJOR.MINOR — two numbers. API_VERSION,
backend/pyproject.toml and the version FastAPI serves are all literally that
string; the git tag is that string with a leading v — version 0.1, tag
v0.1, per the ordinary git convention.
An earlier wording said tag and version were "the same string", which contradicted
the v that the release procedure and its test both require. The v is tag
syntax around the identity, not part of it: an SBOM or escrow deposit naming 0.1
and a tag reading v0.1 refer to the same release, and the test asserts exactly
that relationship rather than raw equality.
-
MAJOR is the API compatibility generation. It is
0, and0means what it says: no backward-compatibility promise is made. MAJOR becomes1when a promise is actually made — when an interface is documented as stable and a deprecation policy exists — not when the product feels mature. -
MINOR increments on each tagged release. No patch component. A fix that warrants a release warrants a MINOR.
-
One string, three places, pinned by test.
API_VERSIONinbackend/app/main.pyis the single declaration; FastAPI is constructed with it; the tag reuses it verbatim.backend/tests/test_release_version_989.pyasserts the servedapp.versionequals the constant and that CHANGELOG.md carries a matching## v<version>heading — an exact match, not a word-boundary one, because\bbetween1and.would let## v0.1.0satisfy a check for0.1and silently readmit the rejected form. -
Semantic versioning is deliberately NOT claimed. SemVer's contract is about breaking changes to a published interface under a stated promise. The interface is published; the promise is not, and nothing in CI detects a breaking change. Asserting SemVer would advertise a discipline that is not being practised — the claim-vs-check defect this project spends its effort removing, applied to its own version string.
-
The changelog entry must state what the release is NOT. A version number invites the assumption that the thing is deployable. The entry therefore carries deployment status, country readiness, sub-processors, certification status and known gaps, each citing its authoritative register, and a test asserts those citations are still present so the section cannot be hollowed out to a surviving heading.
-
Tagging is by hand. No release automation is built. One tag is the deliverable; automating a process performed zero times would be designing against an imagined workflow.
Consequences
Positive
- An OEM instrument, an escrow deposit and an SBOM can all name the same string, and that string resolves to a specific commit.
0is load-bearing rather than decorative: a reader who knows the scheme knows no compatibility promise exists, without having to ask.- The version cannot silently drift from its changelog, or from what the API actually serves — both directions are asserted, and both were mutation-tested.
Negative
- No patch component. A one-line fix released on its own consumes a MINOR, so the MINOR series will run faster than a SemVer reader expects. Accepted: the alternative is a third number whose meaning nothing enforces.
- Not SemVer means tooling does not understand it. Dependency resolvers and release bots assume SemVer. Nothing consumes this project as a dependency today, so nothing is broken; when something does, MAJOR moves to 1 and the question is reopened deliberately.
- The scale figures in a changelog entry are not commit-exact. The entry is written before the tag, and further merges land between. They are labelled as scale rather than identity, and the tag is the identity.
Neutral
- No runtime behaviour changes. The version string moves from
"0.1.0"to"0.1"in the OpenAPI document, which no consumer reads today. /v1/peppoland/api/v1/inhoudingsplichtare untouched. Their prefixes are path-level history, not a compatibility generation, and this ADR does not retro-fit meaning onto them.
Alternatives Considered
Alternative 1: Semantic versioning (MAJOR.MINOR.PATCH)
The default, and what "0.1.0" already looked like.
Why rejected: SemVer is a promise about breaking changes to a published interface, enforced by the maintainer's discipline. There is no published interface, no deprecation policy and no CI that detects a breaking change, so every part of the promise would rest on nothing. Shipping the shape of a guarantee without the substance is precisely the defect class this repository's own registers exist to catch.
Alternative 2: Date-based versioning (2026.08)
Common for products with no API surface, and honest about the absence of a compatibility contract.
Why rejected: it says nothing about compatibility in either direction, so a
partner cannot read intent from it — and it cannot express "we now make a
promise", which is the one transition this scheme must be able to signal. It
also loses the tie to API_VERSION, which is what lets one string serve the
tag, the SBOM and the OpenAPI document.
Alternative 3: Keep the untagged 0.1.0 string
Do nothing; leave the version as documentation.
Why rejected: this is the status quo the issue exists to end. A version that names no artifact is not a version, and the definitions clause of every OEM instrument needs one.
Decision context:
- Latency: none. A module-level string constant.
- Dependency surface: none. No packages added; no release tooling built.
- Debuggability: a drift is a named test failure at PR time naming both values, rather than a discrepancy discovered by a partner reading an SBOM.
- Reversibility: minutes for the scheme (one constant, one changelog heading); a published tag is permanent, which is the point of tagging.
- Blast radius:
API_VERSION, the OpenAPIinfo.version, and CHANGELOG.md. Nothing consumes the version today. - Alternative considered: SemVer — rejected, it advertises a compatibility discipline that is not practised and that nothing here enforces.