Skip to main content

ADR-0163: Reference-data provenance contract, staleness classification, and a fail-closed loader

Date: 2026-08-01 Status: Accepted Deciders: Adrian (Soft4U BV), Claude Opus 5 (implementation agent) Issue: #937 (epic #927)

Decision context:

  • Latency: unchanged on the hot path. The loader reads the same 17 files once per process; the added work is a roster set-difference and an emptiness test per file. The provenance check is pure and runs in the test suite and once at boot, never per case.
  • Dependency surface: zero new packages (json, datetime, urllib.parse, pathlib are stdlib). One new pure module, one new startup guard function, no model or DB change, no migration, no feature flag.
  • Debuggability: a load failure now names every failing dataset and the reason in one message, at boot, in both the API and the worker. Before, it was a single logger.error line in a stream nobody reads, followed by a wrong number.
  • Reversibility: the loader change is ~60 lines and reverting restores the previous swallow-and-continue behaviour. The data-file edits are additive keys. The provenance module has no runtime consumer other than a boot-time warning, so it can be deleted without touching a scoring path.
  • Blast radius: any deployment missing or corrupting a declared reference file now fails to boot instead of scoring cases wrongly. That is the intended change and it is the only behavioural change. With the files intact, every score is byte-identical (pinned by an existing EBA byte-stability test plus a new before/after assertion).
  • Alternative considered: thread a typed not_assessed outcome through every reference-data consumer instead of failing closed at load — rejected for this issue, see §4.

Context

Issue #937, the last item of epic #927. The epic's other issues correct the state of the reference corpus (re-derive values from primary sources); this one is meant to prevent recurrence.

Three things were verified against the running code before anything was written.

1 — The loader was a silent-pass path (the important finding)

ReferenceDataService._load_all caught json.JSONDecodeError and OSError, logged at ERROR, and continued. A missing config directory logged a warning and returned. In both cases the dataset was simply absent afterwards, and the query API answers absence benignly by contract:

calldataset absenthow the consumer reads it
get_dataset(name)[]"the list is empty"
is_in_dataset(name, v)False"not on the list"
get_risk_score(name, k)None"no risk score for this"

None of those is a "not assessed" channel. Traced to the call site in eba_risk_matrix._get_country_risk_score, a False from is_in_dataset("fatf_black_list", …) falls through every branch to return 10.0 — the line commented "Default: low risk (OECD, other clean jurisdictions)".

Measured end-to-end on the real scorer (compute_eba_risk), same input, only the presence of three files changed:

EBARiskInput(jurisdiction="IR", operational_countries=["IR"],
business_profile="gambling", pep_level="head_of_state")

files present : overall 90.0 CRITICAL auto_escalations=['FATF_BLACKLIST']
3 files deleted: overall 42.5 MEDIUM auto_escalations=[]

An Iranian gambling company with a head-of-state PEP becomes a medium-risk, approvable case. Nothing raises; one log line is the entire signal. That is precisely the "report clear when no check ran" that ADR-0067 forbids, reached by a deployment defect rather than a data gap.

A second instance of the same shape sat in load_from_config: self._datasets.update(ref) accepted {"fatf_black_list": []} and replaced the authoritative list with nothing. (That method has no production caller today — the guard added there is defence in depth on a currently-inert path, and this ADR says so rather than claiming a live fix.)

A third, and this one was live on the authoritative path: risk_config_service._build_default_config was a second, independent reader of the same JSON files, with except (OSError, json.JSONDecodeError, KeyError): reference_datasets[key] = []. Worse than the loader instance, because that empty list was then persisted into the audited, versioned risk configuration and handed to compute_eba_risk(inp, config) as ref_datasets — where _get_country_risk_score does code in (ref_datasets.get("fatf_black_list") or []) and falls through to the same return 10.0. The bad value outlived a restart and survived the underlying file being fixed. The existing config validator does not cover it: it checks two of the eleven datasets, and only on a user-submitted config, while this injects on the default-build path.

