Note
This page includes the repository's
DESIGN.md verbatim — the
decision log behind the architecture overview.
DESIGN.md — Decisions & Tradeoffs¶
This document captures the why behind the architecture. It is intentionally the senior-signal artifact: "I know the alternatives and I made a deliberate choice."
D1 — Why Dynamic Task Mapping instead of one opaque check task?¶
Each check becomes its own visible task in the DAG graph via .expand(), rather than a black-box
"validate" task. Reviewers and on-call engineers see exactly which check failed in the Airflow grid,
get per-check retries, and get per-check logs/lineage.
Tradeoff: more tasks in the graph and a small mapping overhead. Acceptable — observability wins.
D2 — Why a pluggable CheckExecutor (Protocol) instead of one SQL engine?¶
We do not build a SQL engine. The executor is a Protocol with adapters that generate SQL or
delegate to a backend:
DuckDBExecutor/PostgresExecutor— generate SQL, run everywhere (demo + CI).DatabricksExecutor(stretch) — Databricks SQL + Delta, optionally via DQX (enterprise flex).
This single abstraction is the core design decision: "runs locally on DuckDB for reviewers, scales to Databricks/Delta for the real world."
Tradeoff: dialect differences leak into adapters. Contained by keeping CheckSpec backend-agnostic
and pushing dialect into the adapter only.
D3 — Why ODCS instead of a home-grown contract format?¶
ODCS (Open Data Contract Standard) is an existing, governed standard. Inventing a format would be undifferentiated work and a portability dead-end. Using ODCS plays to real-world experience.
D4 — Why a separate metadata DB schema, not Airflow's metadata DB?¶
Contract registry, versions and check-run history live in their own Postgres schema — never pollute Airflow's internal metadata DB. Keeps upgrades safe and the product portable across Airflow deployments.
D5 — Why not just use Soda / Great Expectations?¶
They are checks-as-code in YAML next to the pipeline, frequently backed by external SaaS. The wedge here is visual authoring inside the orchestrator + codegen to real tasks + an integrated dashboard, fully self-hosted OSS. The breaking-change detector ("git for data") is specifically the part those tools don't cover as Airflow-native OSS — the differentiator.
D6 — Failure semantics: fail | warn | quarantine¶
fail— stops the critical pipeline (check task fails, downstream blocked).warn— pipeline continues, result recorded aswarn.quarantine— copies failing rows (capped) intodq.dq_quarantinefor triage; pipeline continues.
D8 — Tolerances over binary pass/fail (anti alert-fatigue)¶
Real data is never 100% clean; a check that goes red on a single null gets disabled. A rule may
carry warn/failIfGreaterThan (absolute) or warn/failIfPercentGreaterThan (share) thresholds.
With thresholds, status is graded (pass → warn → fail); without them it falls back to binary-by-
severity. Example: amount.valueRange is blocking but tolerated, so 2/7 bad (29%) only
warns, while freshness (no tolerance) hard-fails. The single biggest lever against the #1
reason DQ tools get switched off.
D9 — Schema-drift is one metadata check, not per-column rules¶
Upstream renaming a column / changing a type is the silent killer. One schema check per dataset
compares information_schema to the contract: missing required columns + coarse type-family
mismatch (string/number/temporal/boolean), so decimal vs numeric doesn't false-positive while
string↔number drift is caught. Also seeds the breaking-change story (stretch).
D10 — Failures must be actionable¶
Every check captures a small sample of the offending rows inline (one query, same predicate as the count, so they can't disagree). The dashboard shows it in an expandable "bad rows" panel — actionable triage, not just a red count. Quarantine persists the rows; it does not rewrite the user's downstream table (owning that write is out of scope).
D11 — Low-code authoring: form library + one expression escape hatch + live preview¶
The editor is no-code for the closed rule library (pick column + rule + params; columns are
introspected from the live table) and low-code via a single expression rule — a boolean SQL
predicate that flags bad rows. Crucially, that predicate plugs straight into failing_predicate,
so count, bad-row sample and quarantine come for free; no parallel code path. We deliberately
do not build a drag-drop block engine (the form already is no-code) nor accept arbitrary full
SELECTs (breaks portability + safety). The differentiator is live preview: every block has a
"Test" button that runs against the real table and shows pass/fail + sample before saving —
interactive authoring no other in-Airflow OSS does. Expressions are guarded (identifiers only,
read-only predicate, no DDL/DML); trust model = data engineers, same as dbt tests.
D12 — One contract, two deployment patterns (inline gate + asset-triggered monitor)¶
The unit is the contract-per-dataset, not the DAG. The same contract is consumed two ways:
(A) inline in the producer DAG as a gate (on_fail="quarantine", blocks/quarantines before
data flows on); and (C) an asset-triggered monitor DAG (dq_monitor) that schedule=[asset]
and runs whenever the table is reproduced (on_fail="warn", record-only). The producer marks the
table updated via outlets=[orders_asset]. This uses Airflow 3 data-aware scheduling so DQ
coverage doesn't require wiring checks into every pipeline — and it makes "gate + observability
from one definition" literal: both feed the same results store / dashboard. (A central scheduled
"sweep" DAG is the third option, better when there's no producer to hang the asset off.)
D13 — Authoring intelligence: profile to draft, diff to warn¶
Two features make the contract editor more than a form. Profiling points at a live table and suggests a starter draft (fully-populated → notNull, distinct==rows → unique, numeric → valueRange from observed min/max, low-cardinality string → acceptedValues) — you refine from a draft, not a blank page. Breaking-change detection diffs the new version against the previous one on save and classifies each change (column removed / type changed / new required / uniqueness added = breaking; optional add / relaxed constraint / rule change = info), surfacing a red warning in the editor. The detector reuses the schema type-family logic (D9); it's the "git for data" wedge that Soda/GE don't ship as Airflow-native OSS.
D14 — Anomaly detection: statistical, push-down, two levels¶
Anomaly detection here is unsupervised and statistical (z-score / baseline), not deep
learning — the same family as Databricks Lakehouse Monitoring, and honest about it. Two levels:
(1) row-level — the anomaly rule flags rows beyond k·σ of the column mean via pure SQL
(ABS(x - (SELECT avg(x))) > k * (SELECT stddev_samp(x))), so it scales (no data pulled into
Python) and reuses the sample/quarantine machinery to surface the weird records; (2) metric-
level — the dashboard's "quality over time" chart flags a run whose failing-row count exceeds
the historical mean + 2σ. A heavier model (e.g. IsolationForest) is a drop-in extension behind the
same rule, with the explicit trade-off that it pulls a sample into Python.
D15 — Incremental by scope, backfill-safe by logical date¶
Real tables are too big for full scans on every run, and backfills must not false-alarm. Two
mechanisms: (1) every row-level check accepts a guarded where scope (partition filter) that
count, sample and quarantine all share — check only the new slice, compare anomaly baselines
against full history; (2) freshness evaluates against the run's logical_date (injected by the
operator as asOfDate), so a backfill for last month doesn't flag last month's data as stale.
D16 — Lineage: OpenLineage facets, never in the critical path¶
The check operator implements get_openlineage_facets_on_complete and emits each result as
DataQualityAssertionsDatasetFacet (+ DataQualityMetricsInputDatasetFacet) on the checked
dataset — lineage UIs (Marquez etc.) then show "the pipeline ran AND the dataset met its
expectations". The mapping core is pure Python (unit-testable without Airflow); the client
conversion uses defensive imports and returns None on any failure — lineage must never break a
pipeline. Dialect note: SQL generation now takes a dialect parameter (regex is the only split
so far), and all free-form predicates/identifiers/numeric params are guarded at SQL-build time
(defense in depth beyond the authoring-side validation).
D7 — Auth is explicit, on purpose¶
Airflow 3 FastAPI plugin endpoints are not auto-protected by Airflow auth. We secure them explicitly (token validated against Airflow's auth manager). A consciously handled auth concern is itself a good signal.
Open questions / parked¶
- Exact ODCS v3 rule coverage in the MVP form (~6 most common check types).
- OpenLineage facet shape for check results (stretch).
- Whether the dashboard also feeds an external BI tool (optional, not the centerpiece).