schema-drift

A contract checker for JSON event streams. It reads the schema each producer publishes per topic, decides whether a new version breaks anyone reading the old one, and blocks the pull request before the change ships.

An event stream couples teams who never call each other. Change amount from integer cents to a decimal string, and your own tests pass while a consumer on another team fails to decode. Every breaking verdict here comes with an event that proves it: the old schema accepts it and the new one rejects it.

Plays the six stages below in order, about 70 seconds. Scroll or press Esc to stop.

Language
Go 1.22+
Tests
29 passing, two of them property tests
Dependencies
None outside the standard library
Commands
check, verify, infer, history, gate
Modes
7, named as in Confluent Schema Registry
License
MIT
schema-drift check examples/contracts/orders/v2.json examples/contracts/orders/v3.json v2 -> v3: NONE (breaking both ways)

All three are real runs of schema-drift 0.1.0 on the repo's own examples, output verbatim. The gate session is examples/gate-demo.sh, a pull request against a throwaway git repository, shortened where marked.

1Refuse what it can't reason about

schema-drift supports a subset of JSON Schema: the part whose compatibility it can decide exactly. Anything outside it is an error with a JSON Pointer, never skipped.

  • A checker that ignored a keyword it didn't understand would call changes compatible that aren't. That is the dangerous direction, so the parser fails closed.
  • A misspelt keyword gets a suggestion. A keyword outside the subset, such as pattern, $ref, oneOf or OpenAPI's nullable, gets a hint about what to write instead.
  • Duplicate keys are refused, because encoding/json would quietly keep the last one. So are schemas no value can satisfy, and enum values the rest of the schema rejects.

The subset is type, enum, properties, required, additionalProperties, items, numeric bounds, string and array lengths, and four formats, which are asserted rather than treated as annotations.

