DataEdgePro Lab

Enforce the Promise: dbt, OpenMetadata and ODCS

A data contract is a promise written in a file. It might say that a table will always have these columns, that an identifier will never be empty, or that the data refreshes every day. The promise is clear, it is versioned, and the team reviews it. By itself, though, it protects nothing. This lab gives each of three tools one clear job, then builds the part that actually checks the promise and stops whatever would break it.

Level: Intermediate ~2–3h hands-on Stack: dbt · OpenMetadata · ODCS · Postgres · GitHub Actions ODCS v3.1.0
Builds on the OpenMetadata lab

This lab continues the earlier OpenMetadata lab and reuses its online_shop Postgres, with the same customers, orders and products tables and a catalog that is already running. If you have not done that lab yet, start there. We pick up right where it left off and add dbt as the transformation layer on top.

The one idea to hold on to

Most people assume dbt and OpenMetadata do the same job, and they really don't. dbt is the worker and OpenMetadata is the librarian. Neither of them is a contract on its own; that role belongs to ODCS, and enforcement is yet another layer that sits on top of all three.

d

dbt

the worker in the kitchen

It does the work. It runs SQL, creates tables, and turns raw data into clean, tested models. It only ever sees its own project, though. After every run it leaves three records behind: manifest.json, catalog.json and run_results.json.

O

OpenMetadata

the librarian of the house

It watches. It builds nothing of its own, but it keeps track of everything that exists and holds the catalog together: who owns what, how things connect, and whether quality holds. It reads the records dbt leaves and turns them into lineage, documentation and test results.

By the end, you'll be able to…

  • Explain why dbt and OpenMetadata play different roles, and why neither of them is a contract.
  • Build and test models with dbt, and tell a dbt model contract apart from an ODCS contract.
  • Ingest dbt's artifacts into OpenMetadata to get lineage, documentation that stays in sync, and test results.
  • Read and write an ODCS v3.1.0 contract, and run it against live data.
  • Enforce the contract at the CI gate and on a schedule in the catalog, and know which layer is really protecting the data.

What you'll learn

1

dbt as the worker

Staging and mart models, dbt model contracts, tests, and the three artifact files every run produces.

2

OpenMetadata as the librarian

Turning dbt artifacts into lineage, documentation that tracks the code, and a history of test results.

3

ODCS, the standard

The Open Data Contract Standard v3.1.0, and how it differs from a dbt model contract.

4

Three enforcement layers

Before (CI), during (streaming) and after (catalog), plus the two a warehouse team really needs.

The path

How to use this lab

Every lesson comes with a Do this exercise. Run the commands, break the data on purpose, and watch the contract turn red in your own instance. The understanding comes from doing rather than reading. Your progress is saved in this browser, so use the Mark complete buttons as you go.

Lesson 0

Setup: get the pieces running

Four pieces, each with one job. None of them doubles up: dbt never catalogs, OpenMetadata never transforms, and the contract file never enforces itself.

PieceRoleWhere
Shop Postgresthe warehouse, holding raw tables and everything dbt buildshost port 5433
dbt (Core + dbt-postgres)the worker, turning raw tables into a tested analytics schemayour host
OpenMetadatathe librarian, cataloging and ingesting dbt artifactsUI 8585
Data Contract CLIthe enforcement engine at the CI gateyour host
Prerequisites

You need three things in place. Docker Desktop, installed and running (on Windows it uses the WSL2 backend). Python 3.10 to 3.13. And the earlier OpenMetadata lab, whose shop database this lab reuses; OpenMetadata itself is only needed from Lesson 2 onward.

A note on paths

The commands below use paths relative to this lab folder (dbt-openmetadata-odcs-lab), so open your terminal there first. If a cd ..\openmetadata-lab\... line complains that the path does not exist, you are standing in the wrong folder. Either cd into the lab folder or use the full path.

Step 1 · Start Docker Desktop

Open Docker Desktop and wait until it reports that the engine is running. Until then, every docker command fails with a "cannot connect to the Docker daemon" error.

powershell
docker info   # succeeds once the engine is up
Docker Desktop showing the engine running with the sample-db and openmetadata-lab containers
Docker Desktop with the engine running (bottom-left) and the shop containers up

