Covenant V0.1.0 · APACHE-2.0 Install

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.

cargo binstall covenant
  • Apache-2.0
  • NDJSON · CSV · Parquet · Arrow
  • Exit 0 / 1 / 2
  • No telemetry
Records in examples/data/orders_bad.ndjson, checked against orders.yaml
Strip orders_bad.ndjson
Row 0 of 6
Rule
Note No rule failed. Every declared field is present, correctly typed, and inside its bounds.
Verdict No violation
Row 1 of 6
Rule order_id · pattern
Note row 1: field "order_id" value "ORD-UPPERCASE" does not match pattern "^ord_[a-z0-9]{12}$"
Verdict FAIL · exit 1
Row 2 of 6
Rule order_id · unique
Note row 2: field "order_id" value ord_a1b2c3d4e5f6 was already seen (unique)
Verdict FAIL · exit 1
Row 3 of 6
Rule order_id · required
Note row 3: required field "order_id" is missing
Verdict FAIL · exit 1
Row 4 of 6
Rule internal_debug · strict
Note row 4: field "internal_debug" is not declared in the contract (strict model)
Verdict FAIL · exit 1
Row 5 of 6
Rule <record> · invalid json
Note row 5: invalid JSON: expected ident at line 1 column 2
Verdict FAIL · exit 1

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.

Without a gate Found by a consumer
  1. Mon 09:14

    A producer drops customer_email from the orders contract. The contract file changes in the same pull request as the code.

  2. 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.

  3. Mon 11:02

    Merged. The producer moves on, correctly believing the change was reviewed.

  4. 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.

  5. 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.

With covenant diff in CI Caught before merge
  1. Mon 09:14

    The same pull request, the same dropped field.

  2. 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.

  3. 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.

  4. Thu 08:40

    The rollup is correct. Nobody is looking at it.

  5. 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.

Breaking · exit 1 Risky · reported Info · reported
Merge gate exit 1 blocks the PR
$ 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:

consumers/finance_daily_rollup.yaml
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
Blast radius the bundled demo pair
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 NDJSON · CSV · Parquet
$ 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.

The CI contract — exit codes
CodeMeaning
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.

Transport-agnostic stream gate
$ kcat -C -t orders_raw -e \
    | covenant gate -c orders.yaml --dlq /var/log/orders.dlq.ndjson \
    | kcat -P -t orders_validated
The same six records, through the gate exit 1
$ 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.

orders.dlq.ndjson — one envelope line-wrapped to read
{"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.

Measured on one core · best of three runs
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.

covenant infer examples/data/orders.ndjson
# 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.

Never drafted

unique. A wrong uniqueness rule fails clean data in production, and a sample can never prove it.

Widened, out loud

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.

Not yet a promise

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.

orders.yaml
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
Types

string · integer · float · boolean · timestamp (RFC 3339) · date · uuid

Constraints

required · nullable · unique · pattern · min/max · min_length/max_length · allowed · format (email, uri)

Advisory

Exactly one marker, deprecated:, and it never changes validation. The diff reports the transition and consumer-check warns every reader.

Embedded — Arrow-native pipelines
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.

Null semantics

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.

Not yet shipped

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.

Not a catalog

No lineage and no discovery UI. DataHub and OpenMetadata are complements: describe there, enforce here.

Not a registry

Storage, publishing, and search are not in the runtime. That is the paid plane's job.

Not observability

Freshness and SLA monitoring both need state and a clock, and the runtime deliberately carries neither.

One machine, one shape

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.

No telemetry

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.
Install it

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.

Prebuilt binary cargo binstall covenant
From source cargo install covenant
Container docker pull mancube/covenant
Release archive covenant-x86_64-unknown-linux-musl.tgz
Sixty seconds from nothing to a gate
# 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
cargo binstall covenant && covenant init orders.yaml