Six schemas the parser refuses Illustrative input

    Schemas refused
    6 of 6
    Exit code, each time
    2
    The six schemas were written for this page; the errors are real. Each one was passed to schema-drift check with examples/contracts/orders/v1.json as the other side, and the error lines are printed exactly as they came back.

    2Ask one question, in both directions

    Every compatibility mode comes down to one question: is every event valid under schema A also valid under schema B? Backward asks it with the old schema as the writer, so consumers on the new schema can read old events. Forward asks it the other way round. Full asks both.

    • Checked keyword by keyword: types, enum values, numeric bounds, string lengths including the length a format implies, formats, required properties, declared properties against additionalProperties, and array items and sizes.
    • It recurses through nested objects and arrays and reports each break at its path, with * standing for any array item.
    • A writer that can never send a value, like a date capped at three characters, breaks nothing, and isn't reported.

    Which mode you need depends on who upgrades first. Consumers first: backward. Producers first: forward. Either, or old events replayed from a retained topic: full.

    The repo's example contracts, pair by pair

    FieldOldNewBackwardnew consumers, old eventsForwardold consumers, new events
    The schemas are the repo's examples/contracts; the table restates what each file declares, and the output under it is the real schema-drift check run on that pair, verbatim, with its exit code. Payments is checked with --mode FULL, the mode its topic folder names; the others use the default, BACKWARD.

    3Prove every break with an event

    A checker that cries wolf teaches people to ignore it. So for each reason it finds, schema-drift builds a whole event and checks it with its own validator: the writer's schema has to accept it and the reader's has to reject it. Only then is it printed as the e.g. line.

    • The witness is built at the level of the break and wrapped by each parent object or array on the way up, so it is always a complete event, not a fragment.
    • Numbers, strings and arrays are searched from candidates near the bounds, so the example sits right at the edge the reader added.
    • A property test generates thousands of schema pairs and fails on any reason without a validated witness.

    An e.g. event, fed back through verify

    The witness, copied from the check output

    Proven: the writer accepts it and the reader rejects it.

    Each event is the e.g. line from the real check output in stage 2, saved to a file and run through schema-drift verify against both schemas. The verify output and exit codes are verbatim.

    4Hold the pull request at the line

    Contracts live in the repository, one folder per topic: v1.json, v2.json and so on, plus an optional mode file. schema-drift gate compares that folder in the working tree with the same folder at the base branch, read from git, and exits 1 on any problem.

    • A new version has to pass the topic's mode against the previous version, or against every earlier one for a _TRANSITIVE mode.
    • A published version is immutable. An edit is judged both ways against what was published, so rewording a title or a description passes and any change to what it accepts fails.
    • Deleting a published version or a whole topic fails, and so do gaps in numbering and files that aren't vN.json or mode.

    It reads only git and the working tree. There is no registry to run and nothing to fetch, so the check is one step in the pull request workflow.

    Two pull requests against the same main

    Files changed in the pull request
      schema-drift gate --base main
      
                      
        Three classic mistakes is the repo's examples/gate-demo.sh, run for real. A safe change is Illustrative input: a pull request written for this page that rewords the title of orders v1 and adds v3 with one new optional field, run through the same command. Output is verbatim, except that the e.g. lines are left out here; stage 3 shows what they prove.

        MergedAbove this line the contract change is still a pull request. Below it the schema is published, producers write events under it, and consumers on other teams decode them.

        5Hold real events to the same standard

        A contract only protects consumers if producers actually honour it. schema-drift verify reads newline-delimited JSON events you've captured and reports every problem at its JSON Pointer, with the line it came from.

        • Formats are asserted: "not-a-uuid" is not a uuid and "yesterday" is not a date-time.
        • Integers follow JSON semantics, so 1.0 is an integer, and enum values compare by value.
        • A duplicate key is reported, not silently resolved to its last value as encoding/json would.

        verify checks samples you capture. It doesn't watch a live topic or talk to a broker.

        Five captured events against orders v2

          The events are the repo's examples/events/orders-mixed.ndjson, and the problems under each are the real output of schema-drift verify examples/contracts/orders/v2.json on that file, line for line.

          6What a missed break looks like downstream

          The repo ships an ordinary Go consumer written against orders v2: it decodes each event into a struct and adds up revenue per currency. It is what stage 4 protects, and what happens when nothing does.

          • On v2 events it works. On the first v3 event it stops, because amount is now a string and its struct field is an int64.
          • The producer's own tests would pass. The failure only appears in a service owned by someone else, reading a topic.
          • The same break, caught by verify before any consumer sees it, and by gate before v3 is ever published.

          Semantics stay invisible. If amount kept type integer and changed from cents to dollars, every check would pass. A schema can only carry what it states.

          One consumer, two producers, one check

            All three are real runs: examples/consumer built with go build, fed the repo's examples/events/orders-v2.ndjson and orders-v3.ndjson, then schema-drift verify on the v3 events. Output and exit codes are verbatim.

            How the verdicts are checked

            A compatibility checker can be wrong in two directions, and both are tested against the validator as an independent oracle. The numbers are from the default test run, read from the test log.

            Calling a break compatible

            The dangerous mistake. TestSoundness generates random schema pairs, most of them by mutating one schema into the next the way people edit contracts. For every pair judged compatible, it generates events valid under the writer and checks each against the reader.

            12,918 pairs judged compatible2,543,839 events0 counterexamples

            Calling a compatible change breaking

            The noisy mistake, the one that teaches people to ignore a tool. TestEveryBreakIsProven fails on any reason whose witness the validator doesn't confirm on both sides.

            7,261 pairs judged breaking15,031 reasons0 unproven

            What the two tests caught

            The README lists real fixes these two tests forced:

            • A writer allowing exactly 2.5 was judged compatible with a reader whose maximum is 0: fractional-only writers skipped the range check.
            • “Shorter than 2 characters” was reported against formats that can't be, such as an email (at least 5) or a date-time (at least 20).
            • Writers that admit no value at all were reported as breaking.
            • A reader property like {"minLength": 0} was treated as a constraint.

            29 tests, standard library only

            Counted with go test -v ./...: 29 tests, all passing, no subtests. No network, and the gate tests build real git repositories in temporary directories.

              Weighted toward the verdict

              • Property tests: soundness, every break proven, and every generated schema compatible with itself.
              • Gate: a compatible addition passes, a breaking version is blocked, published versions are immutable, edits that change nothing pass, transitive modes check every earlier version, deleted topics and bad layouts fail, a bad base ref is an error.
              • Parser and validator: the parser fails closed, JSON Pointers with escaped / and ~, 1.0 as an integer, nullable type lists, each format, enum equality by value.
              • Infer: 2,000 random samples, and every draft must accept its sample and pass the strict parser.
              • The CLI: check, verify, infer and history with their exit codes, usage errors, and a test that fails if the README stops quoting the CLI's current output.

              CI runs vet, gofmt, the short suite under the race detector and the full suite on Go 1.22 and the current stable release, plus a job that regenerates the README's quoted output and fails on any difference.

              Deliberately missing

              $ref, oneOf, pattern and friends
              Containment is easy to decide exactly for the supported keywords and hard for regular expressions and unions of object shapes. A checker that guessed there would be wrong in the dangerous direction, so each is rejected with a hint.
              Semantics
              An integer that changes from cents to dollars passes every check. A schema can only carry what it states.
              Several event types per topic
              That needs oneOf. Split the topic, or use one schema with the union of fields.
              A registry or broker
              Contracts live in git and the gate reads git. It doesn't fetch from Confluent Schema Registry or watch a live topic.
              Certainty from infer
              It drafts a schema from samples. A field present in every sample is marked required, though the samples may simply never have left it out.

              Run it

              git clone https://github.com/ahmed-hashim-pro/schema-drift
              cd schema-drift
              go build -o schema-drift ./cmd/schema-drift
              
              ./schema-drift check examples/contracts/orders/v2.json examples/contracts/orders/v3.json
              ./schema-drift history examples/contracts
              ./schema-drift verify examples/contracts/orders/v2.json examples/events/orders-mixed.ndjson
              ./schema-drift infer examples/events/orders-v2.ndjson
              
              # A pull request, as CI sees it
              SCHEMA_DRIFT=$PWD/schema-drift examples/gate-demo.sh
              
              go test ./...

              Go 1.22 or newer, no dependencies outside the standard library. In CI it is one step: schema-drift gate --base origin/main after a checkout with full history.