ADR 0038 — DQ-dimension classification on checks: seven canonical dimensions, derived default, stored and overridable¶
- Status: Accepted (§5 amended 2026-07-19 — existing checks ARE backfilled; see the amendment in §5)
- Date: 2026-07-19
- Deciders: @TheurgicDuke771
- Related: 0005 (severity tiers + the health-score formula the scorecard reuses), 0012 (
check.kind— the axis this one is orthogonal to), 0015 (comparisonkind), 0019 (custom SQL — the one authoring path with no derivable dimension), 0036 (§4 names this work; see Relationship to ADR 0036 below), 0037 (the workspace-true aggregate rule the scorecard must obey).
Context¶
A check today carries two classification axes, and neither is a data-quality dimension:
| Field | Axis | Example |
|---|---|---|
kind (ADR 0012) |
how the monitor works | expectation / freshness / comparison |
expectation_type |
the specific GX rule | expect_column_values_to_be_unique |
A DQ dimension is a third, orthogonal axis: the semantic category of quality a check measures. It is what makes "we have 47 checks" legible as "your Timeliness is uncovered" — and it is the input the planned asset scorecard aggregates by.
The value is not primarily the score. It is coverage: "this asset has zero Timeliness and zero Uniqueness checks" is immediately actionable in a way a pass-rate never is, and CLAUDE.md §5 already records that most real incidents are freshness/volume — i.e. exactly the dimensions teams are least likely to have covered.
Unlike the ADR-0005 severity columns or ADR-0012's kind/metric_value, a dimension column is pure widening — nullable, additive, no data rewrite, no two-step deploy — so there was never schema-timing pressure to rush it. That is why this work was deliberately held back from the Week-3 migration and gets a deliberate design pass now.
Decision¶
1. Seven canonical dimensions, closed vocabulary¶
accuracy, completeness, consistency, integrity, timeliness, uniqueness, validity.
The canonical six (completeness, uniqueness, validity, accuracy, consistency, timeliness) plus integrity — referential/relational correctness, which the six fold into "consistency" and which DataQ can express distinctly once cross-dataset comparison exists (ADR 0015).
Stored as VARCHAR(32) with a table CHECK over the vocabulary, matching the CHECK_KINDS idiom (models._in_check) — not a Postgres ENUM. An enum type needs ALTER TYPE to extend, which is exactly the migration friction this column was deferred to avoid; a CHECK constraint is a one-line ALTER and stays visible in the model file next to the other vocabularies.
Closed, not free-form. A free-text dimension makes the scorecard's coverage view meaningless the first time someone types "Timeliness " with a trailing space — you cannot report "you have no Timeliness checks" against a set you don't control. The cost is that adding an eighth dimension is a migration; that is the right trade for a reporting axis.
2. Derived default, stored value, user-overridable at any time¶
Three properties, and all three are load-bearing:
- Derived — on create, if the check's type maps to a dimension, that is the default. Nobody should have to hand-classify
expect_column_values_to_not_be_nullas Completeness. - Stored — the resolved value is written to
checks.dimension, not recomputed on read. This is what lets the scorecard aggregate with a SQLGROUP BYinstead of joining through a Python map, and what makes an override survive. - Overridable at any time — at create and by later PATCH. Derivation is a good guess about intent, not a fact: the same
expect_column_values_to_be_betweenis Validity when it bounds a percentage and Accuracy when it asserts a reconciled total.
Rejected: pure derivation (no column) — no override, and the scorecard loses SQL aggregation. Rejected: pure user-set — every existing check and most new ones land unclassified, so the coverage view is empty until someone backfills by hand, which nobody does.
3. Derivation is deliberately partial, and NULL is a real state¶
| Check type | Dimension | Why |
|---|---|---|
expect_column_values_to_not_be_null |
completeness |
|
expect_table_row_count_to_be_between |
completeness |
"is all the data here" |
monitor:volume |
completeness |
a short load is missing data |
expect_column_values_to_be_unique |
uniqueness |
|
expect_column_values_to_be_between |
validity |
conforms to a rule |
expect_column_values_to_be_in_set |
validity |
|
expect_column_values_to_match_regex |
validity |
|
expect_column_value_lengths_to_be_between |
validity |
|
expect_column_values_to_be_of_type |
validity |
|
monitor:freshness |
timeliness |
|
monitor:schema_drift |
consistency |
structural stability over time |
comparison:records / comparison:columns |
consistency |
cross-dataset agreement (ADR 0015) |
custom SQL (unexpected_rows_expectation) |
none | arbitrary predicate — unknowable |
accuracy and integrity are never derived. No generic GX expectation can tell you whether data matches reality, or whether a relationship holds. They exist for the author to select — most often on a custom-SQL check, which is precisely the path with no derivable answer. Pretending to derive them would populate the scorecard with confident nonsense, which is worse than an honest gap.
So dimension is nullable, and NULL means "unclassified" — a state the scorecard must render as a gap, never silently bucket. Existing checks keep NULL (see §5).
4. Orthogonal to kind and to engine¶
kind is how the monitor works, dimension is what quality aspect it measures, engine (ADR 0036) is what evaluates it. All three vary independently: a freshness kind is Timeliness whether GX or DMF runs it; a comparison kind is Consistency by default but Accuracy when the baseline is a source of truth.
The derivation map is therefore keyed on expectation_type with a kind fallback, not on kind alone — expectation covers a dozen different dimensions, so keying on kind would collapse them.
5. Backward compatibility¶
Additive nullable column on checks and on check_versions (history must round-trip the field, or restoring an old version silently reclassifies a check).
~~Existing rows are left NULL, not backfilled.~~ (Superseded — see the amendment below.) The original reasoning: a backfill would be indistinguishable from a deliberate user classification, so a later correction to the derivation map could never tell "the map said so" from "a human said so".
Amendment (2026-07-19): existing checks ARE backfilled¶
Migration a7b8c9d0e1f2 fills dimension for every pre-existing check the map can classify.
The original position over-weighted its own concern and under-weighted the cost:
- The ambiguity it feared is vacuous at the moment it matters. The column is introduced one revision earlier, so when the backfill runs the set of checks carrying a human-set dimension is exactly empty. The backfill cannot overwrite anyone's decision, because nobody has made one yet. The original text reasoned as if derived and user-set values were being mixed, when in fact the user-set set is empty by construction.
- The cost of not doing it is the feature. Leaving them NULL makes the planned scorecard report "unclassified" for every existing check — useless on day one, on precisely the workspaces with the most history worth reporting on. "Classified on next edit" assumed people re-save old checks, which they do not.
The residual risk is narrower than §5 claimed: only that a future correction to the derivation map cannot distinguish a backfilled row from a user's override. Mitigation is recorded in the migration — re-derive only rows whose stored value still equals that migration's map output, which it inlines for exactly this purpose.
The backfill deliberately writes no check_versions rows: it is a system classification, not an edit, and minting a version per check would fill every history drawer with a change nobody made. Unmapped types (custom SQL) stay NULL — §3's "unclassified is a real state" is unchanged.
6. Surfaces¶
- Check editor — a dimension select, pre-filled with the derived default, always editable. The derived default is shown as the selected value rather than as a placeholder, so what you see is what gets stored.
- Export/import —
dimensionrides the check document, and the two cases are distinguished: absent (an older export) → derived, exactly as if freshly authored; present, including an explicitnull→ taken verbatim, so an unclassified check stays unclassified. Conflating them would re-create §5's forbidden backfill by another door — export always emits the key, so every pre-migration check would be silently classified on the way through. The export version does not bump: adding an optional field is backward-compatible in both directions, andEXPORT_VERSIONis documented to bump only on an incompatible shape change. - Changing a check's expectation type re-derives the dimension in the editor (the create page already resets the whole form on a type change). Keeping the old value would leave the select showing a classification the help text simultaneously claims is "defaulted from the check type" — and it would then be sent explicitly, so a uniqueness check filed as completeness would look like a deliberate override forever. The backend does not re-derive on PATCH: it cannot tell a derived value from an override, so leaving it is the conservative choice, and the editor sends the corrected value.
- Scorecard — consumes this; not built here.
Relationship to ADR 0036¶
ADR 0036 §4 asserts the dimension classification and an engine-aware backend expectation catalog are "one story… not two catalogs". This ADR narrows that: it lands the dimension axis and a backend derivation map — the seed of that catalog — without moving the frontend catalog server-side.
The reason is sequencing, not disagreement. The backend catalog's purpose is engine-awareness (which expectations a DMF or DQX connection offers), and no native engine exists yet — ADR 0036 §6 makes DMF the first build and trigger-gates the rest. Building a server catalog now would mean guessing at an interface with exactly one implementation, while the scorecard is blocked on nothing more than a column. When DMF lands, the derivation map is the thing it extends.
Consequences¶
- Coverage reporting becomes possible at all, which is the scorecard's actual value.
- One more field on the authoring form. Mitigated by the derived default: the common path is "don't touch it".
- The derivation map is a judgement call that will be argued with (
row_count→ Completeness rather than a Volume dimension;schema_drift→ Consistency rather than Validity). That is why it is overridable, and why the map lives in one module rather than being scattered. - Adding an eighth dimension is a migration. Accepted in §1.
- Two axes on the wire look similar (
kindanddimension) and will be confused. The editor labels them distinctly and this ADR is the reference.
Alternatives considered¶
- Free-text dimension — rejected in §1; unbounded values destroy coverage reporting.
- Postgres ENUM — rejected in §1;
ALTER TYPEfriction for no gain over aCHECK. - Derive at read time, no column — rejected in §2; no override, and the scorecard loses SQL aggregation.
- Backfill existing checks from the map — rejected in §5; makes a derived guess indistinguishable from a human decision.
- Multiple dimensions per check (a check as both Validity and Accuracy) — rejected. A many-to-many turns "% of Completeness checks passing" into a question with no single answer, and every scorecard number would need a double-counting rule. One dimension per check; split the check if it genuinely measures two things.