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,pathlibare 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.errorline 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_assessedoutcome 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:
| call | dataset absent | how 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:
| # | Where | The absence | Rendered as |
|---|---|---|---|
| 1 | reference_data_service (this ADR) | a risk table failed to load | an empty table → score 0 → "low risk" |
| 2 | lex/integrity.MIN_SEQUENCE_COVERAGE = 0.85 | up to 15% of a statute's articles never captured | integrity check "passes" |
| 3 | sanctioned_ownership / #914 | the 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_datasets | score | escalations |
|---|---|---|
| intact | 90.0 / CRITICAL | FATF_BLACKLIST |
fatf_black_list + cpi_below_40 emptied | 85.0 / high | — |
| whole block emptied | 42.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_configurationsis the version-of-record — the superseded rows are how a past decision's calibration is reconstructed (EU AI Act Art. 12, AMLR). Rewritingconfig_datawould 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.errornaming 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 fromsource_dateand 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 fordata, 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/Dockerfilecopiesbackend/to/app, so the module lives at/app/app/services/andparents[3]is/— the filesystem root silently standing in for the repository.find_repo_root()walks up for the nearest ancestor holding bothdocs/andbackend/, which is correct at any nesting depth (including a git worktree) and honestly returnsNonein 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/passwdnames 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 areresolve()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-backedsource_urlwas reportedMALFORMEDon 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). ReturningCOMPLIANTwould be worse — "could not check" reading as "checked and fine". So the third answer isUNVERIFIABLE: fail-closed, honestly named, and green in CI and in any checkout, where the repository IS present and the reference therefore MUST resolve. Bundlingdocs/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):
| state | meaning |
|---|---|
compliant | nothing to do |
provenance_gap | a required field is present but null/blank — a declared unknown |
malformed | a 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 |
stale | the source has not been published or re-verified inside the class window |
unverifiable | the check cannot be performed — source_date is missing, unparseable or impossible, or source_url is a repo-relative reference and no repository is present in this deployment |
unclassified | the 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_atwas guarded against a future date; the anchor it is compared against was not — so the stronger of the two inputs was the unchecked one. Asource_dateof2099-01-01makes 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 nowMALFORMEDand refused as an anchor, so staleness falls through toUNVERIFIABLErather than to a silent pass. A validfetched_atdeliberately 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). Sosource_url: 123andfetched_at: {}satisfied the first and were skipped by the second, andcheck_dataset_provenancereturned no findings at all for an envelope that cannot represent a URL or a date. Present is not the same as usable. Scoped deliberately tosource_url/source_date/fetched_at— the fields whose semantic check the rule protects — and not tosource/schema_version, which carry no parse and where two live files legitimately shipschema_version: 2as 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-nullfetched_at, not stale. Today's 10 real violations (8 nullfetched_at, 2 stale) are declared inDECLARED_PROVENANCE_EXCEPTIONSwith 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:
TestStartupGuardIsReachableAST-asserts that bothapp/main.pyandapp/worker.pycallassert_reference_data_loadable()— a loader that raises only when someone constructs the service is inert if nobody constructs it at boot.TestTheRepairIsReachableFromEveryScoringReadAST-asserts thatget_active_configandget_version— audited to be the only two functions supplying a config tocompute_eba_risk— route through_prepare_config_for_read. Deliberately not asserted for_active_config_data_director the_config_digestreads, 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) andsecrecy_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
masterwith 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, updatesource_date+fetched_at). - A deployment with a broken reference file now fails to start. Intended.
dataset_types.jsonshows a large diff, almost entirely re-indentation from being wrapped in the envelope (git diff -wshows only the wrapper).
Honest gaps (recorded, not claimed covered)
- No live URL resolution.
source_urlis 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 reportUNVERIFIABLEon every boot of the production image, becausedocs/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_atis 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 insource_date, made explicit rather than assumed, and the reasonfetched_atmay only ever extend freshness from a recorded date and never widen a window.schema_versionis declared but not validated. Every file asserts"1.0.0"; nothing yet checksdataagainst 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-dataand 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 duplicateReferenceDataService— whichtests/test_compliance_package_unbundled_928.pynow pins bidirectionally. Re-checked rather than left as a stale warning: there is no second loader inpackages/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
Outcometyped-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