Skip to content

Supervisory validation rules — credit-risk extract

Machine-readable extracts of the two supervisory validation-rule sets that apply to the templates this project produces. These JSON files are the committed artefact; the source workbooks live in docs/assets/ and are gitignored, so nothing downstream (tests, CI, tooling) needs the raw spreadsheets.

File Framework Rules Live
crr-eba-v3.0-credit-risk.json CRR (EBA DPM 3.0) 1,011 588
basel31-boe-v4.0.0-credit-risk.json Basel 3.1 (BoE banking v4.0.0) 820 808

What these are for

A validation rule is the supervisor's own arithmetic check on a submitted return — "column 0090 must equal the sum of columns 0050 to 0080". They are the most precise available statement of how the reporting templates are expected to tie out, so they double as an independent test oracle for the COREP and Pillar 3 output this project produces. Both files are generated by scripts/extract_validation_rules.py.

Looking up a rule

Both files load with the standard library and no encoding argument (see Encoding):

import json
from pathlib import Path

BASE = Path("src/rwa_calc/reporting/validations/rules")
eba = json.load(open(BASE / "crr-eba-v3.0-credit-risk.json"))
boe = json.load(open(BASE / "basel31-boe-v4.0.0-credit-risk.json"))

Which rules apply to a template? Match on the tables list — a rule is included if any of its tables matches, so always test with any(...) rather than tables[0]:

live = [r for r in eba["rules"] if r["status"] == ["live"]]
c07 = [r for r in live if any(t.startswith("C 07.00") for t in r["tables"])]
# 195 live rules, of which 124 are ERROR and 71 WARNING

Never evaluate a formula on its own

Neither source's formula is self-contained. An EBA formula needs its rows/columns scope, and 79 BoE expressions carry no row/column binding at all — theirs lives in scope. Reading only the formula turns a one-cell check into a whole-table assertion. See the expression is not self-contained before writing an evaluator.

Always filter on status

The extracts deliberately retain deactivated and deleted rules so that history is not silently lost. Anything asserting current behaviour must filter to r["status"] == ["live"] first — 423 of the 1,011 EBA rules are not live.

What does a rule actually check? A rule with a row scope is evaluated once per listed row, and the formula addresses the other axis:

rule = next(r for r in live if r["id"] == "v0305_m")
rule["rows"]     # ['0010', '0020', '0030', '0040', '0050', '0060', '0070', '0080']
rule["formula"]  # '{c0090} = {c0050} + {c0060} + {c0070} + {c0080}'

reads as: for each of those eight rows of C 07.00.a, column 0090 must equal the sum of columns 0050, 0060, 0070 and 0080. See Formula grammar for the full reference syntax.

Cross-referencing the two frameworks. Most BoE rules name their EBA ancestor, which is how you find the CRR equivalent of a Basel 3.1 check (and vice versa):

by_id = {r["id"]: r for r in eba["rules"]}
pairs = [(b, by_id[b["eba_equivalent"]]) for b in boe["rules"]
         if b["eba_equivalent"] and b["eba_equivalent"] in by_id]
# 426 BoE rules resolve to an EBA rule held in this extract
# e.g. boe_b0190 -> v5745_q

427 BoE rules carry a single eba_equivalent; 426 of those resolve within this extract, the odd one pointing at a rule on a table outside the credit-risk filter. See the eba_equivalents caveat for the 35 rules that name several.

Regeneration

uv run python scripts/extract_validation_rules.py           # rewrite both JSON files
uv run python scripts/extract_validation_rules.py --check   # CI: fail if they would change
uv run python scripts/extract_validation_rules.py --sample 2  # eyeball the parse

--check re-extracts from the workbooks and exits non-zero if the committed JSON differs. It needs both workbooks present in docs/assets/; fetch them with uv run python scripts/download_docs.py.

Encoding

Both files are pure ASCII (verified: zero bytes > 127). The source labels contain typographic dashes, curly quotes and — in one case — a zero-width non-joiner; these are emitted as \uXXXX escapes rather than raw UTF-8. This is deliberate. On a Windows default locale (cp1252) the idiomatic json.load(open(path)) raises UnicodeDecodeError on a UTF-8 artefact, so a non-ASCII file would force every consumer to remember encoding="utf-8". As committed, these load correctly under any locale:

