ADR 0036 — Connection-anchored check engines: GX universal; platform-native engines (DMF / DQX / Dataplex) unlocked per connection¶
- Status: Accepted (direction + seam shape; Snowflake DMF is the first native build, DQX/Dataplex trigger-gated — see §6)
- Date: 2026-07-17
- Deciders: @TheurgicDuke771
- Amends: ADR-0003 (its v1.1 engine-swap shape only — the suite-level
engine: gx | dqxtoggle moves to the check grain and engines become connection-anchored; 0003's core decision, GX-only for v1, stands) - Related: 0003 (GX-only v1; the suite-level
gx | dqxtoggle this ADR supersedes), 0011 (CheckRunner/ConnectionAdapterseams), 0012 (check.kind+metric_value— the axis §4 keeps orthogonal), 0030 (registry dispatch shape), 0031 (license guardrail gating DQX), post-v1 roadmap gap G-g (GX-pin engine risk — this ADR is its abstraction answer).
Context¶
v1 is deliberately GX-only (ADR 0003): one result schema, one catalog, one editor. But the platforms DataQ monitors now ship their own DQ primitives — Snowflake Data Metric Functions (system + custom DMFs, Enterprise Edition), Databricks Labs DQX (rule engine over DataFrames/streams; the DLT/streaming cases batch-only GX can't serve), and GCP Dataplex data-quality scans (CloudDQ successor). Users on those platforms reasonably want DataQ to use the native capability rather than re-implement it — both for pushdown (checks run where the data lives, instead of pulling into the worker's pandas) and for meeting warehouse standards teams where they already are.
Two integration patterns must not be conflated:
- Synchronous evaluators — DataQ calls the engine at run time and gets outcomes back (GX today; DQX; ad-hoc DMF invocation via
SELECT SNOWFLAKE.CORE.NULL_COUNT(...); Dataplex on-demand scans). This ADR. - Warehouse-scheduled measurement ingest — the platform runs checks on its own cadence (DMF schedules attached to tables, recurring Dataplex scans) and DataQ provisions and ingests. Structurally a pull provider (like orchestration polling / lineage pull), not a
CheckRunner. Deferred to a future ADR; its open schema question is thatresults.run_idis NOT NULL and a warehouse-scheduled measurement has no DataQ run (synthetic runs vs a sibling measurements table). Snowflake-scheduled DMFs are out of scope for DataQ (decided 2026-09-29): DataQ evaluates DMFs ad hoc and does not attach, schedule or ingest Snowflake-managed DMF runs.ROW_COUNT,ACCEPTED_VALUESandSCHEMA_CHANGE_COUNT, which exist only in the attached model, stay unsupported on the DMF engine; the GX engine covers row count, accepted values and schema drift on Snowflake.
ADR 0003 sketched DQX as a suite-level engine: gx | dqx toggle on UC suites. That shape predates this design and is superseded here (§2).
Decision¶
1. Engines are capabilities of connections¶
There is no global engine setting and no engine config page. Every datasource connection offers gx; native engines are unlocked by connection type and validated per connection instance:
| Engine | Unlocked by connection type | Exists today? |
|---|---|---|
gx |
all datasource types | yes (sole engine) |
dmf |
snowflake |
first native build (§6) |
dqx |
unity_catalog |
trigger-gated (§6) |
dataplex |
bigquery (future type) |
gated on BigQuery-as-datasource (§6) |
No Snowflake connection → no DMF anywhere in the product. This extends the dispatch shape that already exists: registry.build_check_runner keys on connection.type; it becomes two-key (connection.type, engine) with a capability map answering "which engines does this connection offer."
2. Engine selection is per check, not per suite¶
check.engine (TEXT, default 'gx', additive migration), validated on save against the suite's connection capability set. Rationale: the realistic suite is mostly GX expectations plus a few native checks — a suite-level toggle would force artificial suite splits. The run path already partitions one run's checks across evaluators (expectation checks → CheckRunner, monitor kinds → MonitorRunner); partitioning by engine is the same move, inside one run with one result set. Supersedes ADR 0003's suite-level toggle sketch (CLAUDE.md §5 updated in this PR).
3. Type gates the offer; the connection instance validates the reality¶
Native availability is not implied by type alone (DMFs need Enterprise Edition + EXECUTE DATA METRIC FUNCTION / SNOWFLAKE.DATA_METRIC_USER; DQX needs workspace job/serverless execution rights). The connection test/re-auth flow probes actual engine availability and stores a per-engine capability flag on the connection, with an actionable remediation when unavailable — what the limitation is and what access to request (e.g. "requires Enterprise Edition", "grant the DataQ role EXECUTE DATA METRIC FUNCTION + usage on the target database"). The stored reason is classified guidance, never raw exception text (raw driver errors have carried credentials before — a standing lesson). Phasing: phase 1 may gate by type only, with run-time failures landing as classified error results; the capability flag + probe is phase 2, but the flag is designed into the schema from the start.
4. kind ⊥ engine — engines are alternate evaluators of existing kinds¶
check.kind says what is measured; check.engine says who evaluates it. Snowflake's FRESHNESS system DMF is an alternate evaluator of the existing freshness kind — never a new kind (no dmf_freshness). (Amended 2026-08-22, slice-2 build: ROW_COUNT was named here as the volume evaluator, but it cannot be invoked ad hoc — Snowflake documents "You can't call this function directly", live-confirmed in every argument spelling — so volume stays GX-only; its COUNT(*) pushes down identically. FRESHNESS additionally accepts only DATE/TIMESTAMP_LTZ/TIMESTAMP_TZ bare-column arguments — a TIMESTAMP_NTZ column gets classified run-time guidance pointing at the GX freshness monitor.) This keeps the ADR 0012 seam, the scorecard's dimension mapping, and trend queries engine-agnostic. Each engine carries a supported matrix (which kinds/check types it can evaluate) — per-check information, reinforcing §2. Consequence: the expectation catalog (frontend-only today, by design) must become an engine-aware backend catalog; that is one story with the dimension-classification work, not two catalogs.
DMF outcomes are metric-shaped and ride the existing result semantics unchanged: scalar → results.metric_value, thresholds applied by the run service outside the runner (as today), severity tiers unchanged.
5. Lifecycle: the check's engine is tied to the connection — validated, never silent¶
- Save-time: creating/updating a check with an engine the suite's connection doesn't offer is a 422 naming the missing capability.
- Run-time: an engine that was available at authoring but isn't now (privilege revoked, edition downgrade, connection re-pointed) lands the check as the existing
erroroperational status with a classified reason — never a silent skip, never counted in pass rates (erroris already excluded from severity denominators). - Export/import: a suite export carrying native-engine checks imports anywhere, but the user is warned (toast + per-check report) that N checks require platform-native capabilities the target connection may not offer; the same save-time validation marks them explicitly rather than dropping or silently converting them.
- Connection delete already cascades suites/checks (ADR 0020) — no new orphan class.
6. Build order and triggers¶
| Engine | Gate / trigger | Notes |
|---|---|---|
| DMF (Snowflake) | Build first | Ad-hoc SELECT invocation of system DMFs (NULL_COUNT, NULL_PERCENT, DUPLICATE_COUNT, UNIQUE_COUNT, FRESHNESS, BLANK_COUNT, FUTURE_TIMESTAMP_PERCENT) mapped onto existing kinds; custom DMFs shipped 2026-09-29 (amendment below). ROW_COUNT was on this list and is out — it has no ad-hoc form (2026-08-22 amendment in §4). ACCEPTED_VALUES and SCHEMA_CHANGE_COUNT are out for the same reason (Snowflake documents both as "can't call this function directly"; live-confirmed 2026-09-27: a direct call rejects ACCEPTED_VALUES's lambda argument as a syntax error, and without it — or SCHEMA_CHANGE_COUNT with no argument — returns the sentinel -1, never a measurement. They need the attached, scheduled DMF model — warehouse-side attachment management, decided out of scope on 2026-09-29). Ad-hoc DMF arguments must be a bare column of a table or view: an expression, a VALUES list or a CTE is refused (Invalid argument types / only supports table-like objects). Constraint: needs a live Snowflake with Enterprise features to build against. |
| DQX (Unity Catalog) | A real Databricks/streaming user, plus two prereqs | (a) a remote-execution design — DQX's value is Spark-side evaluation, so DataQ must submit work to the workspace, a new architectural capability (today's UC runner pulls into pandas); (b) the Databricks Labs license check against ADR 0031's no-source-available guardrail before it stays on any roadmap. |
| Dataplex (BigQuery) | A BigQuery-as-datasource decision | BigQuery isn't a DataQ datasource; that decision dominates the cost and comes first. |
| Scheduled-native ingest (DMF schedules / Dataplex scans) | Separate future ADR | Pull-provider shape; must first settle synthetic-runs vs sibling-measurements-table for results.run_id. |
Consequences¶
- Additive migration:
check.enginedefault'gx'; per-engine capability flags on connections (phase 2 probe fills them). Backward-compatible; no existing row changes meaning. registrygains the two-key dispatch + capability map; run dispatch partitions a run's checks by engine.- Backend engine-aware catalog lands with the dimension-classification work as one story; check editor filters check types by the selected engine's supported matrix.
- The scorecard and all trend/aggregation SQL stay engine-agnostic — guaranteed by §4.
- G-g (GX-pin risk) is discharged from "watch item" to "decided abstraction": GX becomes one engine behind the seam rather than the definition of a check.
Alternatives considered¶
- Suite-level
enginetoggle (ADR 0003's sketch). Rejected: the realistic suite is mostly GX expectations plus a few native checks — a suite-level engine forces artificial suite splits, and the per-engine supported matrix (§4) is per-check information anyway. The run path already partitions one run's checks across evaluators, so the check grain costs nothing structurally. - Native metrics as a monitor kind (
kind='native_metric') instead of an engine. Seriously considered (2026-07-17 discussion): it would ship ad-hoc DMF calls on the existing ADR 0012 seam with no engine machinery at all. Rejected because it dead-ends — it cannot represent DQX/Dataplex (full evaluators, not metrics), blocks future warehouse-side attachment management, and violates §4 by encoding the evaluator into the kind axis. - Engine-specific kinds (
dmf_freshness,dqx_null_check, …). Rejected outright: forks the kind axis, so every consumer — scorecard, dimension mapping, trend SQL — would branch per engine forever. - A global/workspace engine setting. Rejected: engine availability is a property of a connection instance (edition, grants, workspace rights), not of the workspace. A global toggle cannot answer "is DMF available on this connection" and would reintroduce the config-page indirection §1 deliberately avoids.
Amendment (2026-09-28): DQX shipped, executed remotely¶
DQX is now the second native engine, offered on unity_catalog connections. The user chose to
build it ahead of retiring the Databricks environment. Both §6 prerequisites are settled:
- (b) The licence check failed for bundling.
databricks-labs-dqxis published under the proprietary Databricks License, which permits use only with Databricks services. It is therefore never a DataQ dependency (ADR 0031, CONTRIBUTING rule 40). DataQ neither imports, installs nor redistributes it. - (a) Remote execution is the design. For each run, DataQ uploads a fixed notebook to the
connection user's workspace folder and submits one serverless job (Jobs API
runs/submit) carrying every DQX check of the run. The notebook installs a pinned DQX version, validates each rule (an invalid rule errors only itself), applies all valid rules in one Spark pass, and returns failing-row counts per rule, never row values. The run path gained an optional runner hook,run_native_checks, so an engine with per-job start-up cost is batched. DMF stays per check.
A security property this engine forced: DQX evaluates bare string arguments as Spark SQL
(check_funcs.get_limit_expr → F.expr), live-confirmed when the list value 'SM CASE'
resolved as a column named SM. DataQ therefore builds every rule from a closed vocabulary
of eight types, with typed arguments:
- columns are allowlisted identifiers, backtick-quoted
- list values are emitted as escaped SQL literals
- limits are numbers or ISO dates validated by DataQ
A user string never reaches DQX unquoted. The authoring gate and the run path share one rule builder, so they cannot disagree.
Capability probe: phase 1, gated by connection type only. A missing jobs or workspace permission lands as a classified per-check error at run time, as §2 permits. A probe would itself cost a job start-up.
Amendment (2026-09-29): custom DMFs shipped¶
§6's "custom DMFs later" is built. A Snowflake check of type dmf:custom runs a data metric
function the customer created (CREATE DATA METRIC FUNCTION), on the existing dmf engine.
- Shape.
configis{"function": "DATABASE.SCHEMA.FUNCTION", "columns": [...]}. The name must be fully qualified. Each part goes through the shared SQL-identifier allowlist and is quoted by the usual folding rule (all-lower-case stays bare, anything else is quoted). TheSNOWFLAKEdatabase is refused, because the system DMFs have their own types and some return a sentinel-1when called this way. - Invocation. The system functions' ad-hoc form, with 1 to 20 bare columns of the suite
target, in the function's signature order:
SELECT <db>.<schema>.<dmf>(SELECT <col>[, …] FROM <target>). The ad-hoc rule stands: no expressions, no second table. Authoring and the run path share one statement builder, so a config that saves is exactly one that runs. - Result. The return value is
metric_value, banded by the check's thresholds (higher = worse, a positive fail or critical threshold required, like the other banded DMF types). It is shown unmasked, like every DMF metric. That is safe only because the function returns a number the customer chose to compute, so the user guide tells authors to return a count or a percentage, never a data value. The dimension is never derived (ADR 0038): NULL unless the author sets one. - Discovery. The connection-test probe runs
SHOW DATA METRIC FUNCTIONS IN ACCOUNTand stores the non-SNOWFLAKEresults inengine_capabilities.dmf.custom_functions(capped at 200) for the editor to suggest.SHOWwas chosen overINFORMATION_SCHEMA.FUNCTIONSbecause it needs no warehouse and spans every database the role can see, not just the connection's. The list doubles as theUSAGEcheck the issue asked for: Snowflake lists a custom DMF only to a role holdingUSAGEon it. - Errors. A DMF the role cannot use raises the same
Unknown user-defined functionas one that does not exist, so both get one message naming the grants needed. A column list that doesn't match the signature, an unknown column and a missing schema each get their own fixed message; driver text never reaches the result.
Live-verified 2026-09-29 on the Enterprise account with the existing least-privileged
connection role, against a throwaway schema:
- the probe listed exactly the custom DMFs the role held USAGE on;
- a suite run through the worker path and a dry-run evaluated a one-column and a two-column
DMF with the right values and warn / fail / critical bands;
- an unknown DMF, an ungranted DMF, a wrong column count and an unknown column each landed as
a classified per-check error;
- injection-shaped names and columns were refused at authoring, and the run path refuses them
without issuing any SQL.
Scheduled (attached) DMFs stay out of scope, as recorded in §6 and the Context.
Amendment (2026-09-29): a stream mode for DQX¶
Streaming is why DQX sits beside batch-only GX. A DQX check now has an optional
mode: snapshot (the default, as before) or stream.
How a check targets a stream. The target is unchanged: the suite's
catalog.schema.table. That can be a Lakeflow streaming table or any Delta table that grows by
appends. stream mode evaluates only the rows appended since the check's last persisted
result.
Where evaluation happens. Evaluation stays in the same one serverless job per run. The
notebook reads the table with readStream and an availableNow trigger, applies the same
DQX rules, and sums failing rows per rule from the stream's observed metrics. A noop sink
means nothing is written. The stream stops once it has caught up. Stream checks that resume
from the same version share one stream.
What DataQ persists. Failing-row counts only, as in batch mode. Each result also records
a small stream report: the Delta version range it read, the rows it evaluated, the table's
Delta id, and the number of rewrite commits it skipped. The resume point is that recorded
version, held by DataQ and not by a Spark checkpoint. The next run starts from the latest
evaluated result's next_version. A job whose result is never saved is therefore re-read,
not skipped. A persistent checkpoint would already have advanced past rows whose counts were
lost. The checkpoint Spark requires is a throwaway folder in a UC volume named on the
connection (dqx_checkpoint_volume). It is deleted after each run. Serverless rejects both
temporary and DBFS checkpoint locations, which was confirmed live.
It runs on the existing run and schedule path. There is no new ingest subsystem, table,
migration or beat task. The batch hook gains a previous argument, the latest evaluated
observed_value per check, read from results.
Invariants kept. DQX is still installed pinned in the workspace only. Rules still come
only from the closed vocabulary, through the same builder, and mode never reaches DQX. The
job still returns only counts: its collect() calls read grouped counts, table history, or
a limit(0) pre-flight, never rows.
Honesty rules.
- A run with no new rows is skip, not pass.
- A stream report DataQ cannot trust makes that check error, so it never becomes a resume
point.
- A replaced table (its Delta id changed) or an expired history restarts the stream from the
whole table, and the result says restarted.
- Rewrite commits (UPDATE/DELETE/MERGE/overwrite) are skipped with skipChangeCommits
and counted in change_commits_skipped, since the mode is defined over appends.
A platform fact that shaped the notebook: a streaming query that fails fails the whole
Databricks command, even when the Python exception is caught, and even with
dbutils.notebook.exit in the handler (live). So predictable stream failures are prevented
up front instead of caught:
- a resume version past the latest commit starts no stream
- each rule is pre-flighted on an empty read
- rewrite commits are skipped rather than raised
A stream failure that still occurs errors every DQX check in that run.
Rejected alternatives. - Reading a Lakeflow pipeline's expectation metrics from its event log. It covers only pipelines the user already runs and only the expectations defined inside them. It needs pipeline-id configuration and a second evaluator vocabulary, and it cannot evaluate DataQ's own rules. - Provisioning a DQX step inside the user's pipeline. That is pipeline management: DataQ would edit user infrastructure, which the orchestration model (monitor and trigger only) rules out. - A persistent checkpoint per check. It is simpler to state, but it loses counts on a failed persist, as described above.
Both rejected designs are heavier, and neither evaluates a stream with DataQ's rules.
Live-verified 2026-09-29 (Databricks Free Edition, serverless). The runs went through
execute_run, the UC runner and the real job:
- an append-only Delta table: a full first read, an increment-only second read, a
no-new-rows skip, and an UPDATE skipped and counted
- a Lakeflow streaming table (CREATE STREAMING TABLE … AS SELECT * FROM STREAM …): a first
read, then only the rows a REFRESH appended