Drift gating in CI
document:docs/drift-ciFail the pull request that breaks a consumer, not the release that ships it.
ac drift compares two generated catalogs and fails when a change breaks a
consumer. The baseline is the catalog extracted from your default branch; the
current is the one extracted from the PR. Because both sides are generated
from source, the gate catches breaks the moment the code changes — no one has
to remember to update a spec.
GitHub Actions
Section titled “GitHub Actions”One step covers it, and this is the whole workflow:
name: catalogon: pull_requestjobs: check: runs-on: ubuntu-latest steps: - uses: jagreehal/autocatalog/actions/check@v1The action checks out your history, runs ac check and compares your contracts
with the base branch. Its failures mark the files they name. Set
allow-code-execution: true to run catalog rules in a repository
you trust.
To run the steps yourself, with Zod or another installed plugin:
name: contract-drifton: pull_request
jobs: drift: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: { node-version: 24, cache: pnpm } - run: pnpm install --frozen-lockfile
- run: git fetch origin main
# Scans origin/main and the PR through the same plugins, then compares. # Exits 1 on breaking changes and names the consumers they break. # Add --allow-code-execution only on a locked-down runner when runtime # plugins (for example Zod) or catalog rules (docs/rules) are required # and PR code is trusted. # Optional: gate on Schema Registry compatibility instead of severity # - run: pnpm exec ac drift --base-ref origin/main --root . --format github --compatibility backward - run: pnpm exec ac drift --base-ref origin/main --root . --format github--format github is the CI format. It emits GitHub workflow commands, so every
finding becomes an annotation on the pull request: breaking changes are errors,
suspicious ones warnings, additive ones notices. Findings the scanner traced
back to code annotate that file and line, so the review lands on the Zod schema
that changed rather than in a job log nobody opens. Findings on a JSON Schema
contract or an OpenAPI document (JSON or YAML) land on the line of the field
that changed; a field that was removed lands on the object that held it. The
same report is appended to the job summary as markdown.
A rename to amountDue in contracts/order-placed.json reads like this:
::error file=contracts/order-placed.json,line=10,title=orders/order.placed::field `total` (number) looks renamed to `amountDue` — breaks BillingService, AnalyticsService (reads it: BillingService; fields undeclared or invalid: AnalyticsService; not affected: ShippingService). Next: keep `total` alongside `amountDue` until consumers move off it, or bump to 2.0.0 to mark it intentional and notify BillingService, AnalyticsServiceThere is no action to install — this is npx plus five lines of YAML, and no
API key. If you want the report elsewhere, --format json is the machine
format: pipe it to whatever already posts to your chat tool.
Severity model
Section titled “Severity model”| Change | Severity | Rationale |
|---|---|---|
| Event removed | breaking | every consumer loses the contract |
| Field removed | breaking | consumers reading it get undefined |
| Field renamed | breaking | the same, under a name nobody reads yet |
| Field type changed | breaking | parsers on both sides disagree |
| Field becomes required | breaking | payloads that omit it become invalid |
| Field meaning changed | breaking | it parses, and every reader misreads it |
| Enum narrowed | breaking | already-published values become invalid |
| Delivery promise changed/removed | breaking | consumers built on it, as on a field |
| Maximum lateness lengthened | breaking | consumers stop waiting too soon |
| New access to another team’s data | breaking | it couples one team to another’s storage |
| Field no longer required | suspicious | consumers relying on presence may break |
| Format changed | suspicious | semantic change without a structural one |
| Field meaning no longer declared | suspicious | readers lose the assumption they read by |
| Schema changed, version unchanged | suspicious | version discipline is the whole point |
| A second service publishes event | suspicious | an event is a fact from one boundary |
| New optional field / widened enum | additive | safe |
| New delivery promise | additive | nobody relied on it yet |
| Maximum lateness shortened | additive | the longer window still holds |
| New event | additive | safe |
| New event not in the past tense | info | an event says what happened |
| New command named as an event | info | a command asks for work |
A breaking or suspicious finding carries a rehearsal when one exists: a
payload the proposed schema accepts and the current one rejects, with the
check it fails (/amount is required). It fails on that finding’s change
alone, so paste it into a consumer’s test to watch the break before you ship.
The terminal, Markdown and GitHub outputs all print it.
--fail-on suspicious tightens the gate; --fail-on never turns it into a
report (useful while adopting).
The last three keep events and commands apart. An event records something that
happened in one boundary, so one service publishes it and any number consume
it, and its name is in the past tense: order.placed. A command asks one
handler to do work, so its name is a verb: PlaceOrder. A command with no
handler, or two, is breaking (see commands).
The naming checks read the words of the id and the name, so they only inform,
and they look only at events and commands the change adds.
Nested fields are compared too — a type change on payment.amount or
items[].sku is reported at that path, with the same severity rules. A removed
object is one finding, not one per field underneath it.
A rename is one finding too, not a removal plus a new required field. The rule is deliberately narrow: exactly one field removed and one added under the same parent, with the same type. Anything looser is a guess, and a wrong guess hides a removal, so two fields moving at once, or a rename that also changes the type, are reported as the separate changes they are.
Changes in meaning
Section titled “Changes in meaning”A schema says amount is a number. It cannot say the number is pence, not
pounds, so no schema diff sees the day it switches. Declare what a field means
beside it, under x-meaning:
{ "amount": { "type": "number", "x-meaning": { "unit": "pence", "currency": "GBP" } }, "placedAt": { "type": "string", "format": "date-time", "x-meaning": { "timezone": "UTC" } }}Any key works, with a text value. unit, currency, timezone, identifier (what the value
identifies) and ordering cover most fields. ac drift compares them field by
field: a changed value is breaking and names the consumers that read the field,
a dropped one is suspicious, and declaring one for the first time is info. A
registry accepts all three, so no --compatibility strategy fails on them. In
Zod, z.number().meta({ 'x-meaning': { unit: 'pence' } }) carries through to
the schema. A catalog rule can require the declaration, for
example on every field named amount.
The “version stayed” warning names the consumers the changes reach, and nobody else: a consumer that declared it does not read the changed field is not told a version number breaks it.
A changed delivery guarantee, ordering key or idempotency key, or a longer
maximum lateness, is acknowledged by a major version like any other breaking
change. It does not fail a
--compatibility gate, since the payload a registry checks is unchanged.
Another team’s datastore
Section titled “Another team’s datastore”A service that starts reading or writing a table or collection that another team owns fails the change that introduced it:
checkout (payments) reads collection acme/finance/statements, which ledger ownsOwners come from the same places as everywhere else: catalog-info.yaml,
imports and CODEOWNERS (see brownfield resources).
Access is seen in code, or in traces given to ac resources scan --traces.
Only new access is reported, so adopting this does not fail on what is already
there, and nothing is said when either owner is unknown. A deliberate exception, such as a
migration, is reviewed with ac resources promote; it is then reported as a
warning that does not fail --fail-on breaking.
Consumers in other repositories
Section titled “Consumers in other repositories”An event’s manifest lists the consumers its producer knows about. A team in
another repository can start reading the event without telling anyone, and the
producer’s pull request would never name it. That team declares what it
consumes in its own repository, in autocatalog.consumes.json at the root:
{ "consumes": [ { "service": "LedgerService", "event": "payments/payment.completed", "fields": ["amount", "currency"], "deadLetter": { "queue": "ledger-dlq", "maxAttempts": 3 } } ]}deadLetter says where the service puts a delivery it gives up on, and after
how many attempts. The event’s page shows it beside what the runtime saw the
consumer do (see runtime evidence).
ac site joins every repository’s declarations into the company graph it
publishes at /catalog.json. A declaration naming an event no repository
publishes, or one two repositories publish, keeps that repository out of the
graph and the build says why. The event’s page lists the declared consumers
beside the ones its manifest names.
Any repository can name any service, so a declaration only adds consumers. It
never narrows one the producer’s manifest lists: for those, the producer’s
consumerFields decide what they read. Two declarations of one consumer
combine their fields, and one that names no fields, or a field path that is not
one, means the consumer may read anything. A consumer that may read anything is
reached by every change.
Names from these files, and from runtime snapshots, reach terminal output,
Markdown and pull request annotations, so they keep to letters, digits, spaces
and . _ : / -. A declaration with any other name fails ac validate. Drift
still counts such a consumer, as “a consumer whose name is not safe to print”,
so a hostile name cannot make the gate pass, and it prints the name escaped.
The producer’s pull request reads them back:
ac drift --base-ref origin/main --root . --consumers https://catalog.acme.dev/catalog.jsonA renamed amount then fails with breaks LedgerService. A consumer that
declared it reads only currency is listed as not affected. For a private site,
--consumers-token-env CATALOG_TOKEN sends the token from that variable.
--consumers also takes a file, such as a catalog.json your pipeline
downloaded. When two repositories publish the same event, drift counts the consumers of
this repository’s copy, naming it from the origin remote or --repository.
When it cannot tell which copy is its own, it counts the consumers of every
copy and says so.
The site is built from each repository’s main branch, so a consumer declared in
an open pull request counts once it merges and the site rebuilds. Drift prints
when the index it read was published, from the site’s Last-Modified header or
the file’s time, and how long ago:
consumers from https://catalog.acme.dev/catalog.json, published 2026-10-12T21:30:00Z (2 hour(s) ago)If the index cannot be read, drift says so and runs without it: an outage on the site does not block pull requests across the company.
The consumer checks its side too. In the consumer’s own pull request,
ac validate --catalog <url> checks each declared event exists and each field
it reads is in the schema the producer publishes, so a typo such as amout
fails before anyone relies on it.
Landing a breaking change
Section titled “Landing a breaking change”Every breaking finding ends with what to do next. There are two ways through the gate:
- Make it compatible. Keep
totalalongsideamountDueuntil consumers have moved, keep a removed field and mark it deprecated, make a new required field optional until every sender includes it. Then remove the old shape in a later change, when nobody reads it. - Acknowledge it with a major version. Bump the event’s
versionto the next major (1.4.0→2.0.0, orV1→V2for EventCatalog-style versions). A breaking change that comes with a major bump is still reported, with the consumers to notify, but as a warning that does not fail--fail-on breaking. For an OpenAPI document the version isinfo.version.
A minor or patch bump does not acknowledge anything, and the finding says so:
or bump to 2.0.0 (1.1.0 is not a major bump). Removing an event outright has
no version left to bump, so it always fails; deprecate it first and remove it
when its consumers have moved.
Acknowledging applies to the severity gate only. A --compatibility gate is a
Schema Registry promise about which side may upgrade first, and a version
number does not change whether an old reader can parse new data — so an
acknowledged change still fails the strategy it violates.
Runtime drift
Section titled “Runtime drift”The contracts say what a service promises; a runtime snapshot says what it
actually sent. --snapshot checks the current catalog against one, in the same
command, report, formats and gates:
- run: npm test # autotel's ArchitectureSnapshotSubscriber writes .autotel/snapshot.json- run: pnpm exec ac drift --base-ref origin/main --root . --format github --snapshot .autotel/snapshot.jsonIt reads the autotel-architecture/* snapshot autotel’s
ArchitectureSnapshotSubscriber writes, and works with --base-ref and with
--base/--current alike. A file that is not a snapshot fails the command
before anything is scanned: read as empty, it would report every event unseen.
The consumers a snapshot records join each finding’s list, after the declared
ones and labelled with how the snapshot knows: FraudService (seen at runtime)
when it saw the delivery, (reported at runtime) when message metadata named
it. A consumer already declared is named once, as declared.
| Runtime finding | Severity | Rationale |
|---|---|---|
| Field sent as a type the schema forbids | breaking | the payload breaks the contract consumers trusted: amount as a string where it is a number |
| Value outside the schema’s enum | breaking | a consumer handling only the declared values meets one it does not know |
| Event emitted, not in the catalog | suspicious | an undocumented contract: consumers can come to depend on it with nobody reviewing it |
| Field observed, not in the schema | suspicious | the same, one field at a time; names the field and the event, lands on the contract |
| Producer or channel the event omits | suspicious | something sends the event, or sends it somewhere, that its page does not say |
| Producer or channel, none declared | info | the event names none; declare what the runtime saw |
| Two producers seen publishing an event | suspicious | an event is a fact from one boundary, so one service publishes it |
| Consumer dead-lettered deliveries | info | names the consumer, how many, and why; a test may do it on purpose |
| Event documented, never seen | info | one test run or traffic window is not proof the event is gone |
The two breaking findings are payloads the contract says cannot happen, so they
fail the default gate and name the event’s consumers. A key set to undefined
never reaches the wire and is not a type. JavaScript has no integer, so an
integer field fails on a fractional sample value, not on number, and a
Date sent to a string field with a format counts as the string it becomes.
The report counts rejected values and never prints them. A field
declared differently in each branch of a oneOf is not checked: no one branch
is its contract. The rest gate with --fail-on suspicious. None is a schema
change, so no compatibility strategy fails on them.
Event names ignore case and ., _, -, so track('order.placed') is the
catalog’s OrderPlaced. An exact name wins over
a loose one, and a loose name that fits two events is refused rather than
guessed. When an event that ran undocumented and one documented but unseen have
similar names, both findings say it could be a rename: `payment.refund` is probably `payment.refunded` renamed (87% similar). Both stay reported, since
on the day it is not a rename one of them is a real removal.
A declared field the snapshot never carried is not reported: an optional field absent from one run says nothing.
Only what the change introduced
Section titled “Only what the change introduced”A gap the default branch already has would otherwise fail every pull request
until someone fixes it, and that is how a gate gets switched off. With
--base-ref, drift reads the snapshot as the base committed it, at the same
path, and checks the base catalog against it too. A runtime finding already
true on the base is still reported, as info and saying so, and does not fail
the change. Commit the snapshot for this to work; without one at the base,
every runtime finding counts. Name another file with --base-snapshot <file>,
which also works with --base/--current.
After the change in experiment 05,
where every order gains couponCode and a large one also emits
order.flagged, --format markdown reads:
# Contract drift
**0 breaking** · 2 suspicious · 0 additive
## order.flagged
- ⚠ suspicious: the runtime emitted this event 1 time(s) from OrderService, but the catalog does not document it — an undocumented contract consumers can come to depend on unreviewed. Declare a contract for it, or stop emitting it
## orders/order.placed
- ⚠ suspicious: runtime payloads carry `couponCode`, which the schema does not declare — consumers can start reading it with no contract behind it. Declare it, or stop sending it
## orders/order.cancelled
- ℹ info: documented, but not observed in the runtime snapshot (snapshot of 2026-10-10T05:22:17.032Z); one window is not proof it is goneCompatibility strategies
Section titled “Compatibility strategies”Teams who already reason in Schema Registry terms can gate on a strategy instead of severity:
ac drift --base-ref origin/main --compatibility backwardbackward means the new schema reads old data (consumers upgrade first);
forward means the old schema reads new data (producers upgrade first); full
is both; none disables the gate. Passing --compatibility replaces the
severity gate rather than stacking with it — they answer different questions.
With --base-ref, the OpenAPI documents a repository publishes are compared per
operation too, and the same modes are read in API terms: backward means the new
API still serves existing clients, forward that new clients still work against
the old API. A request is read by the server and a response by the client, so a
response change that old readers cannot parse — a new enum value, say — fails
backward.
Whether a change is compatible usually depends on the object it happens in, so
the rows below say which condition decides. “Open” means the object accepts
properties it does not name; “restricted” means additionalProperties is
false or a schema that constrains them.
| Change | BACKWARD | FORWARD |
|---|---|---|
| Add optional field, object open | ✖ | ok |
| Add optional field, object restricted | ✖ | ✖ |
| Add required field | ✖ | ok |
Add a field forbidden by false | ✖ | ok |
| Remove optional field, object open | ok | ✖ |
| Remove optional field, object closed | ok | ok |
| Remove required field | ok | ✖ |
| Field becomes required | ✖ | ok |
| Field becomes optional | ok | ✖ |
| Type or format changed | ✖ | ✖ |
Constraint tightened (minimum, maxLength, items, …) | ✖ | ok |
| Constraint loosened | ok | ✖ |
Constraint changed ambiguously (pattern, oneOf, not, a conditional predicate, unmodelled keyword) | ✖ | ✖ |
| Enum values removed, or an enum introduced | ✖ | ok |
| Enum values added, or an enum dropped | ok | ✖ |
| Event removed | ✖ | ✖ |
Two rows are worth reading twice, because both once said “ok” here and a validator disagreed.
Adding an optional field is not free. If the object was open, that name
already accepted any value, and typing it rejects payloads that carried
something else — so BACKWARD fails. If the object was restricted, the new field
also lets through values the old schema refused, so FORWARD fails too. The
severity model still calls this additive, on purpose: adding a field is the
commonest change there is, and failing the default gate on it is how a gate
gets turned off.
Some changes are reported because they cannot be cleared, not because they
break. A rewritten pattern, an edit inside oneOf or not, a changed
conditional predicate, or any keyword this tool does not model are all reported
as breaking: each can reject payloads that were accepted, and deciding which
would take a solver. Two conditional cases are proven safe instead of assumed
unsafe — a predicate whose then/else impose no constraints, and one whose
branches are identical — because there the predicate cannot change what is
accepted. For the rest, --compatibility none or a narrower gate is the escape
hatch.
Removing an optional field is not free either. Dropping the definition stops constraining the name, so a new producer may send it as anything and a reader built from the old schema rejects that — FORWARD fails. It only stays compatible when the object is closed, which stops the field appearing at all.
These are the registry’s rules, not the severity model above, and the two
disagree on purpose. Removing an optional field is breaking here — a consumer
reading it now gets undefined — yet gated only under FORWARD. A team asking for
BACKWARD gets BACKWARD.
If CI already produces immutable Catalog artifacts, directory mode remains
available: ac drift --base /tmp/baseline-events --current /tmp/current-events.
It has no source tree to read, so JSON Schema findings there annotate the file
without a line. Each artifact’s resources.json, written by ac scan --out
when the repository has datastore or command resources,
is compared as well; if only one artifact has it, drift warns and compares no
resources.
Every breaking finding names the consumers it breaks, read from the event’s
consumers metadata — so the PR author sees “breaks LedgerService,
NotificationService”, not just a schema diff.