Our release gate for Factur-X/ZUGFeRD hybrids used to end the way most do: veraPDF says the PDF/A-3 is compliant, Mustang says the invoice is valid, ship it. Then we started breaking our own files on purpose, and four of the breaks sailed through green. This post is the list of those four, with the exact checks that close them. Most of it got hashed out in public, in a SolidInvoice issue where several people building generators compared notes — worth reading if you want the raw exchange: https://github.com/SolidInvoice/SolidInvoice/issues/1439
Blind spot 1: the flipped /AFRelationship
A hybrid invoice is a PDF/A-3 with the invoice XML embedded as factur-x.xml. Every embedded file referenced from the document's /AF array carries an /AFRelationship key, and the Factur-X / ZUGFeRD spec cares about its value: Alternative for BASIC, EN 16931 and EXTENDED profiles as used in Germany, Data for MINIMUM and BASIC WL.
Take a known-good EN 16931 hybrid that both validators pass, and flip only that key:
import pikepdf
pdf = pikepdf.open("good-en16931.pdf")
pdf.Root.AF[0].AFRelationship = pikepdf.Name("/Data") # was /Alternative
pdf.save("flipped.pdf")
Re-run the gate on flipped.pdf:
- veraPDF (1.31.167 local install; same result re-run in the
verapdf/cliDocker image, 1.30.2): stillisCompliant="true". Nothing wrong with that —/AFRelationshipsemantics are Factur-X domain, not PDF/A conformance. veraPDF is answering a different question than the one you think you asked. - Mustangproject 2.26.0,
--action validate: still<summary status="valid"/>. The notices it did print on our file were unrelated content rules.
So the one key the spec makes profile-dependent is exactly the key neither tool will fail on. It gets better: the wrong value is the default outcome in popular libraries. Python's factur-x (akretion) defaults the attachment to afrelationship="data" in both generate functions, overridable via a parameter; horstoeko/zugferd in PHP has the same pattern. If you never set it explicitly, you ship Data on an EN 16931 invoice and every validator in your pipeline stays green.
The fix costs one line in CI:
import pikepdf
pdf = pikepdf.open("invoice.pdf")
assert pdf.Root.AF[0].AFRelationship == pikepdf.Name("/Alternative")
Keep the pdf reference in a variable exactly like that: pikepdf closes the document when the object is garbage-collected, so the tempting one-liner pikepdf.open(...).Root.AF[0]... dies with "object of type destroyed" on current pikepdf (10.x).
One honest nuance, credit to podshalocef of invowerk.dev who added it in that same thread: the hybrid rule set does cover this as BR-HYBRID-11 — but at warning severity, so a wrong value never makes the invoice formally invalid. And the accepted value depends on the profile and on the seller and buyer countries:
| Profile | Seller / buyer | Accepted /AFRelationship |
|---|---|---|
| MINIMUM, BASIC WL | any | Data |
| BASIC, EN 16931, EXTENDED, XRECHNUNG | DE / DE | Alternative |
| XRECHNUNG | FR / FR | none (always warns) |
| any other combination | Alternative, Source or Data |
(The reference implementation of that matrix is the HybridValidator in phax/kaltblut.) Two neighbouring rules from the same family are worth knowing: MINIMUM or BASIC WL between two German parties is an error (BR-HYBRID-DE-01/02), and between two German parties with valid XML a failed PDF/A check is downgraded to a warning (BR-FX-DE-03) — everywhere else it stays an error. Which is exactly why a structural assert in your own gate beats hoping a validator escalates a warning.
Blind spot 2: Mustang's last summary
Mustang's validation report contains several <summary> elements — one per part it checks, XML and PDF — and the overall verdict mirrors the XML validity. We proved this live on 2.26.0: strip the OutputIntent from the PDF, set a conformance level that is not on the list, or drop the level entirely, and the PDF-side summary goes invalid while the summary your script probably reads still says valid.
If your gate does this, it ships those files:
# WRONG: reads one summary, Mustang's overall verdict tracks xmlValidity
java -jar Mustang-CLI.jar --no-notice --action validate --source invoice.pdf \
| grep '<summary' | tail -1
Require every summary to be valid, and at least one to exist:
out=$(java -jar Mustang-CLI.jar --no-notice --action validate --source invoice.pdf)
echo "$out" | grep -q '<summary status=' || { echo "no summary at all"; exit 1; }
echo "$out" | grep '<summary status=' | grep -vq 'status="valid"' \
&& { echo "a part failed validation"; exit 1; }
Same theme as blind spot 1: the tool is fine, the single-line read of its output is the bug.
Blind spot 3: raw CEN Schematron is not the XRechnung ruleset
If you target the German public sector, validating against the raw CEN Schematron packs gives you both false alarms and false passes, because the KoSIT validator scenarios override severities. Two live examples from the thread: a Stk unit code is an error under the CEN rules but only a warning under XRechnung (BR-CL-23), while a CII seller contact carrying both a person and a department is the reverse — fine in CEN, an error since the XRechnung rules 2.6.0 (CII-SR-465). Run the KoSIT validator with its scenario configuration for XRechnung targets, not the bare Schematron.
Blind spot 4: the rules have validity windows
The KoSIT packs are date-gated, and during a transition period two versions are in force at once. An invoice can pass the pack you happen to have installed while failing the one that actually applied on its issue date — or the other way round. Pin the Schematron and codelist versions per issue date, ideally by hash, and treat a rules update like any other dependency bump: re-run your whole fixture matrix against it before it reaches CI.
The gate that survives all four
Ordered, each step failing the build on its own:
- XSD of the declared profile at submit time. Cheapest rejection, before anything is rendered or charged.
-
Structural asserts on the produced PDF via pikepdf/pypdf:
/AFRelationshipmatches the profile, exactly one embedded file namedfactur-x.xml, XMP extension schema present withfx:ConformanceLevelspelled the way the spec wants it (for EN 16931 that is the string with the space). - veraPDF against the PDF/A-3 profile, fail on any error.
- Mustang validate, fail unless every summary is valid.
- KoSIT validator with the scenario set matching the invoice's issue date, when XRechnung is in scope.
Pin the versions of all three tools and of the rule packs; when you bump any of them, re-run the matrix of known-bad fixtures first. Speaking of which: invowerk.dev/pruefstand publishes fixtures that cover both BR-HYBRID-11 values, the DE-01/02 pairs, a carrier that is not PDF/A, and PDF/A-4f — their own files CC0, the ones taken from mustangproject and the ZUGFeRD corpus under Apache-2.0. Plugging those straight into the fixture set is the fastest way to find out which of the four blind spots your current gate has.
Full disclosure: I'm the founder of PDFik, a PDF generation API that produces Factur-X hybrids, and the gate above is literally the one our release pipeline runs. Nothing in this post needs the hosted service — every check works the same against files from horstoeko, akretion or your own composer.
Top comments (0)