ADR 0052 — Consumer-side data contracts: a contracts entity with ODCS v3 import and export¶
- Status: Accepted (2026-10-25)
- Date: 2026-10-25
- Deciders: @TheurgicDuke771
- Related: ADR 0012 (monitor kinds the SLA block maps onto), 0016 (thresholds band the unexpected-%), 0014 / 0015 (the adjacent
comparisonkind — two datasets, not one contract), 0019 (custom SQL, which ODCSsqlrules ride), 0027 / 0033 (the grants a contract inherits), 0034 (incidents and their evidence card), 0037 (restricted_suite_counton a grant-filtered rollup), 0036 / 0038 (engine and dimension on a check), 0041 (audit of the import), 0047 (the reconciler shape re-used here), 0049 (where a contract'sclassificationlands). CONTRIBUTING rule 45 names ODCS as the required format.
Context¶
A DataQ suite is a bag of checks a person authored against one target. Nothing records why a check exists, and a team that publishes a dataset with a written contract (schema, quality rules, SLA) has no way to hand that contract to the consumers' DataQ and get the checks that enforce it, nor to read back "is clause X holding?" from a run. This decision scopes the consumer side only: validate data already at rest against a contract, using the existing suite/check execution. Producer-side (pre-publish, in the producer's own pipeline) validation is out of scope, for the reason recorded when an embeddable client library was rejected — no second producer asking for it.
The Open Data Contract Standard
(Bitol, Linux Foundation AI & Data; Apache-2.0) is the only open, vendor-neutral contract
format with a published JSON Schema. Its current line is v3 (apiVersion: v3.2.0 at the
time of writing; v3.0.0 → v3.2.0 are additive minors). Rule 45 makes it mandatory here: a
contract is read and written as an ODCS document, and anything DataQ-specific goes into the
standard's customProperties, never into a parallel format.
What ODCS carries and what DataQ can execute do not line up one-to-one:
- ODCS describes a contract with many schema objects (tables); a DataQ suite has one target.
- ODCS
qualityrules arelibrarymetrics (nullValues,missingValues,invalidValues,duplicateValues,rowCount) with comparison operators,sqlrules returning a scalar compared by an operator,customrules with anengine+ opaqueimplementation, andtextrules that are prose. DataQ runs the GX expectations inALLOWED_EXPECTATIONS, the monitor kinds (freshness,volume,schema_drift,anomaly, …) and validator-gated custom SQL that counts unexpected rows. - ODCS
slaPropertiesare a flat list (property,value,unit,element); only two of the twelve defined properties (frequency,latency) are a data-quality check at all. - ODCS
dimensionhas seven values (accuracy,completeness,conformity,consistency,coverage,timeliness,uniqueness); DataQ's closed vocabulary (ADR 0038) has seven that differ in two places (validity,integrityvsconformity,coverage). - ODCS
severityis free text withinfo/warning/erroras examples; DataQ haswarn/fail/criticalthresholds.
Decision¶
1. One contracts row per contract version; the document is stored verbatim¶
contracts (id, odcs_id — the document's required id —, name, version, status,
domain, tenant, spec JSONB, unmapped JSONB, api_version, created_by, timestamps;
unique (odcs_id, version)). The standard's id is the identity: two domains may each publish
an orders contract, and a renamed contract keeps its id, so re-import, versioning and the
reconciliation in §6 key on (odcs_id, version) and name is display only. spec is the ODCS document as
imported, every field preserved, including ones DataQ does not understand — the standard
says tools must preserve unknown customProperties, and the same courtesy is extended to the
whole document so a round-trip through DataQ loses nothing the producer wrote. status mirrors
the document's status (draft/active/deprecated/retired in practice; stored as text,
as the standard does). A new version of a contract is a new row; nothing is edited in place.
2. A suite links to one contract and one schema object; a contract fans out to N suites¶
suites.contract_id (FK → contracts.id, ON DELETE SET NULL) and suites.contract_object
(the ODCS schema object's name). A suite with contract_id = NULL behaves exactly as today.
Importing a contract with N schema objects creates (or reconciles) N suites on the connection
the caller names, one per object, each targeted at the object's physicalName (falling back to
name) resolved against the connection's namespace by the existing run-target resolver.
contract_id and contract_object are set and cleared together — the service clears both
when a contract is deleted (the FK's SET NULL is the backstop, not the mechanism) and
PATCH /suites/{id} accepts both or neither, so an object name never outlives its contract.
Deleting a contract unlinks its suites; it never deletes a suite, a check or a result.
3. The translation is a fixed, documented mapping with an explicit "not enforced" bucket¶
The importer never drops a clause silently. Every schema field and every quality / SLA rule
ends in exactly one of: a check it created, or an entry in the contract's unmapped list
(stored beside spec, surfaced on the compliance rollup as not enforced, with the reason).
| ODCS | DataQ |
|---|---|
property required: true |
expect_column_values_to_not_be_null |
property unique: true; primaryKey: true on one property |
expect_column_values_to_be_unique |
primaryKey: true on several properties |
expect_compound_columns_to_be_unique |
logicalTypeOptions.minimum / maximum; exclusiveMinimum / exclusiveMaximum |
expect_column_values_to_be_between, the exclusive forms as strict_min / strict_max; multipleOf → unmapped |
logicalTypeOptions.minLength / maxLength |
expect_column_value_lengths_to_be_between |
logicalTypeOptions.pattern |
expect_column_values_to_match_regex |
property enum (v3.2.0+, an array of {value, …} objects — the values are taken) / invalidValues with validValues |
expect_column_values_to_be_in_set |
invalidValues with pattern |
expect_column_values_to_match_regex |
nullValues with an operator |
expect_column_values_to_not_be_null; a percent bound becomes the check's fail_threshold (ADR 0016 bands the unexpected-%), a rows bound is not expressible and goes to unmapped |
missingValues (arguments.missingValues) |
expect_column_values_to_not_be_in_set over the listed values (plus not_be_null when the list contains null) |
duplicateValues at property level / at object level with arguments.properties |
expect_column_values_to_be_unique / expect_compound_columns_to_be_unique |
rowCount with operators |
a volume monitor (min_rows / max_rows) — the monitor kind, not the GX table expectation, so metric_value trends and the anomaly baseline see it. The monitor gains one-sided bounds (at least one of min_rows / max_rows, which also fixes the standing refusal of a min-only volume check), so mustBeGreaterThan: 1000 never invents a ceiling |
| the schema object's property list | one schema_drift check whose baseline is declared from the contract (column names, physicalType when given) instead of learned from the first run; declared names match the warehouse's case-insensitively, and a declared column with no type is never reported as type_changed |
type: sql with query + operator |
a custom-SQL check (ADR 0019, SQL datasources only) built as SELECT 1 WHERE NOT COALESCE((<query>) <operator>, FALSE) — one unexpected row when the scalar breaches or is NULL (an empty table must not pass a mustBeGreaterThan); {object} / {property} substituted with the suite target and the property name; the validator still has the last word and a refused query goes to unmapped |
type: custom, engine ∈ {great-expectations, greatExpectations, dataq} |
the implementation parsed as {expectation_type, kwargs} (GX) or as a DataQ check document (dataq); anything outside ALLOWED_EXPECTATIONS goes to unmapped |
type: custom with another engine; type: text |
unmapped (reason: engine not executable here / prose) |
slaProperties latency with a time unit and an element naming a column |
a freshness monitor on that column, max_age_hours from value + unit; without an element the suite's arrival-time freshness where the datasource supports it, else unmapped |
slaProperties frequency |
unmapped (reason: a refresh cadence is not a staleness ceiling — a daily feed checked at 24h flaps on every run); a producer who wants a ceiling states it as latency or as customProperties[dataq.maxAgeHours] on the object, which maps to the same freshness monitor |
every other SLA property (retention, availability, timeToRepair, …) |
unmapped (reason: not a data-quality check) |
property classification ∈ {confidential, restricted, pii, sensitive, secret} |
the suite's column_policy.pii_columns — the policy rung of the redaction ladder, so a contract's classification masks samples from the first run |
rule dimension |
conformity → validity; coverage → unclassified (NULL, ADR 0038's honest gap); the other five map by name; absent → DataQ's own derivation |
rule severity |
info → warn, warning → warn, error → fail, critical / blocker → critical; absent or unknown → fail — a contract clause is an assertion, and a producer who wants less says warning; overridable per rule by customProperties[dataq.severity] |
customProperties[dataq.schedule] (a cron) on a schema object |
one schedules row on the suite |
rule-level schedule / scheduler |
unmapped (reason: DataQ schedules a suite, not a rule; the rule's cron is recorded on the clause so the rollup can show it) |
4. DataQ-specific fields travel in customProperties, vendor dataq¶
On export every DataQ field with no ODCS home is written as a customProperties entry with
vendor: dataq and a camel-case property: per rule dataq.checkId, dataq.kind,
dataq.engine, dataq.severity, dataq.warnThreshold / dataq.failThreshold /
dataq.criticalThreshold, dataq.enabled; per object dataq.suiteId, dataq.connectionType;
on the contract dataq.exportedAt. A check with no ODCS-native shape (an anomaly or
comparison check, a GX expectation outside the table above) is exported as
type: custom, engine: dataq with the suite-document check as implementation, so
import(export(suite)) recreates every check — the round-trip the acceptance criteria ask for.
On import those properties are honoured when present and ignored when absent; a document that
never saw DataQ imports cleanly.
5. Versions of the standard¶
The importer accepts apiVersion v3.0.0 through the newest v3 minor it knows (v3.2.0 today),
validated against the vendored ODCS JSON Schema for that version (Apache-2.0, pinned under
backend/app/contracts/schema/, bumped by hand like GX). The mapping in §3 is written in
v3.1+ vocabulary; a v3.0.x document is normalised first — the deprecated rule names
(duplicateCount → duplicateValues, validValues → invalidValues + validValues, rowCount)
become metrics and the ${object} / ${property} placeholders become {object} /
{property} — in the translation step only, never in the stored spec. A newer v3 minor than the vendored
schema is accepted with a warning and validated against the newest known schema — ODCS minors
are additive and unknown fields are preserved anyway. A v2 document is refused with a
pointer to Bitol's migration notes; a v4 document is refused until this ADR is amended,
because a major may rename the fields the mapping keys on. Export always writes the newest
known v3 minor.
6. Re-import reconciles; it never destroys a person's work¶
Importing a version that already exists is a 409 unless replace: true; importing a new
version of a known name creates the row and, when the caller names the suites to move,
re-points them (contract_id) and reconciles their checks with ADR 0047's rules: add the
checks for new clauses, update a clause-derived check only if nobody edited it since the
import (compared against the clause's derivation, the same fingerprint idea as
check_suggestions), disable (never delete — deleting a check rewrites run history) the checks whose clause disappeared,
and leave every hand-authored check alone. A clause-derived check that a person did edit is
reported on the rollup as drifted.
7. Traceability: a check remembers its clause, and the incident carries it¶
checks.origin = 'contract' joins CHECK_ORIGINS, and a new nullable checks.contract_ref
JSONB holds {object, property, rule_id, field} — the clause the check enforces, kept out of
config so it survives the editor's wholesale config assignment. The incident evidence card
gains a contract layer (contract_id, name, version, clause) read from the failing
check, so get_incident (REST and MCP), the RCA prompt and every alert renderer can say "clause
orders.customer_id required of contract seller_payments_v1 1.1.0 broke" instead of a
check name. The layer is built from the check at evidence time, never copied by hand.
8. Reads, writes and who may do them¶
POST /contracts/import(ODCS YAML or JSON +connection_id; optionalsuite_idsto re-point on a new version) — Member or above,editon the suites it touches, audited with the contract id, version and counts (never the document).GET /contracts,GET /contracts/{id}(with the compliance rollup),GET /contracts/{id}/export(ODCS YAML,text/yaml) — a contract is visible when the caller canviewat least one linked suite or is a workspace admin; the rollup covers the visible suites and reportsrestricted_suite_count, asget_assetdoes (ADR 0037). A contract with no linked suite is visible to admins only.- Linking an existing suite to a contract by hand:
PATCH /suites/{id}withcontract: {contract_id, contract_object}(edit; the contract must be visible and name the object), orcontract: nullto unlink. Linking adds no checks; an import derives them.DELETE /contracts/{id}is Member+, needsediton every linked suite, and unlinks only. - MCP:
list_contracts,get_contract,export_contract— three read-only tools (ADR 0008 amendment, 56 → 59). Import stays REST-only: it creates suites and checks under a connection and is the kind of bulk write an assistant should hand to a person. - The compliance rollup is per contract: for each linked suite's latest run, each clause is
pass/warn/fail/critical/unverified(error or skipped) /not_run(no result in that run) /disabled(switched off) /missing(the contract derives a check for the clause and the suite has none — deleted, or a hand-linked suite), withdriftedas a separate flag (the check's engine, type, config or thresholds were edited since its import — it can drift and still pass; null when that cannot be told), plus the suite-levellast_run_atandresults_final, so a rollup over a mid-run suite is never read as a verdict. Theunmappedlist is thenot_enforcedhalf, counted beside the clauses.
9. UI, phase 1¶
A read-only Contracts page (list, detail with the rollup and the unmapped list, upload a document, download the export) and a contract chip on suite detail. No in-app contract editor — the document is authored where the producer keeps it. Rule 44 applies to the new surfaces.
Consequences¶
- One additive migration:
contracts,suites.contract_id+contract_object,checks.contract_ref,originvalue-set widened. Nothing existing changes shape. - The
schema_driftmonitor learns a declared baseline; today's learned-on-first-run baseline stays the default for non-contract checks. - The translation table above is the contract between DataQ and ODCS and lives in one module
(
backend/app/contracts/) with a table-driven test per row and a round-trip test; a new row is a PR against this ADR's table, not a special case elsewhere. - Suites gain a second system origin (
contract) besideauto; the reconciler rules are the ADR 0047 ones, so a person's edit is never overwritten by either. unmappedis a product fact, not a log line: a contract whose SLA block saysretention: 7ywill show that clause as not enforced by DataQ forever, which is honest and correct.- The evidence card, alert renderers and MCP docstrings gain a contract layer and must say when it is absent (the check was not contract-derived) — the usual honesty rule for this surface.
Alternatives considered¶
- A DataQ-native contract format with an ODCS exporter. Rejected by rule 45 and because two formats drift; storing the ODCS document verbatim is what makes "preserve what you don't understand" possible.
- One suite per contract regardless of object count. Rejected: a suite has one target, and a contract's objects are different tables, often on different schedules.
- Executing ODCS
sqlrules through a new scalar-comparison check kind. Rejected for now: theWHERE NOT (<scalar> <op>)wrapping makes every operator an unexpected-rows count, which ADR 0019's validator, pushdown and redaction already handle; ametric_valueis lost (the scalar is not recorded), which is accepted and noted on the rollup. - Deleting the checks of a removed clause on re-import. Rejected: deleting a check rewrites run history; disabling keeps the history and the audit trail.
- Producer-side enforcement / an embeddable validator wheel. Out of scope: a wheel is a release cadence and a compatibility promise, and no second producer has asked for one.
- Microsoft Purview / a catalog push of the contract. Not here; the governance-catalog seam
is a separate decision that will reuse the
contractsrow as its payload.