Peppol BIS Billing 3.0 · corpus regression report
What 3.0.21 broke
We replayed 565 invoices against the rule set before and after the 3.0.21 release. Three of the things that broke could not have been found by reading the release notes.
A validation rule set is not a document you read. It is a program you run against your own data — and the only honest way to know what a new release does to your invoices is to run it against your invoices.
Peppol BIS Billing 3.0.21 became mandatory on 17 August 2026. We took a 565-invoice corpus, compiled the Schematron for 3.0.20 and 3.0.21 through the ISO skeleton, and ran both. Every invoice that passed under 3.0.20 and fails under 3.0.21 is a regression. There were 48, and nothing that previously failed started passing.
Three of those results are worth writing down, because each one is invisible to the way rule changes are normally tracked.
Finding 01
A rule named for Danish identifiers breaks German invoices
PEPPOL-COMMON-R053 is described in the artefact as “Danish ERSTORG number (SE) MUST be stated in the correct format.” In 3.0.20 it carried flag="warning". In 3.0.21 it carries flag="fatal".
If you maintain German invoices, nothing about that line asks for your attention. It is the single largest source of breakage in the corpus: 37 of the 48 newly failing invoices are German.
The reason is the rule’s context. It does not select on country — it selects on a scheme identifier:
<rule context="cbc:EndpointID[@schemeID = '0198'] | cac:PartyIdentification/cbc:ID[@schemeID = '0198'] | cbc:CompanyID[@schemeID = '0198']">
<cbc:CompanyID schemeID="0198">123456789</cbc:CompanyID>
<!-- the rule requires exactly 10 -->Scheme 0198 is Danish. These German invoices carry a nine-digit value tagged with it, and the rule requires exactly ten. Under 3.0.20 that produced a warning nobody read. Under 3.0.21 it is a rejection.
Whether the tag is correct is beside the point. It was tolerated for years, and one severity change turned a latent data problem into a hard failure — for invoices in a country the rule never mentions.
No amount of care reading the release notes surfaces this. The entry says “Danish”. Finding it requires running the rule against the documents you actually send.
Finding 02
A rule can start rejecting invoices without changing at all
PEPPOL-EN16931-R007 is identical in both releases. Same id. Same severity. Same test expression, character for character:
<assert id="PEPPOL-EN16931-R007" test="$profile != 'Unknown'" flag="fatal">
<assert id="PEPPOL-EN16931-R007" test="$profile != 'Unknown'" flag="fatal">
Three invoices that passed it under 3.0.20 fail it under 3.0.21. The rule did not change. The variable it depends on did.
<let name="profile" value="
if (/*/cbc:ProfileID and matches(
normalize-space(/*/cbc:ProfileID),
'…billing:([0-9]{2}):1.0'))
then tokenize(…, ':')[7]
else 'Unknown'" /><let name="profile" value="
if (normalize-space(/*/cbc:ProfileID) = (
'…poacc:billing:01:1.0',
'urn:peppol:france:billing:regulated',
'urn:peppol:france:billing:non-regulated'))
then '01'
else if (… = 'urn:peppol:bis:billing_with_response')
then '02'
else 'Unknown'" />The old definition accepted any two-digit billing profile and returned it. The new one accepts a fixed list. All three failing invoices declare the same ordinary profile:
urn:fdc:peppol.eu:2017:poacc:billing:02:1.0
Under the regex that resolved to '02', which is not 'Unknown', so the rule passed. Under the whitelist it matches nothing — billing:02:1.0 is simply absent from the list — so it resolves to 'Unknown' and a fatal rule fires.
Every tool that tracks rule changes by diffing rule ids and severities is structurally blind to this. Nothing about R007 changed. The ground under it moved.
Catching it requires diffing the <let> variables a rule depends on, not just the rule.
Finding 03
The published rule set and the source repository disagree
There are two places to get 3.0.21: the artefacts published at docs.peppol.eu, and the source repository the release is cut from. They are not the same rule set, and they do not give the same answer.
Same corpus, same 3.0.20 baseline, same engine — only the 3.0.21 artefact differs:
| Rule | Source repository | Published artefact |
|---|---|---|
| PEPPOL-COMMON-R053 | 37 | 37 |
| PEPPOL-EN16931-R007 | 3 | 3 |
| PEPPOL-COMMON-R052 | 1 | 1 |
| DK-R-017 | — | 4 |
| DK-R-003 | — | 3 |
| Total newly failing | 41 | 48 |
The repository says 41 invoices break. The publishing authority says 48. The seven-invoice gap is two Danish rules that the published artefact promotes to fatal and the development branch still carries as warning.
DK-R-017 is explicit about it: flag="warning" in the repository at 3.0.21, flag="fatal" in the artefact served for the same version number.
A validator built against the repository would have told you 41 invoices were at risk. Seven more would have been rejected in production, under the same version number.
The practical consequence is a rule with no exceptions: resolve rule sets from the publishing authority, never from a source-control reference. The version number is not sufficient to identify a rule set — only its content is.
How this was produced
Every figure above comes from running the corpus, not from reading a changelog. Both rule layers are applied — the Peppol layer and the CEN EN 16931 layer beneath it — because a rule promoted in either one rejects the invoice.
The corpus is the published Peppol test-case set. It is standing in for what a real corpus would be: your own issued invoices. The same method applied to those gives a number that is about you rather than about the test suite, which is the only number that matters when a deadline is attached to it.
- Corpus
- 565 UBL invoices
- Baseline
- Peppol BIS Billing 3.0.20
- Compared
- 3.0.21, resolved from
docs.peppol.euand from the source repository - Engine
- Schematron compiled to XSLT via the ISO skeleton, executed with Saxon-HE, results read from SVRL
- Layers
- PEPPOL-EN16931-UBL and CEN-EN16931-UBL
- Regression
- Passed under the baseline, fails under the comparison