Step 2 · Start the shop database

powershell
# from this lab folder
cd ..\openmetadata-lab\sample-db
docker compose up -d
docker ps --filter name=shop_postgres   # expect 0.0.0.0:5433->5432

Step 3 · Install dbt and the Data Contract CLI

powershell
# back in this lab folder (dbt-openmetadata-odcs-lab)
python -m venv .venv
.\.venv\Scripts\Activate.ps1          # macOS/Linux: source .venv/bin/activate
pip install "dbt-core~=1.11.0" dbt-postgres "datacontract-cli[postgres]"
dbt --version                         # dbt Core 1.11.x, adapter postgres 1.11.x

Step 4 · Copy the dbt profile into place

dbt only reads a file named exactly profiles.yml, so copy the example across before you connect. Skip this and you will see “Could not find profile named 'shop'”.

powershell
Copy-Item dbt\profiles.example.yml dbt\profiles.yml
# macOS/Linux: cp dbt/profiles.example.yml dbt/profiles.yml

Step 5 · Point dbt at the shop DB and test it

powershell
$env:DBT_PROFILES_DIR = "$PWD\dbt"   # macOS/Linux: export DBT_PROFILES_DIR="$(pwd)/dbt"
cd dbt
dbt debug                          # expect: All checks passed!
dbt debug output ending in All checks passed
dbt debug connecting to the shop DB and ending in “All checks passed!”
Why on the host, and not in a container?

dbt is the worker, so it runs wherever your transformations run. On the host the connection stays simple: host=localhost, port=5433. OpenMetadata runs inside Docker and reaches the same database at host.docker.internal:5433. It is the same database seen from a different place, and that small difference trips people up all the time, so keep it in mind.

If something goes wrong

Three errors come up often, and each has a quick fix:

  • “failed to connect to the Docker API … the daemon is running”: Docker Desktop is not started. Launch it, wait for the engine, then retry.
  • “Could not find profile named 'shop'”: you skipped Step 4. dbt needs profiles.yml, not profiles.example.yml.
  • “Cannot find path … openmetadata-lab”: you ran a relative cd from the wrong folder. Start from this lab folder, or use the full path.
Docker up, dbt installed, and dbt debug green?
Lesson 1

dbt, the worker

Use dbt to build clean, tested models from the raw shop tables. Pay attention to the three files it leaves behind, because those files are the entire bridge to OpenMetadata.

shop.public.orders (raw)          shop.public.customers (raw)
        │                                 │
        ▼                                 ▼
   stg_orders (view)                stg_customers (view)     ← staging: clean 1:1
        │
        ▼
   daily_revenue (table)                                     ← mart: the dashboard reads this

The mart daily_revenue is dbt's version of the hand-written v_daily_revenue view from the OpenMetadata lab. The numbers are the same, but now the table is materialized, tested and covered by a contract. Everything dbt builds goes into a separate analytics schema, so raw and transformed data stay apart.

Do thisBuild the models
powershell
# from the dbt folder, with the venv active and DBT_PROFILES_DIR set in Setup
dbt deps          # fetch dbt_utils (range tests)
dbt build         # run + test every model, in dependency order

Confirm dbt actually created objects in the warehouse. The shop database runs in Docker, so query it through the container (no local psql needed):

powershell
docker exec shop_postgres psql -U shop_user -d shop -c "select * from analytics.daily_revenue;"
Query result showing four days of orders_count and revenue
Four completed-order days, with the cancelled order correctly excluded

You built real objects in the database. That is the worker doing its job, and it is something OpenMetadata could never do, because it builds nothing.

Two kinds of “contract” already live in this project

This is the distinction teams miss most often, so it helps to see it with the code in front of you.

Schema testsdbt model contract
Checksdo the values behave?is the shape guaranteed?
Runsdbt testdbt build (build time)
Exampleunique, not_null, accepted_rangedata_type: date must match the output column

With contract: enforced: true on the mart, dbt checks at build time that the output columns match the declared names and types exactly. If they drift, the build fails before the table is ever created.