import json
rules = json.load(open("src/rwa_calc/reporting/validations/rules/crr-eba-v3.0-credit-risk.json"))["rules"]

The escapes decode back to the original characters, so string values are lossless. Note that 45 BoE short_label values contain non-ASCII text, and boe_b0452's label begins with an invisible zero-width non-joiner (U+200C, serialised as \u200c) — strip or normalise labels before exact-matching on them.

Provenance

Source 1 — EBA (CRR framework)

  • File: docs/assets/eba-validation-rules.xlsx
  • Sheet: v3.0(3.0.1) — one sheet per DPM release; the workbook carries 19 of them and only this one is read. 3.0(3.0.1) is the framework version in force for CRR reporting.
  • Publisher: EBA
  • Download URL: https://www.eba.europa.eu/sites/default/files/2026-06/12d2a6ae-9f58-47ab-a684-cdc9924ed4aa/%28up%20to%203.5%29%20EBA_validation_rules_2026-06-10.xlsx
  • Landing page: https://www.eba.europa.eu/risk-and-data-analysis/reporting/reporting-frameworks
  • Retrieved: 2026-08-01
  • Sheet size: 8,935 rules, of which 1,011 match the credit-risk filter.

Filter — keep a rule if any of its T1..T7 table codes starts with:

C 02.00   C 07.00   C 08.0   C 09.0   C 34.

Note the space after C in this workbook (C 07.00.a).

Source 2 — BoE (Basel 3.1 framework)

  • File: docs/assets/boe-validation-rules-banking-reporting-v4.0.0.xlsx
  • Sheet: banking_reporting (the other sheet, Note, is prose and is ignored)
  • Publisher: Bank of England
  • Download URL: https://www.bankofengland.co.uk/-/media/boe/files/prudential-regulation/regulatory-reporting/banking/2026/february/boebankingtaxonomyvalidationsv400.zip
  • Archive member: Bank of England Banking Taxonomy Validations Banking reporting v4.0.0.xlsx — the workbook ships inside the taxonomy zip; scripts/download_docs.py extracts it.
  • Landing page: https://www.bankofengland.co.uk/prudential-regulation/regulatory-reporting/regulatory-reporting-banking-sector
  • Retrieved: 2026-08-01
  • Sheet size: 1,490 rules, of which 820 match the credit-risk filter.

Filter — keep a rule if any of its T1..T4 table codes starts with:

OF02   OF07   OF08   OF09   C08.04   C09.04   C34.

There is no space after C in this workbook (C34.02.01.01, OF07.00.01.01). A rule is kept when any of its tables matches, so rules that also touch out-of-scope tables (C01.00.01.01, C03.00.01.01, OF34.07.01.01, …) are retained in full with all their table codes listed.

Counts

EBA

Status Rules
live 588
deactivated 222
deactivated + deleted 117
deleted 46
deactivated + not_in_xbrl 20
not_in_xbrl 17
deactivated + deleted + not_in_xbrl 1
Total 1,011

Severity: 505 ERROR / 506 WARNING overall; 276 ERROR / 312 WARNING among live rules.

Rule types: Manual 543, Identity 167, Hierarchy 101, Sign 91, eQuivalence 77, Nonexistence check 21, Allowed values for metric 6, Unique identifier 4, Allowed values for cell(s) 1.

Reactivation caveat. live means no value in Deactivated on, Deleted or Not Implemented in XBRL. But 220 matched rules carry both a deactivation date and a later reactivation date, and 153 of those are not deleted — they were switched off and then switched back on. They are reported as deactivated, and filter.live_or_reactivated (741) gives the wider "not permanently withdrawn" count. Consumers wanting currently enforced rules should read reactivated_on rather than trusting status alone.

BoE

All 820 matched rules have Deactivated = no. 808 are included in XBRL; the remaining 12 are flagged not_in_xbrl and so report as non-live, which is why live is 808 while not_deactivated is 820.

