ADR-0193: "We could not check the VAT" gets its own flag type
Date: 2026-08-23 Status: Accepted Deciders: Adrian Birlogeanu (project owner), Claude Opus 5, Codex (review) Corrects the deferral in: ADR-0165 Number: 0192 is allocated to the phase-condition gate recovered by #1160 (PR #1189); this ADR takes the next free number.
Context
ADR-0165 recorded a type collision on the PEPPOL risk surface. risk_engine.py
emitted VAT_INVALID at FLAG severity when VIES was unavailable, reusing the
same flag type as a confirmed invalid VAT at FAIL. So "the VAT is
invalid" and "we could not check the VAT" were distinguishable by severity
and description but not by type — precisely the conflation that
INHOUDINGSPLICHT_UNAVAILABLE exists to avoid on the withholding side.
ADR-0165 left it unchanged, and said why:
The behaviour is documented in the PRD by this PR and left unchanged in code: giving the unavailable case its own type is an API-contract change for existing consumers and belongs in the PEPPOL extraction work, not in a documentation correction.
PR #1150 then made the change (merged 2026-08-20, 9605363a, verified an
ancestor of master). So an Accepted ADR told any reader consulting it that the
split had not happened, while the code had already made it — the claim-vs-check
shape pointed at our own decision record (#1154).
This ADR records the decision the code now embodies. It does not supersede ADR-0165, whose subject is the withdrawal of the PEPPOL no-LLM claim; only its deferral clause is affected, and per s4u-adr §10 that ADR's body is immutable — its Status line points here, following the ADR-0178 / ADR-0189 precedent.
Decision
RiskFlagType.VIES_UNAVAILABLE is the type for "the check could not run",
distinct from VAT_INVALID for "the VAT is invalid".
backend/app/services/risk_engine.pyemits it when VIES is unreachable.backend/app/services/peppol_persistence_service.pynormalises pre-existingVAT_INVALID+FLAGrows on read, so stored results acquire the same distinction without rewriting history.
Why the deferral's premise was weaker than it looked — corrected in review. ADR-0165 deferred on the ground that this is "an API-contract change for existing consumers".
A first draft of this ADR claimed there are no existing consumers, on the
ground that PeppolApiKey.issue() has no caller outside a test. That reasoning
was wrong and is recorded here rather than quietly removed: it confuses "no
caller is checked into the repository" with "no consumer exists".
docs/runbooks/pilot-one-time-setup.md has an operator call issue() by hand
and configure the PEPPOL proxy against it, so a deployment following the runbook
does hold a key.
What is true is narrower, and still supports the change: there is no automated issuance path and no partner integration built on this enum, so the population of consumers is operator-controlled and known to the owner rather than open-ended. That makes the change cheap now and progressively less so — which is an argument about cost, not about the absence of a contract.
Consequences
Positive
- A consumer can distinguish "invalid" from "unchecked" by
typealone, rather than having to branch onseverity— which ADR-0165 explicitly warned consumers they must do. - It matches
INHOUDINGSPLICHT_UNAVAILABLE, so the two PEPPOL sub-surfaces now express the same distinction the same way. - The decision record and the code agree again.
Negative
- The window was already open, not closing in future — a second correction
from review.
docusaurus/trust-relay/docs/api/webhooks.mdcarries a partner-facing "Risk Flag Types" table, so this enum has been a published contract for some time;docs/api/openapi.json(#995) widens the audience rather than opening it. That table is updated in this change, because a published contract that omits the new type is the same defect one surface over. Any further change to these values is the breaking change ADR-0165 was trying to avoid. - Read-time normalisation means a stored row and its rendering differ. That is
deliberate — rewriting stored verification results to change a flag type would
alter recorded outputs — but a reader of the raw table sees
VAT_INVALIDwhere the API reportsVIES_UNAVAILABLE.
Neutral
- No migration. The normalisation is a read-path transform.
- Not every unreachable VIES emits the new flag, and the ADR should not be
read as claiming it does (raised in review).
VIESService._validate_viesreturnsNoneafter exhausted timeouts, connection errors and terminal HTTP errors, and those paths do not reach the branch that emitsVIES_UNAVAILABLE. So the type distinguishes the unavailable case where it is signalled, and a subset of failures still resolve through theNonepath. Closing that is a separate change to the service's own typed-absence handling; recorded here so the flag is not over-read.
Alternatives Considered
Alternative 1: Keep the collision and document it
- Leave
VAT_INVALIDfor both, and tell consumers to branch onseverity. - Why rejected: it is the state ADR-0165 itself named as a defect, and the code had already moved. Documenting a collision the code no longer has would keep the register wrong in the other direction.
Alternative 2: Supersede ADR-0165
- Mark 0165
Superseded by ADR-0193. - Why rejected: 0165's subject is the withdrawal of the PEPPOL no-LLM claim and the Rule 3c fail-closed normalisation, both of which stand. Superseding it would retire two live decisions to correct a third. Only its deferral clause is affected, so it stays Accepted with a Status-line pointer — the same treatment ADR-0178 gave ADR-0171 and ADR-0189 gave ADR-0167.
Alternative 3: Rewrite stored rows in a migration
- Update historical
VAT_INVALID/FLAGrows in place. - Why rejected: those rows are recorded verification outputs. Changing what a past verification is recorded as having said, to make it agree with a later taxonomy, is exactly the kind of retroactive edit the audit posture forbids.
Decision context
- Latency: none. A dict-level transform on read; no additional I/O.
- Dependency surface: none added. One enum value in
packages/trustrelay-models. - Debuggability: improved — a consumer no longer has to know that
VAT_INVALID+FLAGmeans something other thanVAT_INVALID+FAIL. The read-time normalisation is the one thing to know, and it is recorded above. - Reversibility: hours while unpublished; a breaking change once the contract ships. That asymmetry is the reason for acting now rather than later.
- Blast radius: additive.
VAT_INVALIDkeeps its meaning for the invalid case; only the unavailable case moves. - Alternative considered: keep the collision and document it — rejected because it is the defect ADR-0165 named, and the code had already moved past it.