2 — This is the third instance of one defect class

The same shape has now been found three times in this codebase, in three unrelated subsystems:

#WhereThe absenceRendered as
1reference_data_service (this ADR)a risk table failed to loadan empty table → score 0 → "low risk"
2lex/integrity.MIN_SEQUENCE_COVERAGE = 0.85up to 15% of a statute's articles never capturedintegrity check "passes"
3sanctioned_ownership / #914the ownership graph is unavailable"no sanctioned party in the structure"

The class: an absence of input is rendered as a benign value rather than as a typed absence. Each instance is individually plausible — an empty list is a list, 85% is most of a law, an empty graph does contain no sanctioned party — which is why each survived review on its own terms.

The structural remedy is not a fourth bespoke guard. It is the Outcome typed-absence vocabulary already built and unified for the OSINT layer (#514/#853, ADR-0123): a value and a not-assessed marker are different types, so a consumer cannot read one as the other by accident. This ADR does not extend Outcome into the reference-data or Lex layers — that is a larger piece of work across every consumer signature, recorded here as the named follow-up rather than half-started. §4 explains why the reference-data instance is nonetheless correctly fixed by failing closed instead.

3 — The envelope was a convention with no contract

The ADR-0141 envelope (list_key/type_id/name/source/source_url/source_date/ data) was followed by most files and required by none. Measured before this change: dataset_types.json was a bare array with no provenance at all and a special case in the loader; declared_signal_vocabulary.json was missing list_key/type_id/name; regulator_registers.json carried source_url: ""; pep_tiers.json and sanctions_defaults.json carried source_url: null and cite "ADR-008a Default Configuration", a document that does not exist in this repository; declared_signal_vocabulary.json carried source_url: "internal". No file carried fetched_at or schema_version, and no code anywhere asked whether a dataset's source had moved on.

Decision

1 — The loader fails closed on a declared dataset (the durable fix)

REQUIRED_DATASETS is a frozenset in reference_data_service.py: the roster of datasets that must load. _load_all now raises ReferenceDataIntegrityError, naming every failure at once, when the config directory is absent, or when any declared dataset is missing, unparseable, or carries an empty payload.

The empty-payload rule matters as much as the parse rule: a file that loads as {} produces exactly the same benign answers as a file that did not load, so treating "loaded" as success would leave the hole half-open.

And so does the envelope rule (added in review — Codex on PR #959 found the hole still open by one shape). A declared file that is valid JSON but has lost its data key used to fall through to the permissive bare-payload branch, which took the remaining metadata object as the dataset. It is non-empty, so the emptiness rule passed; it is present, so the roster rule passed; and then is_in_dataset("fatf_black_list", "IR") tested membership in {"list_key": …, "source": …} and answered False. Measured on the real scorer with only data removed from fatf_black_list.json: 85.0/high, auto_escalations=[] instead of 90.0/CRITICAL with FATF_BLACKLIST — this loader's own headline defect, reached through the one input shape it still accepted. A declared dataset without a data envelope is now a named load failure. The bare-payload branch survives only for undeclared files, where the roster is a floor rather than a whitelist and nothing depends on the risk posture of a file nobody declared.

The roster must be declared rather than inferred from the directory listing, because without it "the whole directory is missing" and "we expect zero datasets" are the same observation — the silent pass reproduced inside its own guard.

load_from_config refuses an empty override of a declared dataset (the file-loaded value survives) and logs it loudly. It refuses rather than raises because a risk configuration is edited at runtime by a tenant: refusing keeps the authoritative list in place, which is the never-suppress direction, while raising would only convert a silent wrong answer into a 500 on an unrelated screen.

risk_config_service._build_default_config no longer reads the files itself — the duplicate reader and its = [] fallback are deleted, and the embed now goes through the one guarded loader. Deep-copied, which is load-bearing and was not in the first cut: get_dataset returns the singleton's stored object by reference, and a risk config is handed to callers who edit it (the admin Reference Datasets tab is exactly that). Without the copy, one tenant editing their config would mutate the process-wide authoritative lists for every other tenant scored by the same worker. This was caught by a test-ORDERING failure — running the risk-config suite before the EBA suite dropped two PEP assertions, because a config edit in an earlier test had rewritten the shared pep_tiers dict — and is now pinned directly rather than left to suite order.

assert_reference_data_loadable() in app/startup_guard.py runs the load at boot from both app/main.py and app/worker.py, matching the existing assert_production_safe / ensure_pii_key_available pattern — a worker that booted while the API refused would execute exactly the activities the API rejected.

1b — …and the rows the deleted fallback already wrote

Deleting _build_default_config's = [] fallback stops new empties being written. It does nothing for the rows written before it, and those rows are what the live path reads (Codex, PR #959):

reassess_risk_activity → get_active_config → config_data
→ compute_eba_risk(inp, config)
→ _get_country_risk_score: code in (ref_datasets.get("fatf_black_list") or [])
→ return 10.0 # Default: low risk

get_active_config returned any non-empty stored config_data unchanged, so for a tenant whose configuration was auto-seeded while a file was unreadable, the collapse survives both a repaired file and a restart. Measured on the real scorer against a persisted configuration (not a deleted file), same input each time:

persisted reference_datasetsscoreescalations
intact90.0 / CRITICALFATF_BLACKLIST
fatf_black_list + cpi_below_40 emptied85.0 / high
whole block emptied42.5 / MEDIUM

The bottom row is ADR-0163's own headline figure, reached from the database instead of from deleted files. A guard that only protects the future leaves the past in production.

The fix is a read-time clamp (repair_reference_datasets), not a migration. A config that carries a reference_datasets block is claiming to supply the scorer's risk tables, and compute_eba_risk then reads them instead of the singleton — so the claim must be complete: every declared dataset present and non-empty. Absent and empty are the same silent pass here (.get(k) or [] cannot tell them apart), so both are filled from the authoritative file-loaded value, which the §1 boot guard proves non-empty or the process refused to start. One-directional: an empty list contributes no membership and an empty dict yields no score, so restoring can only add risk signal. A config with no reference_datasets key is untouched — it makes no claim, and the scorer correctly falls back to the singleton. A populated tenant edit is never overwritten.

Two alternatives were rejected, and the reasons are the point:

  • A migration rewriting the rows. risk_configurations is the version-of-record — the superseded rows are how a past decision's calibration is reconstructed (EU AI Act Art. 12, AMLR). Rewriting config_data would destroy exactly the evidence that a tenant was scored against an empty list, and would alter an audited row with no audit trail. So nothing is written: the stored row keeps telling the truth, and only the value served is corrected. Pinned by a test that re-SELECTs the row after a read and asserts it is byte-identical.
  • A loud startup check naming the affected rows. It would name them and then let them score anyway — detection without response, the failure this codebase already has a name for (ADR-0096). The clamp is the response; the loud logger.error naming tenant, config id and keys is kept as the surfacing.

This is the module's established shape, not a new mechanism: cadence_map_from_config, art19_thresholds_from_config, art19_customer_scope_from_config and profile_deviation_band_from_config all clamp a persisted config value toward scrutiny at read time without writing. This is the fifth, and the only one whose absence was measured to flip a verdict. It is applied at get_active_config (the scoring path) and get_version (the preview path), and outside config_data — a marker inside it would move _config_digest and pollute diff_versions, and the ADR-0070 four-eyes binding must keep digesting exactly what is stored.

How a bad row actually gets fixed: create_draft clones get_active_config's config_data, so the officer's next version carries the corrected datasets with their own rationale on the audit row — the repair enters the record the way every other calibration change does. The admin display path (_get_active_version_row) deliberately still shows the stored row: displaying the repaired value there would hide the defect the clamp exists to compensate for. An explicit UI disclosure of "this row is being overridden" is not built and is recorded here as a gap, not claimed.

validate_config rule 6 is widened from 2 declared datasets to all 11 in the same change. fatf_grey_list, cpi_below_40, pep_tiers and the rest are read by compute_eba_risk exactly as the two originally-protected lists are; two protected and nine not was a coincidence of which two somebody thought of, not a rule. Present-and-empty only — an absent key is left to the read-time clamp, so a legitimately partial config edit is not rejected.

2 — A five-field provenance contract

PROVENANCE_FIELDS = (source, source_url, source_date, fetched_at, schema_version). The first three are the ADR-0141 envelope; #937 adds two:

  • fetched_at — when we last went and looked. It answers a different question from source_date and neither substitutes for the other: a source published in 2024 and retrieved yesterday is current; a source published yesterday and retrieved in 2024 is not.
  • schema_version — the version of our shape for data, independent of whatever the publisher does to theirs.

Every file now carries all five, including dataset_types.json, which was wrapped in the standard envelope so that no file is exempt from the contract (get_dataset returns the identical list; the loader unwraps data).

fetched_at is set only where it is true: where the file's own source string records a research date, or where the artifact is authored in-repo by us. For the ten third-party datasets where nobody recorded a retrieval, it is null — a declared gap. Back-filling a plausible date would have been fabricated provenance, which is the exact thing epic #927 exists to remove.

Only keys actually present in the file are retained. Building the envelope with raw.get(key) synthesized every declared key with None, which collapsed the one distinction this contract is built on: a key absent entirely (nobody ever considered it — MALFORMED, a schema defect) versus a key present and null (we recorded that we do not know — PROVENANCE_GAP, a declared gap with an owning issue). With the keys synthesized, deleting fetched_at outright from a file reported as the already declared null exception, the ratchet stayed green, and the field not in envelope branch was unreachable for every loaded dataset — a live guard that could not fire. (Codex, PR #959.)

source_url is checked for resolvability, in two accepted forms: an absolute http(s) URL with a host, or a repo-relative path to a file that exists inside the repository. The second is not a weakening — for a judgement recorded in an ADR it is stronger provenance than a URL, being pinned by git and verifiable offline, and a renamed ADR is caught. A bare word like "internal" satisfies neither and is malformed in every deployment: it names nothing a reviewer could go and read.

Three corrections to how that second form is resolved, all from PR #959 review and all consequences of one root cause — parents[3] is a claim about how deeply this module is nested below the repository root, and it is only true in a source checkout:

  • The root is discovered by marker, not by depth. backend/Dockerfile copies backend/ to /app, so the module lives at /app/app/services/ and parents[3] is / — the filesystem root silently standing in for the repository. find_repo_root() walks up for the nearest ancestor holding both docs/ and backend/, which is correct at any nesting depth (including a git worktree) and honestly returns None in the image.
  • Containment is enforced, not assumed. exists() answers "is there a file at the end of this string", which is not the property claimed. The claim is committed and reviewable, so the resolved path must be a descendant of the root; ../../etc/passwd names a real file and is not provenance anyone can review. Whether it passed varied with checkout depth, and in the container — where the root degenerated to /Path("/") / "../etc/passwd" exists, so it passed outright. Both sides are resolve()d before comparison, so .. and symlinks are normalised first rather than after.
  • Where there is no repository, the check is not performed — and says so. docs/ is not copied into the image, so before this every ADR-backed source_url was reported MALFORMED on API and worker boot: five permanent false alarms per process, and a control that cries wolf is a control someone switches off (the ADR-0121 lesson). Returning COMPLIANT would be worse — "could not check" reading as "checked and fine". So the third answer is UNVERIFIABLE: fail-closed, honestly named, and green in CI and in any checkout, where the repository IS present and the reference therefore MUST resolve. Bundling docs/ into the image was considered and rejected: the property is a property of the repository, it is fully verified on every PR, and shipping documentation into a runtime image to satisfy a boot-time check is the wrong direction.

The check is deliberately not a network fetch. A gate that depends on a third-party server being reachable goes red for reasons unrelated to the repository and gets switched off, and an HTTP 200 would not prove the URL still names the cited document (the ADR-0121 lesson: referential soundness is not correctness). This is the honest limit of the check and it is recorded rather than glossed.

3 — Staleness per source class, five states, fail-closed

reference_data_provenance.py is the single interpreter. It returns five states, not a boolean, because the failure kinds have different owners and different remedies (the ADR-0119 pattern):

statemeaning
compliantnothing to do
provenance_gapa required field is present but null/blank — a declared unknown
malformeda required field is absent; a field that must carry a URL or a date is not even a string; source_url is neither a well-formed absolute http(s) URL nor a path inside the repository; or source_date/fetched_at is unparseable or dated in the future
stalethe source has not been published or re-verified inside the class window
unverifiablethe check cannot be performedsource_date is missing, unparseable or impossible, or source_url is a repo-relative reference and no repository is present in this deployment
unclassifiedthe dataset has no declared source class

unverifiable and unclassified both fail closed: "could not check" must never read as "checked and fine", and a new reference file cannot ship without someone deciding how quickly it goes stale.

Windows are publisher cadence + 90 days grace, per source class, each with its cadence and rationale recorded in code. The generalised shape is the existing in-repo precedent — the art62_bo_dataset.currency block in dataset_types.json (ADR-0136), which pairs an explicit window with explicit bands and the words "never silently current". Deliberately kept as separate vocabularies: that block governs the cadence of a computed BO record, this module governs the cadence of a reference file's source. Conflating a computation clock with a publication clock is the taxonomy-conflation defect this codebase keeps finding (ADR-0094, ADR-0113).

The clock runs from the later of source_date and fetched_at — the publisher issued this edition, or we went and checked it is still the current one. The first cut measured source_date alone, which made the arithmetic disagree with the windows' own stated meaning: every window is a re-verification cadence, and four of the nine classes say so in their own rationale ("not the point of expiry", "Annual re-verification, not expiry", "our review cycle", "reviewed annually"). Under a publication-only clock, an edition that is old but still current — an unamended EBA guideline, a biennial index whose next edition is not out — could never be cleared by doing the work and recording it; the only move that cleared the finding was writing today's date into source_date, i.e. claiming a publication that did not happen. A gate whose cheapest remedy is fabricated provenance is pointed at epic #927's own target. (Codex, PR #959.)

This is a relaxation, in exactly one direction, and it is bounded three ways. It applies only where somebody recorded a real, past re-verification in the field that means precisely that. fetched_at is itself validated before it can buy any freshness — present-but-unparseable, or dated in the future, is MALFORMED and counts for nothing, because an input that can only make the answer look better must be checked or the relaxation is unguarded. And where fetched_at is null — the ten third-party datasets, including both currently-stale ones — the clock is unchanged. Measured after the change: the live findings are byte-identical, and both stale exceptions still reproduce, so nothing was silently cleared.

Both inputs to that clock are now validated, and so is their type. Two further holes of the same shape were found in review (Codex, PR #959), and both are the "an input that can only make the answer look better must be checked" rule applied where it had not been:

  • A future source_date. fetched_at was guarded against a future date; the anchor it is compared against was not — so the stronger of the two inputs was the unchecked one. A source_date of 2099-01-01 makes the age negative, no class window can ever be exceeded, and the dataset reports itself perpetually fresh: one impossible date keeps a superseded FATF list compliant for seventy years, and the gate that exists to catch exactly that says nothing. It is now MALFORMED and refused as an anchor, so staleness falls through to UNVERIFIABLE rather than to a silent pass. A valid fetched_at deliberately does not rescue it: an envelope carrying a date nobody could have published on has not earned a relaxation.
  • A wrong-typed URL or date field. The completeness check counted any present, non-null value as satisfied, while every semantic check below it is guarded by isinstance(..., str). So source_url: 123 and fetched_at: {} satisfied the first and were skipped by the second, and check_dataset_provenance returned no findings at all for an envelope that cannot represent a URL or a date. Present is not the same as usable. Scoped deliberately to source_url/source_date/fetched_at — the fields whose semantic check the rule protects — and not to source/schema_version, which carry no parse and where two live files legitimately ship schema_version: 2 as an integer. Widening it there would add findings nobody can act on, which is how a control earns the reputation that gets it switched off (ADR-0121). The narrow scope is pinned by a control test so it reads as a decision, not an oversight.

The classes live in code, not in the JSON. A dataset must not certify its own freshness requirement: if max_age_days sat in the envelope, the cheapest way to fix a red staleness test would be to widen the window inside the very file being checked — the file grading its own homework. This mirrors ADR-0123's rule that a source never asserts its own trust. Note the difference from fetched_at: a date we verified on is a fact the file is entitled to record and that the gate then validates, whereas a window we are graded against is not.

4 — Why fail-closed here rather than a typed not_assessed

The doctrinally pure fix for §2's defect class is a typed absence threaded to every consumer. It is rejected for this issue, for a reason specific to this input:

Reference data is deployment input, not runtime evidence. A missing FATF list is not a fact about the customer that we failed to learn; it is a broken build. There is no honest per-case answer to give, because every case in the deployment is equally affected. Rendering it as a per-case not_assessed would spread one deployment defect across thousands of cases as thousands of individually-plausible data gaps — the loudest possible signal converted into the quietest.

Failing at boot is therefore both the safer and the more honest option here, and it is the same rule app/startup_guard.py already states: for this product a crash is cheaper than a wrong answer. Instances 2 and 3 of the class in §2 are genuinely runtime evidence and do need typed absence; that remains their fix, not this one.

5 — The test is the enforcement, ratcheted

tests/test_reference_data_service.py is extended, not competed with. Two tiers:

  • Tier 1, no exceptions possible — every declared dataset has all five provenance keys present, a parseable source_date, and a declared source class. This is true of all 17 files as of this ADR, so the tier is green and exception-free.
  • Tier 2, ratcheted — resolvable source_url, non-null fetched_at, not stale. Today's 10 real violations (8 null fetched_at, 2 stale) are declared in DECLARED_PROVENANCE_EXCEPTIONS with a reason and an owning issue, and pinned in both directions: an undeclared violation fails, and a declared exception that no longer reproduces fails, forcing its removal. A declared gap that starts resolving must not be able to sit there forever (ADR-0119, ADR-0121).

The existing roster pin test is rebound to REQUIRED_DATASETS so the test literal and the loader constant cannot drift apart.

Two further structural pins, both instances of guard reachability is not correctness:

  • TestStartupGuardIsReachable AST-asserts that both app/main.py and app/worker.py call assert_reference_data_loadable() — a loader that raises only when someone constructs the service is inert if nobody constructs it at boot.
  • TestTheRepairIsReachableFromEveryScoringRead AST-asserts that get_active_config and get_version — audited to be the only two functions supplying a config to compute_eba_risk — route through _prepare_config_for_read. Deliberately not asserted for _active_config_data_direct or the _config_digest reads, which are raw on purpose: they compare stored to stored, and clamping them would move the ADR-0070 four-eyes binding away from what is actually persisted. The exclusion is recorded in the test rather than left to be rediscovered.

Every guard added here was mutation-tested — break it, watch the named test fail, restore it, watch it pass — with the anchor proved unique and the file bytes proved changed each time, so a mutation that silently failed to apply cannot be scored as a kill. Data mutations are included alongside code ones: removing Iran from fatf_black_list.json, moving a live source_date into the future, and replacing a live source_url with an integer are each caught by the gates above.

Consequences

Positive

  • A missing, corrupt or emptied risk table can no longer be scored as an absence of risk. The measured 90.0/CRITICAL → 42.5/MEDIUM collapse is now a refusal to boot.
  • There is now one reader of config/reference_data/ with one failure posture, instead of two with opposite ones.
  • The reference lists embedded in a risk config are copies, so a tenant's config edit can no longer mutate the authoritative lists for every other tenant in the process.
  • A new reference file cannot ship without provenance, a source class, and a place in the declared roster.
  • Two genuinely stale datasets became visible the moment the check first ran: eu_tax_blacklist (297 days, past the 273-day ECOFIN window — the list has a published biannual revision cycle) and secrecy_jurisdictions (822 days, past the 820-day biennial window — more than a full publication cycle has elapsed). Both were serving silently. Note what the gate does and does not establish: it proves the publisher has had time to supersede these values, not that they have been superseded. Confirming that is #929's job, and the exception entries say so.
  • The 10 datasets with no recorded retrieval date are now an explicit, reviewable inventory rather than an unasked question — which is directly the information epic #927 was opened to surface.

Negative / accepted

  • The staleness test will go red on master with no code change, when a window elapses. That is the control working, not a defect; the failure message names the remedy (re-derive from the primary source, update source_date + fetched_at).
  • A deployment with a broken reference file now fails to start. Intended.
  • dataset_types.json shows a large diff, almost entirely re-indentation from being wrapped in the envelope (git diff -w shows only the wrapper).

Honest gaps (recorded, not claimed covered)

  • No live URL resolution. source_url is checked for shape and, for repo paths, existence-inside-the-repository. A URL that 404s today still passes. §2 explains why, and a link-rot sweep would be a separate scheduled job, not a PR gate.
  • Repo-relative references are unverifiable in the container, by design. Five ADR-backed source_urls report UNVERIFIABLE on every boot of the production image, because docs/ is not shipped there. That is the honest state, not a defect to suppress: the reference IS checked, and must resolve, in CI and in any checkout.
  • fetched_at is self-asserted. The gate validates that it parses and is not in the future; it cannot tell whether anybody actually went and looked on that date. This is the same trust the contract already places in source_date, made explicit rather than assumed, and the reason fetched_at may only ever extend freshness from a recorded date and never widen a window.
  • schema_version is declared but not validated. Every file asserts "1.0.0"; nothing yet checks data against a schema for that version. The field is the seam; the validator is not built. connector_contracts.json (ADR-0143) already does this for connector responses and is the model to follow.
  • The provenance module has no officer-facing surface. Findings are enforced in the test suite and logged at boot; there is no GET /monitoring/reference-data and no UI. Deliberately not half-built.
  • The package copy: closed by #928, re-verified, not assumed. When this ADR was first drafted, packages/trustrelay-compliance/src/trustrelay_compliance/reference_data/ carried its own loader with the identical swallow-and-continue behaviour plus a stale subset of the data (12 files, byte-identical to the backend's but missing the 5 datasets added since) — the ADR-0122 drifted-package-copy shape. It was left alone because #928 owned that file set and was in flight, and this ADR recorded it so that closing #928 without addressing the loader would be a visible omission. On rebase, #928 had landed and deleted the whole directory — data and the duplicate ReferenceDataService — which tests/test_compliance_package_unbundled_928.py now pins bidirectionally. Re-checked rather than left as a stale warning: there is no second loader in packages/ today (grep -rn "class ReferenceDataService" packages/ returns nothing). The gap is closed by someone else's work, and this ADR says so rather than continuing to claim an open risk that no longer exists.

References

  • Issue #937, epic #927
  • ADR-0067 — fail-closed compliance outputs and the "not assessed" contract
  • ADR-0119 / ADR-0121 — five states not a boolean; ratcheted exception blocks; the limits of a referential check
  • ADR-0123 / #514 / #853 — the Outcome typed-absence vocabulary (the structural remedy for the defect class in §2)
  • ADR-0136 — art62_bo_dataset.currency, the in-repo staleness precedent generalised here
  • ADR-0141 — the reference-data envelope this contract makes mandatory
  • ADR-0146 — tests/test_activation_flag_state.py, the inventory-as-constant test idiom
  • ADR-0122 — the drifted package copy precedent (see honest gaps)
  • Issue #914 — instance 3 of the defect class in §2