Severity: 514 ERROR / 306 WARNING. 34 rules set always_execute. 462 rules carry a DESCRIPTION label; 427 of those resolve to exactly one EBA rule id.

Precondition and Simplified Precondition are empty for all 820 matched rules, as are Where and Join. Scope is populated on 466 of them. The fields are still emitted (as null) so the schema stays stable if a future taxonomy release starts using them.

Row and column id normalisation

Decision: ids are preserved verbatim. Sub-4-digit ids are never zero-padded.

The EBA sheet is inconsistent — most rules scope rows/columns with 4-digit ids (0010), but 42 of the 1,011 matched rules use 3-digit ids (070). Zero-padding 070 to 0070 would be wrong, and the evidence is unambiguous:

  1. No live rule uses them. All 42 sub-4-digit rules are deleted and/or deactivated, most on 2014-04-16. Live rules: 588, of which 0 use legacy ids.
  2. The workbook records the renumbering as an explicit event. The Last Change column holds the literal value 4 digits for 564 of the 1,011 matched rules — the DPM was renumbered from 3- to 4-digit ids, rules were migrated in place, and the ones that were not migrated were deleted at that point.
  3. The mapping is not a zero-pad. Deleted rule v0414_m scopes (010-140) with formula {c105} >= {c030}; the live C 07.00.a column set contains no 0105. Deleted v1664_m references c255/c260; there is no 0255/0260 either. But deleted v1639_m references 215/220, and 0215/0220 are real modern columns. So a blind zero-pad would silently invent a plausible-looking but unverified equivalence for some ids while producing dangling references for others — the worst of both outcomes.

Each rule therefore carries has_legacy_ids (true when any scope token is not exactly 4 digits and contains a digit run shorter than 4). Filter on status == ["live"] and the question disappears entirely.

Two further scope forms exist, both only on non-live rules:

  • Ranges(010-140), (0010-0095). Kept verbatim as a single token and flagged with has_id_ranges; they are not expanded into the individual ids they span. 6 rules, 0 live.
  • (All) — a literal "every row/column/sheet" scope. Represented as rows_scope: "all" with an empty rows list. 211 live rules use it on some axis, of which 61 use it on the row or column axis (it is the common case on the sheet axis).

JSON schema

Both files share the top-level shape:

{
  "source": {
    "framework": "CRR", "publisher": "EBA",
    "file": "eba-validation-rules.xlsx", "sheet": "v3.0(3.0.1)",
    "url": "...", "retrieved": "2026-08-01", "framework_version": "3.0(3.0.1)"
  },
  "filter": {"table_prefixes": ["C 02.00", "..."], "matched": 1011, "live": 588},
  "rules": [ ... ]
}

EBA rule object

