ADR-0165: PEPPOL Verify's no-LLM claim is withdrawn, and a withholding check that did not run is a visible FLAG
Date: 2026-08-01 Status: Accepted Supersedes: ADR-0006 Deciders: Adrian (Soft4U BV), Claude Opus 5
Supersedes: ADR-0006 (PEPPOL Verify as Synchronous REST API)
Decision context:
- Latency: unchanged. The Rule 3c change is a constructor call on a branch that
previously returned early; the rest of this ADR is a records correction with no
runtime path. The PEPPOL latency figure is separately contested and deliberately
NOT re-asserted here (issue #949).
- Dependency surface: none added. No new package, no new service, no new config key.
- Debuggability: improves. A withholding check that produced nothing now reaches the
officer as `INHOUDINGSPLICHT_UNAVAILABLE` from every caller rather than from only
the callers that happened to build a check object.
- Reversibility: minutes. The guard is six lines in one function
(`risk_engine.py:101-106`); the document changes are text.
- Blast radius: additive. One new flag can appear on a verification that previously
carried none; no existing flag's type, severity or wording changes (pinned by
`test_risk_engine_withholding_gap_945.py:146-148`).
- Alternative considered: put the guard in the PEPPOL bridge
(`peppol_verification_service.py:169-170`). Rejected — it fixes one caller, and the
invariant has to hold for every caller (§ "Why the engine, not the bridge").
Context
ADR-0006 decided how PEPPOL Verify is deployed (a synchronous REST API on the existing FastAPI app, not a Temporal workflow and not a microservice). That part of the decision is sound and still in force. This ADR exists because of a different sentence in the same document.
ADR-0006 states, as part of its rationale:
No LLM involvement:
- The entire pipeline is deterministic: API lookups, string matching (Jaro-Winkler), rule evaluation, and structured output
- Zero inference cost, zero hallucination risk — every data point is cited from an authoritative source
That is false as shipped, and it has been false since the withholding (inhoudingsplicht) check joined the verify path — a fourth source ADR-0006 never named. ADR-0006 was written on 2026-02-20 against a three-source pipeline; it was never revisited when the fourth arrived.
The evidence. PeppolVerificationService.verify gathers four branches, not three
(peppol_verification_service.py:115-119). The fourth is the withholding check, and its
determination is produced by a language model:
inhoudingsplicht_service.py:697-699constructs a pydantic-aiAgent[None, _BrightDataExtraction]whose structured output is the determination.- The model is
settings.belgian_scraping_agent_model(:692) =openai:gpt-4.1-mini(config.py:794). - The model drives the fetch: Bright Data's Web Unlocker is attached as an MCP toolset
and the system prompt directs the agent to call
scrape_as_markdownonhttps://www.checkinhoudingsplicht.be/result/{en_short}(:688), so the page enters the model's context as a tool result and leaves it as adebt_free_social/debt_free_taxpair. - Attaching a tool is not requiring it (added on review):
toolsets=[server]makesscrape_as_markdownavailable, and pydantic-ai accepts a_BrightDataExtractionthe model emits having called nothing — in which case no page ever entered its context and both booleans are invention. As first written this record asserted the call as fact while the code acceptedrun_result.outputunconditionally, which is the claim-vs-check pattern this ADR exists to correct, reproduced inside the correction._brightdata_scrapenow REJECTS an output carrying noscrape_as_markdowntool return (_scrape_tool_returned), routing it into the un-assessed branch a Bright Data outage already produces (debt_free_* = None,unavailable=True, GREY, no certificate). Accepted evidence is narrow — aToolReturnPartfor that tool; aToolCallPart, aRetryPromptPart, another tool's return and an unrecognised result shape all read as not fetched (fail-closed, ADR-0067).CACHE_NAMESPACEbumpedv2->v3: entries minted before the gate carry no record of whether the page was read, andcheck_singleserves a cache hit ahead of the gap check. The gate establishes that a page was fetched — it does not establish that it was the right page or that the model read it correctly, which is item 5's territory, still open. - There is no deterministic parse of that page anywhere on the path. The model is the sole producer of those two booleans.
- The fallback path (
belgian_scraping_agent.py:416-432) constructs a second Agent, resolved through the tier table tomid— more expensive than the primary path, not less.
Those two booleans are the inputs to SOCIAL_DEBT_DETECTED and TAX_DEBT_DETECTED
(risk_engine.py:135-148) — the only two FAIL-severity flags in the whole PEPPOL
engine whose truth value originates in a generative model. A FAIL is "do not onboard
without further investigation". So the claim is not merely stale trivia: it is wrong at
exactly the point where it matters most to a reader deciding whether to trust the
service.
The claim is not confined to ADR-0006. It has four renditions, and the ones a non-employee reads are the strongest:
| Where | Line | What it says |
|---|---|---|
docs/adr/ADR-0006-peppol-verify-as-rest-api.md | 25, 38-40 | the canonical record |
docusaurus/trust-relay/docs/adr/0006-peppol-rest.md | 41, 43, 50 | the published rendition — restates it three times, including a "Positive consequence" bullet the canonical does not have |
docusaurus/trust-relay/docs/api/webhooks.md | 46 | "The entire pipeline is deterministic -- no LLM involvement" — directly under a numbered source list whose item 4 is the withholding check |
docusaurus/trust-relay/docs/architecture/cost-monitoring.md | 117 | the claim in operational form: PEPPOL is listed as a "non-LLM wrapper" with "no token tracking since they don't invoke PydanticAI agents" |
The last one is the most consequential, because it is not just a description — it is the
reason the inference cost is unmeasured. agent_executions.cost_eur is populated only
when a caller passes a model and token counts into update_status
(cost-monitoring.md:108-118 documents the five-step bridge). The withholding call is
outside that bridge by design, on the strength of a classification that is wrong. A
document asserting a cost is zero, and a pipeline not instrumented to measure it, are the
same mistake wearing two hats.
The second defect, found while correcting the first. risk_engine.evaluate took
inhoudingsplicht: InhoudingsplichtCheck | None = None and guarded all three withholding
rules on the object being present. inhoudingsplicht is None means the caller produced
no check object at all — the source failed above the boundary, or the branch that builds
the object was removed. Epistemically that is identical to a check that ran and returned
no verdict: the determination did not happen. But only the second shape reached Rule 3c.
This was a latent shape, not a live false clear, and the distinction is load-bearing:
every documented failure today is caught below that boundary and yields a tri-state
None inside a check object that still gets built, so Rule 3c does fire on the live path.
The hole is what happens when someone severs the LLM branch — the exact change issue #945
contemplates. Removing the branch would leave inhoudingsplicht_check = None
(peppol_verification_service.py:169-170), which under the old guard produced no flag
at all. Fixing the model without fixing the guard makes the failure mode worse, not
better.
Decision
1. Withdraw the no-LLM claim in all four renditions, and supersede ADR-0006.
ADR-0006's status becomes Superseded by ADR-0165; its body is left untouched, per the
supersession discipline — the historical record of what was decided on 2026-02-20 is not
rewritten. The deployment decisions it made (REST not Temporal, mounted on the existing
FastAPI app, API-key auth, in-memory rate limiting) are carried forward by this ADR
unchanged. What is not carried forward is the "No LLM involvement" rationale.
The published rendition (0006-peppol-rest.md) is treated asymmetrically and
deliberately: it gets the status flip and a correction banner at the top of the page.
A header field reading Superseded is adequate notice in an engineering register that a
reader arrives at deliberately; it is not adequate notice on a page a prospect lands on
from a search result, three screens above the sentence that misleads them. The banner
does not alter the body. This asymmetry is recorded here so it is a decision rather than
an inconsistency, and because the mirror generator
(docusaurus/trust-relay/scripts/generate-adr-mirror.js) is generate-if-missing and
never overwrites — the published page will not self-heal from a canonical fix and
must be maintained by hand.
webhooks.md:46 and cost-monitoring.md:117 are not ADRs and are corrected in place
with a dated note, in the same style as the PRD.
2. State the cost honestly: not measured, not zero.
The inference line is recorded as not measured. The model and its token prices are
known (openai/gpt-4.1-mini at €0.28/€1.10 per 1M tokens; openai/gpt-5.2 at €2.30/€9.20
— Alembic 046_cost_monitoring.py:76-77, source='manual', effective 2026-04-01, internal
estimates and not vendor invoices). The token volume of a single extraction is not: the
model is fed a whole scraped page whose length nothing in this repository measures, and
the share of verifications falling through to the more expensive fallback is not
instrumented either.
Readers are not pointed at agent_executions.cost_eur for this number, because that
column does not contain it — see the instrumentation gap above. Pointing at telemetry that
was never wired would replace a false zero with a false promise. The correct statement is
that the line is unmeasured and that measuring it requires instrumenting the call
first; that work is tracked, not claimed.
3. Rule 3c fires when the withholding source wholly fails.
risk_engine.evaluate normalises an absent check object into an explicitly un-assessed
InhoudingsplichtCheck before any rule runs (risk_engine.py:101-106). Both shapes —
"ran, no verdict" and "no check object at all" — then travel the same path, so Rule 3c
fires for both, for every caller.
Why the engine, not the bridge
The obvious place for this guard is the PEPPOL bridge, where the None is produced
(peppol_verification_service.py:169-170). That was rejected. The bridge stops
constructing the object on exactly one failure path; the invariant that must hold is
"a withholding check that did not run is a visible FLAG, never a silent clean pass", and
that is a property of the engine's contract, not of one caller's error handling. Placing
it in the engine means a future caller — a second bridge, a batch re-screen, a test
harness — inherits the invariant instead of having to remember it. It also means a later
rule cannot reintroduce the hole, because None no longer exists downstream of line 106.
What this ADR does not decide
- Whether the model stays. Issue #945 item 5 asks whether a generative model may sit anywhere on a path producing a FAIL-severity regulatory flag. That decision is blocked on a factual question this repository cannot answer — whether RSZ/ONSS operate a licensable Art. 30bis/30ter consultation channel. This ADR records that the model is there and bounds what its failure can claim. It does not bless it.
- The sub-2-second latency claim. ADR-0006 asserts it twice and the published
rendition four times; it is contested by issue #949 with its own evidence. It is
deliberately not re-asserted in this ADR. Superseding ADR-0006 must not become the
mechanism by which a second unverified claim is laundered into a fresh
Accepteddocument. - The VIES-unavailable flag variant.
risk_engine.py:171-176emitsVAT_INVALIDatFLAGseverity when VIES is unavailable, reusing the same flag type as a confirmed invalid VAT atFAIL. So "the VAT is invalid" and "we could not check the VAT" are distinguishable by severity and description but not bytype— precisely the conflation thatINHOUDINGSPLICHT_UNAVAILABLEexists to avoid on the withholding side (packages/trustrelay-models/src/trustrelay_models/peppol.py:36-39). 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.
Consequences
Positive
- The document a commercial counterparty reads no longer claims a property the service does not have. That is the whole point: an overclaim in a PRD or a published ADR is the same defect class as a false clear in the product.
- The failure mode is bounded structurally rather than by convention. An extraction that
determines nothing yields
INHOUDINGSPLICHT_UNAVAILABLEfrom every caller, so severing the LLM branch — the change #945 contemplates — can no longer silently turn every failed check into no flag at all. - The instrumentation gap is now named in the place that caused it
(
cost-monitoring.md), so the next person to ask "what does this cost?" is told the truth instead of being sent to an empty column.
Negative
- The €2.00-per-verification economics can no longer be stated as a single number. Every margin figure becomes an upper bound with an unmeasured line beneath it, which is weaker commercial material than the 97% it replaces. It is also true, and the previous figure was arrived at by booking a real cost at zero.
- A verification that previously carried no flag can now carry
INHOUDINGSPLICHT_UNAVAILABLE, which costs the caller a manual review. This is the intended direction (scrutiny added, never suppressed) but it is a real cost to a customer whose withholding source was merely flaky. - Superseding ADR-0006 means a reader must now follow one hop to get the current deployment decision. The alternative — editing ADR-0006 in place — would have destroyed the record of what was actually decided in February, which is worse.
- Two claims in ADR-0006 are now in different states: one withdrawn here, one contested elsewhere (#949). Until #949 lands, the honest reading of the latency assertion is "unverified", and nothing in this ADR makes that visible on the published page beyond the supersession banner.
Neutral
- No migration, no configuration flag, no new dependency. The guard is unconditional because a fail-closed default needs no rollout ramp — there is no state in which emitting the gap flag is the wrong answer.
- The evidence hash, response schema and API contract are unchanged; consumers see at most one additional flag object of an already-published type.
Alternatives Considered
Alternative 1: Correct the PRD only, leave ADR-0006 Accepted
- Fix the PRD (which this PR does) and leave the ADR register alone.
- Why rejected: it produces two live documents that contradict each other, one of
which is the architectural record of record and remains
Accepted. An engineer readingdocs/adr/would find a binding decision asserting no LLM; an engineer reading the PRD would find the opposite. The published rendition andwebhooks.mdwould keep the claim in front of customers regardless of what the PRD says. The finding that prompted this work names precisely this outcome as the failure.
Alternative 2: Edit ADR-0006 in place — strike the "No LLM involvement" section
- Delete or rewrite the offending rationale inside the existing Accepted ADR.
- Why rejected: it destroys the record. ADR bodies are immutable once Accepted (s4u-adr §10); the decision on 2026-02-20 genuinely was made partly on a no-LLM premise, and erasing that erases why the service was scoped as it was. ADR-0122 is the in-repo precedent for the opposite move — retiring something by writing a new ADR that cites it. Silently editing an accepted record to match current reality is the documentation form of overwriting an audit row.
Alternative 3: Give the guard to the PEPPOL bridge instead of the engine
- Construct the un-assessed check object in
peppol_verification_servicewhere theNoneoriginates. - Why rejected: covered above — it binds one caller, and the invariant is a property
of the engine's contract. It also leaves
Nonealive insideevaluate, so any rule added later can reintroduce the hole.
Alternative 4: Estimate the inference cost rather than record it as unmeasured
- Pick a plausible token count per page, multiply by the known per-token prices, publish a number.
- Why rejected: it would replace an unsourced zero with an unsourced non-zero, in a document whose whole correction is that its numbers were not sourced. The page length is not measured and the fallback-path share is not instrumented, so any figure would be invented. "Not measured" is the honest state and is what the reader needs in order to know the number is missing rather than small.