Skip to main content

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.py emits it when VIES is unreachable.
  • backend/app/services/peppol_persistence_service.py normalises pre-existing VAT_INVALID + FLAG rows 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 type alone, rather than having to branch on severity — 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.md carries 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_INVALID where the API reports VIES_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_vies returns None after exhausted timeouts, connection errors and terminal HTTP errors, and those paths do not reach the branch that emits VIES_UNAVAILABLE. So the type distinguishes the unavailable case where it is signalled, and a subset of failures still resolve through the None path. 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_INVALID for both, and tell consumers to branch on severity.
  • 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/FLAG rows 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 + FLAG means something other than VAT_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_INVALID keeps 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.