Skip to content

Drift gating in CI

document:docs/drift-ci

Fail the pull request that breaks a consumer, not the release that ships it.

View as Markdown

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.

One step covers it, and this is the whole workflow:

name: catalog
on: pull_request
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: jagreehal/autocatalog/actions/check@v1

The 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-drift
on: 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, AnalyticsService

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

ChangeSeverityRationale
Event removedbreakingevery consumer loses the contract
Field removedbreakingconsumers reading it get undefined
Field renamedbreakingthe same, under a name nobody reads yet
Field type changedbreakingparsers on both sides disagree
Field becomes requiredbreakingpayloads that omit it become invalid
Field meaning changedbreakingit parses, and every reader misreads it
Enum narrowedbreakingalready-published values become invalid
Delivery promise changed/removedbreakingconsumers built on it, as on a field
Maximum lateness lengthenedbreakingconsumers stop waiting too soon
New access to another team’s databreakingit couples one team to another’s storage
Field no longer requiredsuspiciousconsumers relying on presence may break
Format changedsuspicioussemantic change without a structural one
Field meaning no longer declaredsuspiciousreaders lose the assumption they read by
Schema changed, version unchangedsuspiciousversion discipline is the whole point
A second service publishes eventsuspiciousan event is a fact from one boundary
New optional field / widened enumadditivesafe
New delivery promiseadditivenobody relied on it yet
Maximum lateness shortenedadditivethe longer window still holds
New eventadditivesafe
New event not in the past tenseinfoan event says what happened
New command named as an eventinfoa 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.

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.

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 owns

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

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:

Terminal window
ac drift --base-ref origin/main --root . --consumers https://catalog.acme.dev/catalog.json

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

Every breaking finding ends with what to do next. There are two ways through the gate:

  • Make it compatible. Keep total alongside amountDue until 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 version to the next major (1.4.0 → 2.0.0, or V1 → V2 for 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 is info.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.

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

It 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 findingSeverityRationale
Field sent as a type the schema forbidsbreakingthe payload breaks the contract consumers trusted: amount as a string where it is a number
Value outside the schema’s enumbreakinga consumer handling only the declared values meets one it does not know
Event emitted, not in the catalogsuspiciousan undocumented contract: consumers can come to depend on it with nobody reviewing it
Field observed, not in the schemasuspiciousthe same, one field at a time; names the field and the event, lands on the contract
Producer or channel the event omitssuspicioussomething sends the event, or sends it somewhere, that its page does not say
Producer or channel, none declaredinfothe event names none; declare what the runtime saw
Two producers seen publishing an eventsuspiciousan event is a fact from one boundary, so one service publishes it
Consumer dead-lettered deliveriesinfonames the consumer, how many, and why; a test may do it on purpose
Event documented, never seeninfoone 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.

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 gone

Teams who already reason in Schema Registry terms can gate on a strategy instead of severity:

Terminal window
ac drift --base-ref origin/main --compatibility backward

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

ChangeBACKWARDFORWARD
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 openok✖
Remove optional field, object closedokok
Remove required fieldok✖
Field becomes required✖ok
Field becomes optionalok✖
Type or format changed✖✖
Constraint tightened (minimum, maxLength, items, …)✖ok
Constraint loosenedok✖
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 droppedok✖
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.

Owned by MaintainersLast changed 6 Nov 2026SourceEdit this page