Field Type Notes
id str e.g. v0305_m
severity str ERROR / WARNING (upper-cased from the sheet's Error/Warning)
type str Manual, Hierarchy, Sign, Identity, eQuivalence, Nonexistence check, …
status list[str] ["live"], or any of deactivated / deleted / not_in_xbrl
tables list[str] T1..T7, blanks dropped, e.g. ["C 07.00.a", "C 07.00.b"]
rows list[str] scope ids, verbatim
rows_scope str none / all / list
columns, columns_scope list[str], str as rows
sheets, sheets_scope list[str], str the z-axis (sheet) scope
formula str | null see grammar below
prerequisites str | null apply the rule only if these tables are reported
if_value_missing str | null treat as zero/empty string / do not run rule / not applicable
arithmetic_approach str | null Interval / Point / Mixed / Not applicable
narrative str | null human-readable error message (populated on 122 live rules)
replaces str | null superseded rule id(s)
changed_in_release, last_change str | null provenance from the sheet
deactivated_on, reactivated_on str | null ISO date
has_legacy_ids, has_id_ranges bool see normalisation above

BoE rule object

Field Type Notes
id str e.g. boe_b0190
severity str ERROR / WARNING, parsed out of Severity and modules
severity_modules list[str] the module codes from the same cell, e.g. ["PRA001"]
status list[str] ["live"] or ["not_in_xbrl"] (none are deactivated)
tables list[str] T1..T4, blanks dropped
expression str | null Simplified Expression — the readable primary form
expression_raw str | null the full Expression; keeps the interval operators (below)
precondition, precondition_raw str | null simplified / raw; both empty in this extract
scope, where, join str | null scope(...) iteration domain; where/join empty here
short_label str | null the SHORT_LABEL(en) segment
description str | null the DESCRIPTION(en) segment, verbatim
eba_equivalent str | null the corresponding EBA rule id, only when exactly one
eba_equivalents list[str] every EBA id found in description (see caveat)
error_message str | null the BUSINESS(en) segment of Error message
labels dict all parsed KIND(en) segments, {"SHORT_LABEL": ..., "DESCRIPTION": ...}
include_in_xbrl, always_execute bool
owner, framework_code, framework_version_code str | null BOE, banking, banking_reporting

eba_equivalents caveat. The DESCRIPTION segment usually holds one EBA id (v5745_q), but 35 rules hold an expression over several: an enumeration (v10293_s, v10295_s), a sum (v2042_s + v10312_s + v10314_s + v10485_s) or a range (v3354_i to v3370_i). eba_equivalents lists the ids appearing literally in the text, so for a range it captures the two endpoints, not the rules in between. Read description when the distinction matters. eba_equivalent is populated only for the unambiguous single-id case (427 rules).

Formula grammar

EBA

A rule's formula is evaluated against a scope. A rule with a row scope is evaluated once per listed row, and the formula's column references resolve within that row; likewise a column-scoped rule is evaluated once per listed column. This is why the two axes are usually mutually exclusive — the scoped axis is the loop, the formula addresses the other one.

Form Meaning
{c0090} column 0090 of the current row — used by row-scoped rules
{r0010} row 0010 of the current column — used by column-scoped rules
{r0030, c0010} a fully-qualified cell in the rule's own table
{C 07.00.a, c0200} column 0200 of table C 07.00.a, at the current row
{C 08.01.a, r0070, c0020} a fully-qualified cell in another table (the commonest form)
{C 08.01.a, r0010, s0003} sNNNN addresses the sheet (z) axis
{C 07.00.a} the whole table — used by Sign rules, e.g. {C 07.00.a} <= 0
{C 08.02, rNNN} rNNN is an open-row wildcard for templates with a variable row count, always inside sum(...) / where(...)

Operators seen in this extract: = >= <= > < !=, arithmetic + - * /, and the functions sum(...), where(...), abs(...), max(...), min(...), empty(...), plus if … then … conditionals and and.

arithmetic_approach qualifies comparison: Interval means the comparison tolerates rounding (the reported value is treated as an interval around its rounded figure); Point means exact.

Example — v0305_m, a row-scoped rule over C 07.00.a:

rows    = (0010;0020;0030;0040;0050;0060;0070;0080)
formula = {c0090} = {c0050} + {c0060} + {c0070} + {c0080}

reads as: for each of those eight rows, column 0090 must equal the sum of columns 0050, 0060, 0070 and 0080.

Legacy 3-digit rules are self-consistent — do not half-migrate them

Rules predating the 2014 DPM renumbering use 3-digit ids in both the scope columns and the formula body, so they resolve against themselves. v0304_m has rows (070;080) with formula {c010} >= {c020}; v1784_h has rows (290;310) with {c040} = +{c010} + {c030}. Zero-padding the scope to 0070 while leaving the formula's {c010} untouched would produce references that no longer resolve against each other — which is why this extract pads neither.

Every one of these rules is dead, so it never bites a consumer filtering to status == ["live"]. Counted two ways: 42 rules carry a 3-digit id in the rows/columns scope (flagged has_legacy_ids), and 153 carry one somewhere in the formula body — most legacy notation lives in the formula, not the scope, which is why a scope-only count understates it. 0 are live under either count. The distinction matters only if you ever evaluate deactivated rules for historical comparison, and then you must treat a rule's notation as a single generation.

One caveat if you do: a handful of non-live rules mix generations in one formula — v6188_m has {C 103.00, r999} <= {C 08.01.a, r0010, s0001}. The source also contains at least one malformed id, c00280 in v6191_m. Both are reproduced verbatim.

Example — v0309_m, a row-scoped cross-table rule:

tables  = C 07.00.a, C 07.00.b
rows    = (0090;0110;0130)
formula = {C 07.00.a, c0200} = {C 07.00.b, c0210}

BoE

References are keyed maps rather than positional:

{t: TABLE, r: ROW, c: COLUMN, z: SHEET, filter: [dim] = [member]}
Key Meaning
t table code, e.g. OF09.01.01.01
r, c row / column id
z sheet (z-axis) id, e.g. z: 0002
filter dimensional restriction, e.g. [eba_dim:CEG] = [eba_GA:x1] (counterparty geography)
dv default value substituted when the cell is not reported (raw expression only)
id binding name (v0, v1) referenced from the error message as {$v0}
seq, f, fv sequence flag, framework code, framework version (raw expression only)

scope(...) wraps the same reference syntax with multi-valued r:/c:/z: lists and defines the domain the rule iterates over — the BoE analogue of the EBA row/column scope columns.

The expression is not self-contained — apply scope first

79 of the 820 BoE rules have an expression with no r:/c: binding at all; the row, column and sheet coordinates live entirely in scope. All 79 have scope populated. Evaluating expression alone turns a single-cell check into a whole-table assertion and produces mass false positives.

boe_b0529 is the canonical example. Its expression is:

{t: OF07.00.01.01} = 0

which says nothing useful on its own. The binding is in scope:

scope({t: OF07.00.01.01, r:0140, c:0220,
       z:0001;0002;...;0017, f: banking, fv: banking_reporting})

Together they mean row 0140 / column 0220 of OF07.00.01.01 must be zero, on each of the listed sheets. Its EBA equivalent v0316_m states the identical check inline — {r0140, c0215} = 0 — which is the clearest illustration of the grammar difference: the EBA puts the coordinates in the formula, the BoE hoists them into scope. (The column differs, 0215 vs 0220, because the templates were renumbered between frameworks.)

Always evaluate scope, and where / precondition where present, before the expression. Those three are empty across all 820 rules in this extract, but a future taxonomy release may populate them, so handle them rather than assuming they are null.

The EBA has the same trap, in mirror image

An EBA formula is equally incomplete on its own: 161 live formulas contain no r-reference (158 of them carry an explicit rows scope) and 202 contain no c-reference (199 carry a columns scope). {c0090} = {c0050} + ... is meaningless until you loop it over the rule's rows list. The difference is only presentational — the EBA keeps the scope in dedicated spreadsheet columns, the BoE inlines it in a scope(...) expression. Neither source lets you read the formula in isolation.

Interval operators. The raw Expression uses i=, i>=, i<=, i> for interval (rounding-tolerant) comparison — the equivalent of the EBA Interval arithmetic approach. The Simplified Expression collapses these to plain =, >=, <=, >, losing the tolerance semantics. Use expression for reading, expression_raw when the comparison semantics matter. In this extract 654 of the 820 raw expressions use an interval operator.

Example — boe_b0190, a cross-template consistency check:

{t: OF09.01.01.01, r: 0010, c: 0010, filter: [eba_dim:CEG] = [eba_GA:x1]}
  = {t: OF07.00.01.01, r: 0010, c: 0010, z: 0002}

reads as: original exposure pre-conversion-factors for central governments/central banks (sheet 0002 of OF07.00) must equal the corresponding domestic-geography cell in OF09.01. Its eba_equivalent is v5745_q, the matching EBA rule in the CRR extract.

Severity semantics

Both publishers use the same two levels:

  • ERROR — a hard validation failure; the submission is rejected.
  • WARNING — a plausibility/quality check; the submission is accepted but the firm is expected to explain or correct the flagged value.

The EBA sheet spells these Error/Warning; both extracts normalise to upper case.