Stop bad data here.
Not downstream,
three days later.
Your data contracts already exist. Covenant is the part that runs them — so a record that breaks one never reaches the people who depend on it, and a change that breaks a consumer never reaches your main branch.
One static Rust binary, at every boundary your data crosses: the pull request, the CI job, the live stream, the batch in memory. No warehouse grant, no JVM, no Python runtime, and it sends nothing anywhere.
- Apache-2.0
- NDJSON · CSV · Parquet · Arrow
- Exit 0 / 1 / 2
- No telemetry
The same rename,
with and without a gate.
One producer drops a field that a downstream team reads. Nothing else changes. What changes is whether anything was standing at the boundary when it happened.
-
Mon 09:14
A producer drops
customer_emailfrom the orders contract. The contract file changes in the same pull request as the code. -
Mon 09:16
CI is green. It lints the YAML and never compares it to the previous version, so nothing notices that a field someone reads has gone.
-
Mon 11:02
Merged. The producer moves on, correctly believing the change was reviewed.
-
Thu 08:40
Finance's daily rollup has been writing nulls since Monday. Someone spots it in a dashboard and asks in Slack whether anyone changed anything.
-
Thu 14:20
Three days of loads to re-run, and an afternoon spent bisecting commits to find a rename that was written down all along.
covenant diff in CI
Caught before merge
-
Mon 09:14
The same pull request, the same dropped field.
-
Mon 09:16
The check compares the two contract versions and exits 1. It names the change breaking, says consumers feel it, and lists which one:
finance_daily_rollup, and the person who owns it. -
Mon 09:31
The producer restores the field, or keeps the removal and bumps the major version — which is now a conversation with finance instead of a surprise.
-
Thu 08:40
The rollup is correct. Nobody is looking at it.
-
Thu 14:20
Nothing happens. That is the entire product.
An illustration of one week, not a captured run. The verdict it turns on is real, and shown further down: the merge gate classifies exactly this change as breaking and names that consumer.
The contract was right there.
It did nothing.
That is the whole failure, and it is not a tooling gap — it is a step nobody runs. The YAML gets authored, published to a catalog, linted in CI, and then nothing executes the assertions where the data actually flows. Catalogs describe. Spec tools lint. Scan-based checkers observe after the load, from outside the path the data took. The promise existed the entire time and never once got checked.
Covenant is the half that runs: one compiled contract, enforced at the boundary, cheap enough to sit in the data path. Which is also why a light leak is chance and a violation is not — every mark on this page names a rule, a row and a value, because that is all Covenant ever emits.
Start where the risk is zero:
block the pull request.
covenant diff runs on two YAML files. It touches no data path, needs no
credentials, and returns a verdict in a PR check, which is why it is the first thing
to adopt. It classifies every change as breaking, risky,
or info, tags whether producers or consumers feel it, and enforces the
matching semver bump.
$ covenant diff main/orders.yaml pr/orders.yaml --fail-on breaking
contract diff: v1.2.0 -> v1.2.1 (5 changes)
[risky ] (producers) models.orders.fields.amount_cents: min tightened 0 -> 100
— conforming values may start failing
[risky ] (consumers) models.orders.fields.currency: allowed set widened — added: "JPY"
(consumers switching on values may not handle them)
[breaking] (consumers) models.orders.fields.customer_email: field removed
— consumers reading it break
[breaking] (producers) models.orders.fields.region: required field added
— existing producers don't send it
[breaking] (both ) version: changes require at least a major version bump,
but version went 1.2.0 -> 1.2.1
$ echo $?
1
Classification says what changed. Consumer manifests say who breaks. Each consumer declares the fields it actually reads in a small file kept next to its own code, and the merge gate intersects the diff with those declarations:
consumer: 1
id: finance_daily_rollup
owner: finance-eng@acme.io
consumes:
- contract: orders
fields: [order_id, customer_email, currency]
# omit fields = the whole model
impacted consumers (4 manifests, 4 consume orders):
[breaking] finance_daily_rollup (finance-eng@acme.io)
via currency, customer_email
— models.orders.fields.currency,
models.orders.fields.customer_email
[risky ] looker_revenue (analytics@acme.io)
via currency
— models.orders.fields.currency
unaffected: ml_churn_features, ops_alerting
The matching is deliberately honest. A tightened bound or a new required field is felt
by producers and never by readers, and the semver finding is process discipline rather
than a shape change, so neither is counted against a consumer. Two of the four
consumers never read the changed fields and are reported as provably untouched.
--fail-on breaking-with-consumers turns this into the adoption-friendly gate:
block the merge only when a declared consumer actually breaks, report everything else.
The loop closes on the consumer's side too, in the consumer's own CI:
covenant consumer-check verifies every declared field is still enforced and
grades the optional verified: pin, and
covenant check --as-consumer validates only the fields that consumer reads —
dirt in fields it never touches stays the producer's problem. A stale manifest can never
scope a data check, and is refused outright.
A failing check names the rule,
the row, and the value.
A non-zero exit code in the run that produced the data, addressed to the team that owns the contract, rather than a dashboard entry somebody notices three days later.
$ covenant check examples/data/orders_bad.ndjson -c orders.yaml
FAIL examples/data/orders_bad.ndjson [orders v1.2.0, model orders]
rows checked: 6 violations: 9
contract owner: data-platform@acme.io
by rule:
order_id pattern × 1
amount_cents min × 1
currency allowed × 1
order_id unique × 1
order_id required_missing × 1
customer_email format × 1
created_at type_mismatch × 1
internal_debug unexpected_field × 1
<record> record_not_object × 1
samples:
[row 1] row 1: field "order_id" value "ORD-UPPERCASE" does not match pattern "^ord_[a-z0-9]{12}$"
[row 1] row 1: field "amount_cents" value -50 is below min 0
[row 1] row 1: field "currency" value "BTC" not in allowed set [EUR, GBP, USD]
[row 2] row 2: field "order_id" value ord_a1b2c3d4e5f6 was already seen (unique)
[row 3] row 3: required field "order_id" is missing
[row 3] row 3: field "customer_email" value "not-an-email" is not a valid email
[row 3] row 3: field "created_at" value yesterday is not an RFC 3339 timestamp
[row 4] row 4: field "internal_debug" is not declared in the contract (strict model)
[row 5] row 5 (line 6): invalid JSON: expected ident at line 1 column 2
Counts stay exact however bad the file is; only the examples are capped
(sample_violations, ten per field-and-rule by default), so a
million-row disaster still produces a bounded, readable report rather than a
gigabyte of log. A tolerated budget is a policy field, not a flag you remember:
max_violations.
| Code | Meaning |
|---|---|
| 0 | Clean — the data conforms, or the diff is acceptable. |
| 1 | The subject violates: data breaks the contract, the diff is breaking, or the contract has error-level lint findings. |
| 2 | The run failed: bad flags, an unreadable file, or a contract too broken to enforce. |
In the stream, the bad record
never reaches the topic.
covenant gate is a plain pipe process: records in on stdin, clean records
out on stdout, violations diverted to a dead-letter file with the rule that caught them.
It is the Kafka SMT equivalent without being tied to Kafka, so anything that can pipe
can gate.
$ kcat -C -t orders_raw -e \
| covenant gate -c orders.yaml --dlq /var/log/orders.dlq.ndjson \
| kcat -P -t orders_validated
$ covenant gate -c orders.yaml --dlq orders.dlq.ndjson < orders_bad.ndjson > orders_clean.ndjson
covenant gate [orders v1.2.0, model orders]: 6 records, 1 passed, 5 blocked, 0 warned
$ cat orders_clean.ndjson
{"order_id":"ord_a1b2c3d4e5f6","amount_cents":12999,"currency":"USD","created_at":"2026-08-11T09:30:00Z"}
The producer boundary, not the consumer autopsy
The gate sits before the topic or the table. One record survived; five were withheld and written to the dead-letter file, each wrapped in an envelope that names the contract, the row, and every rule it broke. That envelope goes to the producing team while the data is still theirs to fix, instead of to a consumer three days later through a broken dashboard.
Policy decides the posture: on_violation: block withholds the record,
warn lets it through and counts it. Optional taps
(--dlq, --stats) write the files that the off-by-default
local console reads; the gate itself stays a lean sync process with no state.
{"contract_id":"orders","contract_version":"1.2.0",
"model":"orders","row":1,"ts":"2026-08-21T19:14:45Z",
"record":{"amount_cents":-50,"created_at":"2026-08-11T09:31:00Z",
"currency":"BTC","order_id":"ORD-UPPERCASE"},
"violations":[
{"model":"orders","field":"order_id","rule":"pattern",
"row":1,"value":"ORD-UPPERCASE","message":"row 1: field
\"order_id\" value \"ORD-UPPERCASE\" does not match
pattern \"^ord_[a-z0-9]{12}$\""},
{"model":"orders","field":"amount_cents","rule":"min", …},
{"model":"orders","field":"currency","rule":"allowed", …}]}
One core, one second,
and about seven megabytes.
Enforcement you cannot afford to run is enforcement that does not run. So here is what Covenant actually costs, measured rather than asserted — every row below came from running the binary, and the method is written under the table.
| Operation | Records | Wall time | Peak memory |
|---|---|---|---|
| covenant validate | contract only | < 10 ms | 6.6 MB |
| covenant diff the merge gate | two contracts | < 10 ms | 5.7 MB |
| covenant infer default sample | 10,000 | 0.01 s | 6.2 MB |
| covenant infer full scan | 1,000,000 | 1.58 s | 6.1 MB |
| covenant check NDJSON, 140.7 MB | 1,000,000 | 1.10 s | 6.7 MB |
| covenant check CSV via Arrow, 67.3 MB | 1,000,000 | 0.88 s | not measured |
| covenant gate stdin to stdout | 1,000,000 | 2.07 s | not measured |
Memory is flat. Uniqueness is the exception.
The runtime holds about seven megabytes no matter how large the file is, because it
streams. Exactly one rule breaks that, and it breaks it honestly:
unique has to remember every key it has seen. The same million-record
check costs 6.7 MB without a uniqueness rule and
113 MB with one, and the difference is the key set, not overhead.
That is also why bounded uniqueness for unbounded streams is on the roadmap rather
than in the runtime: a sliding window is the only honest way to keep the rule in a
gate that never ends. Until it ships, unique belongs on bounded files.
How this was measured
- Release build, run in a Linux container pinned to one CPU, on an AMD Ryzen 9 3950X.
- Wall time and peak resident memory from
/usr/bin/time, best of three runs. - One million synthetic order records against the demo contract, read from local disk, output discarded.
- Numbers from one machine and one shape of data. They are a floor to reason about, not a promise about your workload.
Nobody writes their first contract
from a blank page.
covenant infer reads a sample of real records — NDJSON, CSV, or Parquet —
and drafts one. The draft is honest about its evidence, which is the whole point.
# DRAFT contract inferred by `covenant infer` from 3 sampled record(s).
# source: examples/data/orders.ndjson
#
# A sample proves what appeared; it can never prove what will not.
# Every `confirm:` line below is a guess from this window — read it,
# then keep, widen, or delete the rule above it.
covenant: 1
id: orders
version: 0.1.0 # 0.x: a draft, not yet a promise
# owner: data-platform@example.com # TODO: who answers for this?
models:
orders:
# confirm: the 3 records carried 3 different field sets (optional
# fields come and go) — drafted `strict: false`; turn it on once
# the shape is settled, it is what catches silent additions
strict: false
fields:
amount_cents:
type: integer
required: true
# confirm: observed range [250, 499900] over 3 values — widen or
# delete these before enforcing, a sample is not a bound
min: 250
max: 499900
# confirm: absent from 1 of 3 sampled records — drafted optional;
# make it required if the producer always sends it
# observed: 1 of 2 present values were null
customer_email:
type: string
nullable: true
format: email
Types, presence, and nullability are the things a sample really settles, so they are
emitted as rules. Everything guessed from the window is emitted with a
# confirm: note naming exactly what it was inferred from.
unique. A wrong uniqueness rule fails clean data in production, and a sample can never prove it.
Mixed-type columns widen to string and say so. Nested objects are flagged, never silently flattened. An all-null column admits it is a guess.
The version starts at 0.1.0 and owner: is left as a TODO, because a drafted contract is not something anyone should pin to yet.
Everything in the spec is
something the runtime enforces.
Anything Covenant cannot check at the boundary is deliberately not in the schema. That is the rule that keeps the file from drifting back into documentation.
covenant: 1
id: orders
version: 1.2.0 # semver — `covenant diff` enforces the bump
owner: data-platform@acme.io
models:
orders:
strict: true # undeclared fields are violations
fields:
order_id: { type: string, required: true, unique: true,
pattern: "^ord_[a-z0-9]{12}$" }
amount_cents: { type: integer, required: true, min: 0 }
currency: { type: string, required: true,
allowed: [USD, EUR, GBP] }
customer_email: { type: string, format: email, nullable: true }
created_at: { type: timestamp, required: true }
policy:
on_violation: block # block | warn
max_violations: 0 # tolerated budget before a check fails
sample_violations: 10 # examples per (field, rule); counts stay exact
string · integer · float · boolean · timestamp (RFC 3339) · date · uuid
required · nullable · unique · pattern · min/max · min_length/max_length · allowed · format (email, uri)
Exactly one marker, deprecated:, and it never changes validation. The diff reports the transition and consumer-check warns every reader.
let contract = CompiledContract::compile(&contract)?;
let model = contract.resolve_model(None)?;
let mut collector = Collector::new(contract.policy.sample_violations);
let mut unique = UniqueTracker::new(model);
for batch in reader { // any RecordBatch source
rows += validate_batch(model, &batch?, rows,
Some(&mut unique), &mut collector);
}
The contract compiles once (regexes built, allowed-sets hashed) and each batch is a
columnar pass with per-rule sample caps. Before any of that,
covenant validate refuses a contract it could not enforce:
OK orders v1.2.0 — no findings.
Condition notes.
A category whose whole failure is an honour system does not get to hide its own caveats. Here is what Covenant does not do, in the same detail as what it does.
Columnar formats (CSV, Parquet and Arrow) cannot distinguish “key absent” from “explicit null”. So an explicit null in an optional field's column is treated as absent and passes, where the NDJSON path, which can see the difference, rejects it. Required fields behave identically everywhere.
Nested and dotted field paths, Arrow Struct columns, a decimal
type, dictionary-encoded columns, bounded uniqueness for unbounded streams, and a native
Kafka feature are all roadmap rather than runtime.
No lineage and no discovery UI. DataHub and OpenMetadata are complements: describe there, enforce here.
Storage, publishing, and search are not in the runtime. That is the paid plane's job.
Freshness and SLA monitoring both need state and a clock, and the runtime deliberately carries neither.
The measured figures above come from a single machine and a single shape of data. They are honest about what they are: a floor to reason about, not a benchmark suite and not a promise about your records, your disks, or your contract.
The binary phones nobody on any code path, so there is no flag to go looking for.
Against the shelf.
Most of these are good tools solving a neighbouring problem. The column that matters is the last one.
| Tool | What it does | What it does not |
|---|---|---|
| DataHub · OpenMetadata | Catalog and contract description, with lineage and ownership. | Enforcement is advisory or eventual. Nothing sits in the data path. |
| data-contract-cli | Spec authoring, linting, format conversion. | Explicitly not a runtime — no gate, no stream, no exit-code verdict on data. |
| Soda | Contract checks over warehouse data. | Python-locked, scan-based, batch-after-load. Needs a warehouse read grant. |
| Great Expectations | A rich assertion library, notebooks, data docs. | Observation after the fact. Nobody embeds it in a Kafka path. |
| Confluent Schema Registry | Real enforcement — of shape, in Kafka. | Types and compatibility only: no min/max, no pattern, no enum of values, no uniqueness. JVM, Kafka-only. |
| dbt tests | Post-load SQL assertions in the warehouse. | Fires after the bad data landed. No producer feedback loop, no streams. |
| Covenant | Compiles one contract; runs it in CI, in streams, in process, and on the pull request. | Deliberately no catalog, no registry and no freshness monitoring. |
The runtime is complete,
and free forever.
A single engineer gating one pipeline never hits a paywall. Crippling the runtime would recreate the exact failure Covenant exists to kill.
Everything that enforces, Apache-2.0
- Contract spec, linter, and compiled enforcement core
- CI file gate, stream gate with dead letters, Arrow embedding
- Breaking-change diff with semver enforcement
- In-repo blast radius, consumer manifests,
infer - Every constraint. Every enforcement point. No seat limit.
What organisations need once enforcement works
Team and Enterprise, both still in development.
- A versioned, searchable contract registry with ownership
- Cross-repo CI bot: PR comments tagging the teams that break
- Violation digests and deprecation countdowns
- Fleet dashboard, SLA monitoring, SSO/SCIM, audit
Priced per registered contract rather than per seat or per gigabyte, so it scales with the governance surface and stays predictable for high-volume streams. Prices are not set, and nothing here is on sale yet.
One binary. Sixty seconds.
Pure Rust, so the whole release matrix builds without a C toolchain: static musl for Linux, macOS on both architectures, Windows, a distroless container image, and a crate you can depend on.
# start from data you already have
$ covenant infer exports/orders.parquet --out orders.yaml
$ covenant validate orders.yaml
# gate a file in CI — exit 0 clean / 1 violated / 2 error
$ covenant check exports/orders.parquet -c orders.yaml
# block the pull request that breaks the contract
$ covenant diff main/orders.yaml pr/orders.yaml --fail-on breaking