Do thisBreak the shape on purpose

This one is a file edit, not a command to paste in the terminal. Open the mart model in your editor (it lives in this lab's dbt folder):

file
dbt\models\marts\daily_revenue.sql

Find the revenue line near the bottom and force the wrong type by adding ::text:

sql · daily_revenue.sql
    count(*)::bigint          as orders_count,
    sum(total_amount)::text   as revenue      -- was: sum(total_amount) as revenue
The daily_revenue.sql model with the revenue line changed to ::text
The one-line change in daily_revenue.sql

Save the file, then build just that model:

powershell
dbt build --select daily_revenue

dbt refuses, because the output column no longer matches the contract:

dbt build failing with an enforced-contract data type mismatch on revenue
The enforced contract fails the build: revenue came out TEXT, but the contract requires a numeric type

Now put the line back to sum(total_amount) as revenue, save, and rebuild to return the lab to health:

powershell
dbt build --select daily_revenue
dbt build completing successfully with PASS equals 7
Reverted and green again: the model and its tests all pass

You just watched Layer 1 enforcement work inside dbt: the problem never made it as far as the table.

Don't confuse dbt model contracts with ODCS

A dbt contract guarantees columns and types inside your dbt project. ODCS, which you will meet in Lesson 3, describes the whole agreement, including SLAs, ownership and support, across teams that never touch your dbt project. A sensible order of adoption is to start with dbt's native contracts on your final models, then reach for ODCS when you need to formalize an agreement with the outside world.

The three files dbt leaves behind

FileGenerated byHolds
manifest.jsondbt run/buildthe graph, columns, dependencies (for lineage), docs and compiled SQL. Required.
catalog.jsondbt docs generatereal column types and stats. Recommended.
run_results.jsondbt run/testthe outcome of the last run, meaning which tests passed or failed. Recommended.
Do thisGenerate all three cleanly

Run them in the order OpenMetadata expects, with the tests before the catalog, so the latest results are captured:

bash
dbt run             # manifest.json + run_results.json
dbt test            # updates run_results.json with test outcomes
dbt docs generate   # catalog.json (+ refreshes manifest.json)
ls target/*.json    # manifest.json, catalog.json, run_results.json all present

Checkpoint

What's the difference between a dbt schema test and a dbt model contract?
A schema test checks the values (it runs on dbt test and passes or fails). A model contract checks the shape, meaning column names and types, at build time, and fails the build before the table exists. Neither of them is ODCS.
Which of the three artifact files is required by OpenMetadata, and why?
manifest.json. It carries the dependency graph used for lineage, along with columns, docs and compiled SQL. catalog.json and run_results.json are recommended (types and test results) but optional.
Models built, contract broken and reverted, artifacts generated?
Lesson 2

OpenMetadata, the librarian

Hand dbt's artifacts to OpenMetadata and watch it observe. It builds lineage through your models, syncs the documentation you wrote in YAML, and shows your dbt test results in the catalog.

dbt run/test/docs generate               OpenMetadata
        │                                       │
   target/manifest.json    ──ingest──▶   lineage (raw → stg → mart)
   target/catalog.json     ──ingest──▶   column types + docs, in sync with code
   target/run_results.json ──ingest──▶   test pass/fail history

Open OpenMetadata first

Everything in this lesson happens in the OpenMetadata web app, which is a different thing from Docker Desktop. Docker only keeps the containers running in the background; the clicking is all in your browser.

  1. Go to http://localhost:8585 in a browser.
  2. Sign in with the default lab credentials: user admin@open-metadata.org, password admin.
  3. Find your shop database service under the gear icon → Settings → Services → Databases. From the OpenMetadata lab it is called online_shop.
OpenMetadata Settings, Services, Databases, showing the online_shop Postgres service
Settings → Services → Databases, with the online_shop service
Order matters: catalog the tables first

Before you push the dbt information, open the online_shop service and run its Metadata ingestion once, then confirm that stg_orders, stg_customers and daily_revenue all appear under the analytics schema. The dbt step only enriches tables that already exist: it adds lineage, descriptions and test results onto catalog entities. If those tables are not there yet, the push still succeeds but attaches nothing, and you will see Updated records: 0 in the summary. That service reaches Postgres through host.docker.internal:5433, which is how a container connects to a database on your host.

Ingest the dbt artifacts

OpenMetadata's built-in ingestion runs inside a container, which cannot read the target/ files sitting on your Windows host. The simplest way around that on a local setup is the metadata ingest-dbt command: it runs on your host, where the files already are, and pushes everything to OpenMetadata over its API.

Do thisIngest with metadata ingest-dbt

1. Install the OpenMetadata ingestion package into the same venv you used for dbt. It must match your server's major.minor version, or it refuses to connect. This lab runs OpenMetadata 1.12.x, so pin the client to 1.12:

powershell
pip install "openmetadata-ingestion[dbt]==1.12.*"
# the client version must match the OpenMetadata server (check yours in Settings)

2. Get an API token: in OpenMetadata, go to Settings → Bots → ingestion-bot and copy its JWT token.

The ingestion-bot page in OpenMetadata with its JWT token
Settings → Bots → ingestion-bot, where you copy the JWT token

3. Add an openmetadata block to the vars: section of dbt\dbt_project.yml (add a vars: section if there isn't one):

yaml · dbt_project.yml
vars:
  openmetadata_host_port: "http://localhost:8585/api"
  openmetadata_jwt_token: "<paste the ingestion-bot token>"
  openmetadata_service_name: "online_shop"
dbt_project.yml with the vars block for OpenMetadata added at the bottom
The vars: block added to dbt_project.yml (token redacted)

4. You already generated the artifacts in Lesson 1, so from the dbt folder run:

powershell
metadata ingest-dbt

It finds target/, connects to OpenMetadata, and reports the models it processed. This is a local lab, so pasting the token is fine; for anything real, use an environment variable instead of writing it into the file.

metadata ingest-dbt output showing 100% success and DBT artifacts ingestion completed successfully
A successful push: 100% success, and “DBT artifacts ingestion completed successfully”
AlternativeThe UI method (needs the files inside the container)

You can also add a dbt ingestion from the UI, on the online_shop service under Ingestions → Add Ingestion → dbt Configuration, with source Local Storage. The catch is that the path has to be one the ingestion container can see, so you copy the three files into it first (find the container name with docker ps; it is usually openmetadata_ingestion):

powershell
docker cp dbt\target\manifest.json    openmetadata_ingestion:/tmp/dbt/manifest.json
docker cp dbt\target\catalog.json     openmetadata_ingestion:/tmp/dbt/catalog.json
docker cp dbt\target\run_results.json openmetadata_ingestion:/tmp/dbt/run_results.json

Then point the ingestion at /tmp/dbt/manifest.json and the other two. The host-side command above avoids all of this, which is why it is the recommended path.

Do thisSee what the librarian now knows
  1. Lineage. Open analytics.daily_revenue and go to Lineage. You should see public.orders, then stg_orders, then daily_revenue, drawn from the depends_on graph in the manifest.
  2. Docs in sync. The column descriptions you wrote in _marts.yml are now in the catalog. Change the YAML, re-run and re-ingest, and the catalog updates.
  3. dbt tab. The model shows its compiled SQL from the manifest.
  4. Test results. The unique, not_null and range outcomes from run_results.json appear as pass or fail.
OpenMetadata lineage graph showing orders to stg_orders to daily_revenue
The lineage OpenMetadata derived from the manifest: orders → stg_orders → daily_revenue

You didn't teach OpenMetadata any of this. It observed all of it from the records dbt left behind.

Why neither tool is a contract

dbt only knows its own perimeter, so it has no idea who consumes the table or what SLA finance is counting on. OpenMetadata only describes what already exists, which makes it a mirror rather than a promise. The formal agreement between producer and consumer is the contract, and that agreement now has a standard.

dbt ingested, with lineage, docs and tests visible in the catalog?
Lesson 3

ODCS, the contract

Write the formal agreement that binds the producer of daily_revenue to its consumers, using the open standard the industry has now settled on.

The standards question is settled

ODCS, the Open Data Contract Standard, is an open, machine-readable YAML format. Version 3.1.0 shipped in December 2025 under the Linux Foundation's Bitol project, and the competing specification was deprecated in the same period. In 2026, a data contract is written in ODCS. Reference: bitol.io.

ODCS is not the dbt model contract

You now have two contracts on one table, and they do different jobs:

dbt model contractODCS contract
Lives in_marts.ymldaily_revenue.odcs.yaml
Coverscolumn names and typesschema, quality, SLA, ownership, roles, support
Scopeinside your dbt projectthe whole agreement, across teams
Enforcedat dbt buildthe CI gate, then on a schedule in the catalog
Answers“did the model keep its shape?”“is the producer keeping the full promise?”

Anatomy of the contract

A single document covers the whole agreement. The servers block is what makes it executable, since that is where the CLI connects.

yaml · daily_revenue.odcs.yaml
apiVersion: v3.1.0
kind: DataContract
id: daily-revenue-001
name: Daily Revenue Contract
version: 1.0.0
status: active
domain: revenue
servers:
  - server: shop-postgres
    type: postgres
    host: localhost
    port: 5433
    database: shop
    schema: analytics          # the dbt-built schema, not public
schema:
  - name: daily_revenue
    physicalType: table
    properties:
      - name: order_date
        physicalType: DATE
        primaryKey: true
        required: true
        unique: true
      - name: revenue
        physicalType: NUMERIC
        required: true
        classification: restricted
    quality:
      - type: library
        metric: rowCount
        mustBeGreaterThan: 0
      - type: sql
        description: Revenue must never be negative.
        query: SELECT COUNT(*) FROM daily_revenue WHERE revenue < 0
        mustBe: 0
team:
  members: [{username: bmillet, role: Owner}]
slaProperties:
  - {property: frequency, value: "1", unit: d}
  - {property: freshness, value: "24", unit: h}
support:
  - {channel: "#data-revenue", tool: slack}

That SLA, ownership and support section is the part that could never live in a dbt model contract. It is an agreement between people, not a check on the shape of a table.

Do thisValidate the document (no DB needed)

Run this from the lab root (the folder that contains contracts\), as one line. The backslash line continuation you see in bash examples breaks PowerShell.

powershell
pip install check-jsonschema
check-jsonschema --schemafile https://raw.githubusercontent.com/bitol-io/open-data-contract-standard/main/schema/odcs-json-schema-latest.json contracts\daily_revenue.odcs.yaml

Expected output: ok -- validation done. Green means the document is well-formed ODCS. It tells you nothing yet about whether the live data obeys it; that comes with enforcement in Lesson 4.

One format detail learned the hard way: write the team block in the array form (team: then - username: ...), not the object form with name and members. Both pass the Bitol schema, but OpenMetadata's importer in Lesson 4 only accepts the array form.

A contract on paper still protects nothing

You now have a valid, versioned, reviewable ODCS document. By itself it still does nothing to stop someone breaking it tomorrow. When people say the contracts are live, the question worth asking is which layer is actually enforcing them today. If the honest answer is that the catalog is holding the file, the work is not finished.

Contract read end to end, and it validates against the ODCS schema?
Lesson 4

Enforcement: three layers

Turn the contract from a promise on paper into one that protects. A contract is only worth as much as the check behind it, so the real question is not whether to verify but when. There are three moments to choose from.

#MomentWhereTrade-offTool
1Beforein CIcheapest and most preventive, but only covers codedatacontract-cli, dbt contracts
2Duringstreamingreal time, but complex and streaming-onlybroker-side enforcement
3Afterthe catalogeasy to install, finds out after the fact, and adds history and SLAOpenMetadata 1.10+
The recommended stack for a warehouse team

For batch work, combine two of the layers and leave the third aside. Write the contracts in ODCS, enforce them at the CI gate (Layer 1), and wire OpenMetadata for scheduled validation (Layer 3). Layer 2, streaming, only earns its place when you genuinely have a stream to protect. This lab has none, and leaving it out is the right call rather than a gap.

Layer 1: enforce at the CI gate (before)

Block the break in the pull request, before it can ever reach the data. On every PR that touches dbt/ or contracts/, three gates run against a throwaway Postgres, cheapest first:

1 · Schema gate → is the ODCS file valid ODCS 3.1.0?   (check-jsonschema)
2 · Build gate  → dbt build (model contract + data tests)
3 · Data gate   → does the LIVE built data satisfy the ODCS contract?  (datacontract test)
Install datacontract-cli in its own venv, not dbt's

Hit live on Windows / Python 3.13: installing datacontract-cli into dbt's venv downgrades mashumaro below 3.11 (a pin from its collate-data-diff dependency), and dbt then crashes with AttributeError: __class_getitem__. Give the CLI its own venv (one-time, from the lab root):

powershell
python -m venv .venv-contract
.venv-contract\Scripts\pip install "datacontract-cli[postgres]"

If dbt already crashed, repair its venv with pip install "mashumaro[msgpack]==3.14" (there is no 3.14.1; dbt needs <3.15 and 3.14 supports Python 3.13). Real CI avoids all of this by running the two tools in separate jobs.

Do thisRun the three gates locally
powershell
# gate 1 -- validate the ODCS document
check-jsonschema --schemafile https://raw.githubusercontent.com/bitol-io/open-data-contract-standard/main/schema/odcs-json-schema-latest.json contracts\daily_revenue.odcs.yaml
# gate 2 -- build and test the models
cd dbt ; dbt build ; cd ..
# gate 3 -- run the contract against the live data (CLI from its own venv)
$env:DATACONTRACT_POSTGRES_USERNAME = "shop_user"
$env:DATACONTRACT_POSTGRES_PASSWORD = "shop_pass"
.venv-contract\Scripts\datacontract test contracts\daily_revenue.odcs.yaml

Expect 13 checks green: the three quality rules plus the schema checks the CLI derives from the contract's properties.

datacontract test output with all 13 checks passed, data contract is valid
Gate 3 green: datacontract test connects via the servers block and runs every promise as SQL
Do thisBreak the contract and watch the gates go red

Play the careless insider: insert a bad order straight into the warehouse, on a fresh day so that day's revenue comes out negative. (Putting it on a day that already has sales would only lower the total, not push it below zero — aggregation can hide bad source rows.)

powershell
docker exec shop_postgres psql -U shop_user -d shop -c "INSERT INTO public.orders (customer_id, order_date, status, total_amount) VALUES (1, '2025-05-10', 'completed', -50.00);"

Rebuild before you re-test. The mart is a table: if you skip dbt build, the CLI happily validates the stale data and everything stays green. A green check is only as fresh as the last build.

powershell
cd dbt ; dbt build ; cd ..
.venv-contract\Scripts\datacontract test contracts\daily_revenue.odcs.yaml

Both layers catch it: dbt build finishes with ERROR=1 (the model's own data test — the worker's perimeter), and the contract goes red:

output
data contract is invalid, found the following errors:
1) Revenue must never be negative.: Actual was 1, expected = 0
datacontract test output with Revenue must never be negative failed, data contract is invalid
The contract catches the manual insert: red check, non-zero exit code — exactly what turns a CI run red

In a real PR this exit code blocks the merge. Clean up by deleting the bad row and rebuilding, then confirm green again:

powershell
docker exec shop_postgres psql -U shop_user -d shop -c "DELETE FROM public.orders WHERE total_amount < 0;"
cd dbt ; dbt build ; cd ..
.venv-contract\Scripts\datacontract test contracts\daily_revenue.odcs.yaml
datacontract test output back to all checks passed after cleanup
Restored: PASS=21 in dbt, 13/13 contract checks green

Layer 1 does have a limit: it only sees what goes through code, so a manual insert like the one you just made slips straight past CI — you only caught it because you ran the checks by hand. That is exactly why you also want Layer 3, which runs them on a schedule.

Layer 3: validate in the catalog on a schedule (after)

The data is already sitting in the warehouse. On a schedule, OpenMetadata re-runs the contract checks and flags anything that failed. You gain a history and SLA reporting that CI cannot give you. OpenMetadata added contracts in v1.9 (August 2025) and full ODCS 3.1 import and export in v1.10 (October 2025).

Do thisImport the ODCS contract into OpenMetadata
  1. Open localhost:8585 (login admin@open-metadata.org / admin), search daily_revenue and open the table under the online_shop service.
  2. Click the Contract tab. It starts empty — click the ˅ arrow on Add Contract and choose Import ODCS, then upload contracts\daily_revenue.odcs.yaml. Wait for Schema Validation: Passed. If it fails with Cannot deserialize ... ArrayList at the team block, your contract uses the object form — switch to the array form (see Lesson 3).
  3. Click Import. The contract now shows in the tab: description, SLA (freshness / latency / retention), and the schema with PRIMARY_KEY and NOT_NULL constraints.
Import ODCS Contract dialog with Schema Validation Passed, 3 fields verified
The import dialog once the file parses: syntax valid, 3 fields verified
Contract tab showing the imported Daily Revenue Contract with description, SLAs and schema
The contract living in the catalog — but scroll it and count what survived

Now audit what survived. On a real run against OpenMetadata 1.12.6: description ✔, schema and constraints ✔, SLA partially (frequency not surfaced) — and all three quality rules silently dropped. No error, no warning. The catalog looks protected while nothing checks revenue. That is the March 2026 pitfall, reproduced live.

Do thisRecreate the quality rules as native test cases and schedule them

Since the import dropped the quality rules, mirror them as native OpenMetadata test cases via the API. The lab ships a script that reads the ingestion-bot JWT you already stored in dbt/dbt_project.yml in Lesson 2:

powershell
# from the lab root (quote the path if it has spaces), dbt venv active
python scripts\create_om_test_cases.py

Expected: [ok] table found, [ok] test suite, then three [ok] test case created lines for contract_row_count_gt_0, contract_orders_count_positive and contract_revenue_never_negative.

  1. See them. On the table page: Data Observability tab → Data Quality sub-tab. You'll count 9 test cases: 6 dbt tests already green (those results were synced from dbt by Lesson 2's ingestion — the librarian reporting what the worker ran) and your 3 contract_* tests with no result yet: nothing in the catalog has executed them.
Data Quality tab with six dbt tests green and three contract tests without results
Before the pipeline: dbt's tests report their last dbt run; the contract tests have never been executed
  1. Schedule them. Still under Data Quality, click the Pipelines sub-tab (shows 0) → Add Pipeline. Name it (for example dq test), leave the test-case selection on All, set the schedule (for example every day at 12:00 AM, after the nightly dbt build), and submit. You should see "has been created and deployed successfully".
  2. Run it once now. Back on the Pipelines list, use the row's Run action, wait about a minute, refresh: Recent Runs: Success, Status: Active.
Pipelines tab showing the dq test pipeline Active with a successful recent run, scheduled daily
The scheduled audit, live: deployed, Active, first run Success
  1. Confirm. Switch back to Test Cases: 9 / 9 Successful, and the three contract_* tests now carry their own Last Run timestamp — executed by the catalog, not by dbt. Every scheduled run appends to this history: the pass/fail and SLA reporting view CI cannot give you.
Test cases list with all nine tests successful including the three contract tests
Layer 3 wired: the promise is now audited on a schedule, with history

The same promise is now enforced at two moments: blocked in CI before merge (Layer 1) and audited on a schedule in the catalog (Layer 3) — with the honest caveat that the catalog's copy of the quality rules is maintained by script until the ODCS import defect is fixed.

The pitfall: don't rely on the catalog as your only registry

A defect reported in March 2026 is worth knowing about. When an ODCS contract is imported, the quality field can be stripped during schema synchronization, quietly dropping your rules. Confirmed live in this lab on 1.12.6: the import kept description, SLA and schema, and dropped all three quality rules without a word. Check your own version after importing, and keep the source of truth in Git, enforced at the CI gate. Treat the catalog as a consumer of the contract for monitoring, not as the master copy.

The full chain, end to end

ODCS contract   declared in YAML, versioned + reviewed in a PR
      │
   CI gate       check-jsonschema + dbt build + datacontract test   ← blocks any violation
      ▼
   dbt           builds + tests models, leaves manifest/catalog/run_results
      ▼
   OpenMetadata  ingests artifacts → lineage/docs/tests; imports ODCS → scheduled validation + SLA
A contract that protects, not just reassures

dbt builds and transforms. OpenMetadata catalogs, connects and monitors. ODCS binds the producer to the consumer. The standard, though, is not the safeguard. The safeguard is the layer that checks the promise. One kind of contract lives in a file; the other lives in a layer that verifies, every day, that the promise still holds.

Checkpoint

Which two layers does a warehouse team use, and which does it skip?
Layer 1 (the CI gate, before) and Layer 3 (the catalog on a schedule, after). It skips Layer 2, streaming, unless there is a real stream to protect.
Why keep the contract's source of truth in Git rather than the catalog?
CI enforcement in Git blocks violations before they land, and the March 2026 defect can quietly strip the quality field on import into OpenMetadata. The catalog is a monitoring consumer, not the master copy.
Layer 1 and Layer 3 wired, a break blocked in CI, and the pitfall understood?
Conclusion

A contract that protects, not just reassures

You have given three tools a clear job each and built the layer that checks the promise. Now step back and look at what each one really does, where the standard stops and the organisation has to take over, and how much anyone can honestly claim to know about this yet.

Each tool does exactly one job

ToolRoleWhat it can't do
dbtbuilds and transforms, and leaves manifest/catalog/run_resultsonly knows its own project, not who consumes the data or what SLA they expect
OpenMetadatacatalogs, connects lineage, and monitors on a scheduleonly describes what already exists, so it is a mirror, not a promise
ODCSbinds producer to consumer: schema, quality, SLA and ownershipit is a file, so it cannot enforce itself
Enforcementthe CI gate plus scheduled catalog checks that block or flag breaksonly protects as far as the layer you actually wired up
The big idea

The standard is not the safeguard. The safeguard is the layer that checks the promise, and there are three places to put it: before (CI), during (streaming) or after (catalog on a schedule). For a warehouse team, the first and the third together are usually enough. A contract that only reassures lives in a file; a contract that protects lives in a layer that verifies, every day, that the promise still holds.

The verdict

Strengths

  • Each layer has one job, and no tool pretends to do its neighbour's.
  • An open standard (ODCS) keeps it portable, vendor-neutral and versioned in Git.
  • Two contracts give you two safety nets: dbt guards the shape, ODCS guards the agreement.
  • Failures are caught early, at the CI gate, before they reach real data.
  • Layer 3 adds the history and SLA reporting the CI gate cannot provide.

Limitations

  • Layer 1 only covers what goes through code, so a manual UPDATE bypasses it.
  • The tooling is young: ODCS 3.1 arrived in December 2025, OpenMetadata's ODCS support in October 2025.
  • The March 2026 import defect can silently strip the quality field.
  • None of it means much without a named owner who is accountable when it breaks.
  • Coverage is opt-in, so undocumented datasets stay ungoverned.

No one has five years of this

Keep your expectations grounded. ODCS 3.1.0 arrived in December 2025. OpenMetadata shipped contract enforcement in v1.9 (August 2025) and full ODCS support only in v1.10 (October 2025). The capability is measured in months, not years, so nobody has real tenure enforcing ODCS contracts in OpenMetadata. What does carry over is the judgment underneath the tools:

  • Schema evolution: knowing which changes are safe, which break consumers, and how to version a breaking change without causing an incident.
  • Quality gates: deciding what to test, where to test it, and which failures should block rather than warn. The rules matter more than the syntax.
  • Ownership: negotiating the boundary between producers and consumers, and holding named owners to it. This is organisational work, not tooling.

Someone who can explain why a catalog holding a file is not the same as a contract being enforced understands more than someone who lists a tool they cannot have used for long. On this stack, weigh judgment over years served, because the years are not there yet for anyone.

The organisation and framework layer: CDO Compass

Ownership, the change and breach processes, and the operating model that gives a contract its weight all sit beyond the tooling. That is the ground CDO Compass covers on DataEdgePro. Pair this hands-on lab with the framework: dataedgepro.net · CDO Compass.

You gave the tools their roles and wired up the enforcement that protects.