ADR 0015 — Two-connection comparison check model (suite = target under test; source ref on the check)¶
- Status: Accepted
- Date: 2026-07-11
- Deciders: @TheurgicDuke771
Context¶
ADR 0014 reserved check.kind = 'comparison' (cross-dataset reconciliation, reusing the FastAPI_DataComparison diff engine) and deferred the one genuine design decision to this ADR: how a comparison check carries two connection refs when everything in DataQ binds a check to exactly one connection through its suite. Today the kind is constraint-valid but unbuildable — run_service._run_outcomes raises NotImplementedError for it, and there is nowhere to put a second connection.
The single-connection invariant is not incidental. Suite.connection_id + Suite.target is what the platform's whole "dataset under test" lens hangs off: suite authz (ADR 0027/0033), asset resolution + run stamping (ADR 0034), incidents, lineage, the run-target resolver, and the runner registry (build_check_runner builds one runner per run from the suite's connection). A model that weakens it ripples everywhere.
A second candidate consumer exists at a different grain: ADR 0030 deferred its "Option B" (an iceberg connection referencing an adls_gen2/s3 connection for storage credentials) to this ADR's settlement — a connection→connection ref, versus 0014's check→connection ref. This ADR must also settle whether those become one generalized mechanism.
Decision¶
1. The suite stays single-connection and supplies the target side; a comparison check adds exactly one new ref — the source (baseline)¶
A comparison check does not carry two connection refs. It reads: "this suite's dataset (the target under test) must match baseline dataset Y."
- Target side = the suite's existing binding —
suite.connection_id+ the resolvedsuite.target(including flat-file batch materialization), identical to every other check in the suite. Asset stamping, incidents, authz, and run history keep working unchanged, and a comparison result is correctly a quality statement about the suite's dataset. - Source side = new on the check:
checks.source_connection_id(FK →connections.id) + a source dataset spec inchecks.config["source"], shaped exactly likeSuite.target(table/schema/catalog/path/file_format/batch) and validated by the same purerun_target.resolve_targetagainst the source connection's type. - Either side may instead be a read-only SQL query (SQL datasources only):
config["source"]["query"], andconfig["target_query"]for a target-side projection/filter — the target query always runs on the suite's connection. Both reuse ADR 0019's read-only custom-SQL validation (single statement, no writes/DDL), so a comparison can diff query results (FDC's native shape), not just whole tables. - Join keys:
config["keys"]— an ordered list of key columns; entries may be per-side-mapped ({"source": col, "target": col}) when names differ. - FDC bucket naming maps as: suite side = FDC target, check ref = FDC source.
Cross-env comparison is explicitly allowed (source and suite connections may differ in env) — DEV-vs-QA parity and migration validation are headline use cases. The source may be any datasource type (all five produce DataFrames); orchestration provider types are rejected at validation (CLAUDE.md §4).
2. Schema: a real FK column, not JSONB; presence tied to the kind; RESTRICT on delete¶
checks.source_connection_id UUID NULL REFERENCES connections(id) ON DELETE RESTRICT, plusCHECK ((kind = 'comparison') = (source_connection_id IS NOT NULL))and an index on the column. A real column (not a UUID buried inconfig) buys referential integrity, "what references this connection" queryability, and the delete guard — the same don't-bury-structure-in-JSONB reasoning asmetric_value(ADR 0012).- RESTRICT, with a service-level pre-check returning a friendly 409 listing dependent comparison checks. A deleted source would otherwise leave a permanently broken check (
SET NULLfail-soft was rejected — see Alternatives); this matches the de-facto posture ofsuites.connection_id(NO ACTION), while upgrading the UX from a raw FK error to an explanatory 409. check_versionssnapshotssource_connection_idas a plain UUID column, no FK — snapshots are self-contained history and must not block deleting a connection an old version once pointed at (same reasoning as ADR 0020's no-credential-snapshot rule).- Export/import (
suite_io_service) serializes the source ref portably as(connection name, env), resolved on import — a raw UUID would never survive a workspace move. Unresolvable → clean import error. - Migration is additive-only (nullable column + constraint that all existing rows already satisfy) — backward-compatible per the working agreements, no two-step needed.
3. Execution: a DatasetReader seam + the ported FDC engine; capped, fail-fast, never silently truncated¶
- New
DatasetReaderseam in the registry, per connection type (datasources only): resolve a target spec → pandas DataFrame. It grows out of plumbing that already exists — the profiler's per-type engine access for SQL sources,flatfile's object read,pyiceberg's scan — rather than a new subsystem.build_check_runnerand the one-runner-per-suite shape are untouched; the worker builds source readers additionally, only for suites containing comparison checks. - The FDC diff engine is ported, not copied verbatim (per ADR 0014: engine yes, app no) as a frame diff under
backend/app/datasources/— join onconfig["keys"], optional column subset/mapping, producing matched / mismatched / additional-in-source / additional-in-target buckets. The port has explicit latitude to optimize beyond FDC's whole-frame load (batched/chunked reads via theDatasetReader, and similar) as long as bucket semantics stay exactly FDC's; technique choices are build-time detail, not this ADR. No report files, no result cache, DataQ tooling (Black/mypy/pytest + the adversarial battery). - Row-cap discipline: a configurable
max_rows(config override, settings default). For SQL sources aCOUNT(*)preflight runs first; either side over the cap → an operationalerrorresult ("dataset exceeds comparison row cap"), never a diff over truncated frames — a truncated diff produces confidently wrong mismatch buckets, which is worse than no answer. Batching raises how far the cap can sit; pushdown/hash-based comparison beyond it is the scale follow-up (G-b), not this model. - Dispatch composes exactly like the monitor kinds:
_run_outcomesgains acomparisonbranch beside theexpectation/monitor branches —kindpicks the monitor,connection.typepicks the adapter (ADR 0012).
4. Results ride the existing seams (reaffirming ADR 0014 §3, now concrete)¶
expectation_typeis the canonicalcomparison:records(mirroringmonitor:<kind>); a per-column graincomparison:columns(FDC's second mode) is reserved, not first-build.metric_value= mismatch-% (non-matching rows over the comparable universe) — a badness scalar, so ADR 0016's severity banding and the existing warn/fail/critical thresholds work unchanged; no thresholds → plain pass/fail (success = zero non-matching rows).- Bucket counts →
observed_value; capped per-bucket row samples →sample_failures, through the suite's column policy + blanket redaction like any failing sample. - Report files are derived, never stored. FDC's disk reports (
comparison_reports.py) are replaced by an on-demand download on the result: CSV/XLSX (format chosen at download time, not check config) generated from the persisted redacted buckets. A stored full-mismatch file would bypass thesample_failuresredaction path, escape the PII-minimisation retention sweep, and assume an object store a BYOL deploy may not have. Opt-in full-report export to a user-designated flat-file connection (ADLS/S3, redaction applied) is a recorded follow-up, not first-build.
5. Authoring UX: side-by-side comparison editor¶
The check editor gains a side-by-side comparison layout — left = source (connection picker, table or read-only SQL, key columns), right = target (connection pre-filled and locked to the suite's — the §1 invariant made visible — with optional SQL projection), common options below (mode, row cap, thresholds). Exact layout is build-time detail; the locked target connection is the one binding piece of this section.
6. Scope guard: no generalized connection→connection mechanism¶
The check-level source ref settles 0014's question only. ADR 0030's Option B (a connection referencing another connection for credentials) is a different grain with different lifecycle semantics and still has zero shipped consumers — generalizing now would be speculative machinery (ADR 0011's second-impl-deferred discipline). Option B remains deferred to its own future ADR if a real need lands.
Consequences¶
Positive
- The single-connection suite invariant survives intact — authz, assets, incidents, lineage, and the runner registry need no changes; comparison lands as one nullable FK + a run-path branch, the same additive shape as the monitor kinds.
- Heavy reuse: resolve_target validates both sides, severity banding and results/redaction/alerting seams work unmodified, and the diff engine arrives proven (FDC is unit-tested, MIT, same author).
- A comparison check is an explicit cross-dataset edge — a future lineage signal for ADR 0034 (source → target edge with a quality facet), deferred.
Negative / accepted
- In-memory diff is memory-bound like the flat-file/UC/Iceberg paths; the row cap makes large tables honestly unsupported rather than slow-and-wrong until G-b pushdown work.
- RESTRICT means a source connection used by comparison checks cannot be deleted until those checks are repointed/deleted (surfaced as a clear 409). Accepted as the price of never having zombie checks.
- Duplicate join-key rows make bucket semantics ambiguous — the engine port must define (and test) explicit behaviour, e.g. error the check on non-unique keys.
- The suite-level column_policy describes the target's columns; source samples reuse it on the reconciliation assumption that both sides share a logical schema. Unlisted columns still default-redact, so the posture can't regress.
Alternatives considered¶
- Two connection refs on the check (source + target both) — rejected: decouples the check from its suite's dataset, orphaning authz/asset/incident/lineage anchoring, and makes the suite's own connection meaningless for that check. The suite already is one side.
- Kind-specific side table (
comparison_specs) — rejected: cleaner column-nullability at the cost of a join in every CRUD/version/export/run path, for exactly one column plus config that fits the existing JSONB. Reconsider only if comparison config outgrowsconfig. - Generic
check_connections(check_id, role, connection_id)join — rejected: N-ref generality with no second consumer at the check grain; speculative (ADR 0011). - Source ref as a UUID inside
config(no column) — rejected: no referential integrity, no delete guard, invisible to SQL. - A "comparison suite" binding two connections at suite level — rejected: breaks the suite invariant for all checks in the suite, forces single-check suites, and confuses the asset lens.
ON DELETE SET NULL(fail-soft likeasset_id) — rejected:asset_idis derivable metadata a sweep may legitimately remove; a source connection is load-bearing config whose silent loss turns a passing suite into a permanently erroring one.- Generalize to connection→connection refs now (fold in 0030 Option B) — rejected: different grain, no consumer, speculative machinery (see Decision §6).
- Persist FDC-style report files at run time — rejected for the first build: bypasses redaction, escapes the retention sweep, assumes an object store (see Decision §4); replaced by the derived on-demand download, with connection-targeted export as the recorded follow-up.
Related¶
- ADR 0014 — reserved the
comparisonkind and deferred this model decision here. - ADR 0012 — the kind-dispatch seam this branch composes with;
metric_value. - ADR 0016 — badness-% banding reused for mismatch-%.
- ADR 0011 — second-impl-deferred discipline (Decision §5); post-v1 RDBMS adapters that widen comparison's reach.
- ADR 0020 — snapshot self-containment (versions carry the UUID, no FK).
- ADR 0030 — Option B (connection→connection) stays deferred; not generalized here.
- ADR 0033 — connections are workspace-visible; referencing one as a source needs no new authz surface (mutations stay Admin-only).
- Source engine:
github.com/TheurgicDuke771/FastAPI_DataComparison(MIT) —data_comparison/{record_comparison,column_comparison,get_dataset}.py.