pacto diff and change classification: what counts as a breaking change¶
pacto diff compares two versions of a contract and tells you which changes can break the services that depend on it. Run it in CI to catch a breaking change before it merges:
Every change goes through a fixed rule table, so the same two contracts always give the same answer. The tables below are the complete list: every field Pacto compares and the verdict it reaches.
Each change is classified as:
BREAKING: a change that breaks consumers or platforms relying on the previous contractPOTENTIAL_BREAKING: a change that may break consumers, depending on how they use the fieldNON_BREAKING: a safe change that keeps compatibility
A change that matches no specific rule below defaults to POTENTIAL_BREAKING. Inside an interface schema the default is BREAKING; see Schema position.
Service identity¶
| Field | Change | Classification |
|---|---|---|
service.name |
Modified | BREAKING |
service.version |
Modified | NON_BREAKING |
service.owner.team |
Added / Modified / Removed | NON_BREAKING |
service.owner.dri |
Added / Modified / Removed | NON_BREAKING |
service.owner.contacts[] |
Added / Modified / Removed | NON_BREAKING |
The owner block is compared field by field, so changing a dri or adding a
contact while the team is unchanged reports that specific change. Contacts are
keyed by type:value, and a purpose change on an existing contact is a
modification. service carries identity only, so the contract has no image or
chart field to diff. A pactoVersion change, such as "2.0" to "2.1", is
NON_BREAKING.
Interfaces¶
| Field | Change | Classification |
|---|---|---|
interfaces |
Added | NON_BREAKING |
interfaces |
Removed | BREAKING |
interfaces.type |
Modified | BREAKING |
interfaces.ref |
Modified | POTENTIAL_BREAKING |
interfaces.visibility |
Modified | POTENTIAL_BREAKING |
interfaces.lifecycle |
Added / Removed | NON_BREAKING |
interfaces.lifecycle.deprecated |
Modified | NON_BREAKING |
interfaces.lifecycle.sunset |
Added / Removed | NON_BREAKING |
interfaces.lifecycle.sunset |
Modified, earlier | POTENTIAL_BREAKING |
interfaces.lifecycle.sunset |
Modified, later | NON_BREAKING |
interfaces.lifecycle.successor |
Added / Modified / Removed | NON_BREAKING |
interfaces.guarantees |
Added | NON_BREAKING |
interfaces.guarantees |
Removed | BREAKING |
interfaces.guarantees.weakened |
Modified | BREAKING |
interfaces.guarantees.strengthened |
Modified | NON_BREAKING |
interfaces.compatibilityEpoch |
Modified (raised or lowered) | BREAKING |
Interfaces are keyed by name. The type enum is openapi/asyncapi/grpc/mcp, and the spec pointer is ref. Deprecating an interface changes nothing a
consumer calls, so it is a notice. An earlier sunset shortens the time consumers
have to migrate. Removing an interface before its sunset is still BREAKING,
and the reason says that the sunset has not been reached.
Each guarantee is compared on its own: a longer dedupeWindow or retention, a shorter
timeout, global ordering in place of per-key and a new idempotent: true
are stronger. A delivery change is weaker in both directions, because a
consumer relying on the old value breaks.
A compatibilityEpoch change marks a behavior change that no spec diff can see.
Configurations¶
| Field | Change | Classification |
|---|---|---|
configurations[] |
Added | NON_BREAKING |
configurations[] |
Removed | BREAKING |
configurations[].schema |
Added | POTENTIAL_BREAKING |
configurations[].schema |
Modified | POTENTIAL_BREAKING |
configurations[].schema |
Removed | BREAKING |
configurations[].ref |
Added | NON_BREAKING |
configurations[].ref |
Modified | POTENTIAL_BREAKING |
configurations[].ref |
Removed | BREAKING |
configurations[].values.* |
Added / Modified / Removed | NON_BREAKING |
Dropping a schema is BREAKING: the configuration a consumer supplies stops
being validated. Introducing a schema where there was none is
POTENTIAL_BREAKING, because a configuration that was unchecked before can now
fail validation.
Inline configuration values and environment
values are the provider's own inputs. Pacto diffs them key by key (for example
configurations[app].values.replicas) and classifies each change NON_BREAKING.
Environments¶
| Field | Change | Classification |
|---|---|---|
environments[] |
Added | NON_BREAKING |
environments[] |
Removed | POTENTIAL_BREAKING |
environments[].*, a value in the environment's file, for example environments[production].chart.ingress.host |
Added / Modified / Removed | NON_BREAKING |
Policy¶
| Field | Change | Classification |
|---|---|---|
policies[] |
Added | NON_BREAKING |
policies[] |
Removed | POTENTIAL_BREAKING |
policies[].schema |
Added / Modified / Removed | POTENTIAL_BREAKING |
policies[].ref |
Added | NON_BREAKING |
policies[].ref |
Modified | POTENTIAL_BREAKING |
policies[].ref |
Removed | POTENTIAL_BREAKING |
Introducing a policy schema where there was none is POTENTIAL_BREAKING for
the same reason it is on a configuration: a contract that no policy checked before
can now fail against it.
Workload and state¶
| Field | Change | Classification |
|---|---|---|
workload |
Modified | BREAKING |
workload |
Added / Removed | NON_BREAKING |
state.type |
Modified | BREAKING |
state.persistence.scope |
Modified | BREAKING |
state.persistence.durability |
Modified | BREAKING |
state.dataCriticality |
Modified | POTENTIAL_BREAKING |
state.dataCriticality |
Added / Removed | NON_BREAKING |
workload and state are top-level sections. Scheduling and scaling are
delivery concerns, so the contract has no runtime, lifecycle or scaling
section to diff.
Capabilities¶
| Field | Change | Classification |
|---|---|---|
capabilities |
Added | NON_BREAKING |
capabilities |
Removed | POTENTIAL_BREAKING |
Capabilities (health, metrics, extension) are compared as a set, keyed by
type, plus ref for an extension. Adding a capability is a new signal
(NON_BREAKING). Removing one loses an observability guarantee
(POTENTIAL_BREAKING). The diff compares only set membership and ignores
binding, so re-pointing an existing capability's binding.interface or
binding.path produces no change entry.
Dependencies¶
| Field | Change | Classification |
|---|---|---|
dependencies |
Added | NON_BREAKING |
dependencies |
Removed | BREAKING |
dependencies.ref |
Modified | POTENTIAL_BREAKING |
dependencies.compatibility |
Modified | POTENTIAL_BREAKING |
dependencies.required |
Modified | POTENTIAL_BREAKING |
dependencies.requires |
Added / Modified | POTENTIAL_BREAKING |
dependencies.requires |
Removed | NON_BREAKING |
OpenAPI¶
pacto diff performs deep comparison of referenced OpenAPI specs, detecting changes at the path, method, parameter, request body and response level.
Paths¶
| Field | Change | Classification |
|---|---|---|
openapi.paths |
Added | NON_BREAKING |
openapi.paths |
Removed | BREAKING |
Methods¶
| Field | Change | Classification |
|---|---|---|
openapi.methods |
Added | NON_BREAKING |
openapi.methods |
Removed | BREAKING |
Parameters¶
| Field | Change | Classification |
|---|---|---|
openapi.parameters |
Added (optional) | POTENTIAL_BREAKING |
openapi.parameters |
Added (required: true) |
BREAKING |
openapi.parameters |
Removed | BREAKING |
openapi.parameters |
Modified (optional → required) | BREAKING |
openapi.parameters |
Modified (schema narrowed) | BREAKING |
openapi.parameters |
Modified (any other) | POTENTIAL_BREAKING |
Introducing a new required parameter, or flipping an existing one from
optional to required, is BREAKING because existing clients omit it. A
parameter's schema sits in request position, so narrowing
it, for example dropping an enum value or lowering a maxLength, is
BREAKING too. Relaxing required to optional is POTENTIAL_BREAKING.
Parameters are identified by name + in (location: query, path, header, cookie). A parameter renamed or moved to a different location counts as a removal plus an addition.
Request body¶
| Field | Change | Classification |
|---|---|---|
openapi.request-body |
Added | POTENTIAL_BREAKING |
openapi.request-body |
Removed | POTENTIAL_BREAKING |
Responses¶
| Field | Change | Classification |
|---|---|---|
openapi.responses |
Added | NON_BREAKING |
openapi.responses |
Removed | BREAKING |
Neither table has a Modified row. A request body or a status code present on
both sides is deep-diffed field by field. A request body schema sits in request
position and a response schema in response position, and the
schema position rules classify each inner difference. A
documentation edit, such as a response description or an example, is
NON_BREAKING. Any other change around the schema, such as a media type or a
header, is POTENTIAL_BREAKING, except that removing a response's whole
content, one of its media types or its schema is BREAKING.
Change paths use a hierarchical format that pinpoints the exact location, for example:
openapi.paths[/users].methods[GET].parameters[filter:query]
openapi.paths[/users].methods[POST].request-body.content.application/json.schema.required[email]
openapi.paths[/users].methods[GET].responses[200].content.application/json.schema.properties.email
AsyncAPI¶
pacto diff compares referenced AsyncAPI documents at the channel and operation level. Both AsyncAPI 2.x and 3.x are supported, in YAML or JSON.
Channels¶
| Field | Change | Classification |
|---|---|---|
asyncapi.channels |
Added | NON_BREAKING |
asyncapi.channels |
Removed | BREAKING |
asyncapi.channels.publish, subscribe (a 2.x flip removes one) |
Removed | BREAKING |
A channel present on both sides is compared field by field, so each payload difference surfaces as its own change. Old and new publishers and subscribers run side by side, so a message's payload and headers schemas sit in record position: a new optional property is NON_BREAKING and every other schema difference is BREAKING. Documentation, tags included, and a new 2.x publish or subscribe operation are NON_BREAKING. Any other difference, such as a binding or a message name, is POTENTIAL_BREAKING. For that reason the table has no asyncapi.channels Modified row.
Operations¶
| Field | Change | Classification |
|---|---|---|
asyncapi.operations |
Added | NON_BREAKING |
asyncapi.operations |
Removed | BREAKING |
asyncapi.operations.action, channel.$ref |
Modified | BREAKING |
An operation present on both sides is deep-diffed exactly like a channel, so flipping action from send to receive surfaces as asyncapi.operations[sendOrder].action (BREAKING).
Top-level operations are an AsyncAPI 3.x concept. A 2.x document has no operations map, so only its channels are compared.
The diff ignores info, servers and defaultContentType, which change on most releases and leave what a consumer can publish or subscribe to unchanged. Components are compared where they are used.
Change paths pinpoint the exact location, for example:
asyncapi.channels[payment.completed]
asyncapi.channels[payment.refunded].publish.message.payload.required[charge_id]
asyncapi.operations[sendOrder].action
gRPC¶
pacto diff compares referenced .proto files at the service, rpc, message, field and enum level.
| Field | Change | Classification |
|---|---|---|
grpc.services |
Added | NON_BREAKING |
grpc.services |
Removed | BREAKING |
grpc.rpcs |
Added | NON_BREAKING |
grpc.rpcs |
Removed | BREAKING |
grpc.rpcs |
Modified | BREAKING |
grpc.messages |
Added | NON_BREAKING |
grpc.messages |
Removed | BREAKING |
grpc.messages.fields |
Added | NON_BREAKING |
grpc.messages.fields |
Removed | BREAKING |
grpc.messages.fields |
Modified | BREAKING |
grpc.enums |
Added | NON_BREAKING |
grpc.enums |
Removed | BREAKING |
grpc.enums.values |
Added | POTENTIAL_BREAKING |
grpc.enums.values |
Removed / Modified | BREAKING |
proto3 has no required, so an added rpc, message or field is always wire-compatible with existing clients. An added enum value is POTENTIAL_BREAKING: the scan cannot tell whether the enum reaches a reader that switches over every value. A removed or renumbered value is BREAKING. Everything else here is BREAKING. Removing a service, rpc, message or field breaks every caller. A changed rpc signature (including a switch between unary and streaming), or a field whose type or number changed, breaks the wire format for clients built against the old descriptor. For that reason a modified field is BREAKING.
Change paths pinpoint the exact location, for example:
grpc.services[FraudService]
grpc.rpcs[FraudService.EvaluateTransaction]
grpc.messages[EvaluateTransactionRequest].fields[metadata]
Limits of the proto comparison¶
The comparison scans proto3 source as text. It runs no protobuf compiler, which has these effects:
imports stay unresolved. Pacto compares only the declarations in the referenced file. A message that moves into an imported file reads as a removal.- Nested messages, nested enums and
oneofbodies are skipped. Their fields are left out of the enclosing message, so a change inside one produces no change entry. - Identity is the declared name. A renamed field or rpc reads as a removal plus an addition even when the field number is unchanged.
- Type identity is textual. The scanner compares names as written, without
packageorimportresolution, so fully qualifying a type (Inner→pkg.v1.Inner) reads as a modified field even though the descriptor is unchanged. - Inline field options are ignored. Adding or removing
[deprecated = true]produces no change entry, while a retype behind one still does. A field whose option block contains braces (a nested text-format value such as[(validate.rules).string = {min_len: 1}]) is dropped from the revision that carries it, and only from that one. Adding such an option to an existing field therefore reads as a removal and is classified BREAKING. - Comments and string literals are inert. A
//, a}or a/*inside a string literal, such as a URL in anoptionline, is read as part of the string.
JSON Schema (configuration & policy schemas)¶
Schema files referenced by configurations[].schema, policies[].schema or the auto-detected policy/schema.json are compared recursively. Pacto detects and classifies every structural difference, including properties, types, constraints, defaults and enums.
| Field | Change | Classification |
|---|---|---|
schema.properties.* |
Added | POTENTIAL_BREAKING |
schema.properties.* |
Removed | POTENTIAL_BREAKING |
schema.properties.* |
Modified | POTENTIAL_BREAKING |
schema.required |
Added / Removed | BREAKING |
schema.description, title, examples and other documentation |
Added / Removed / Modified | NON_BREAKING |
schema.* (any other path) |
Added / Removed / Modified | POTENTIAL_BREAKING |
A configuration or policy schema carries no position. A configuration schema
constrains a value the platform supplies, so both required transitions take
the conservative answer and stay BREAKING.
Schema position¶
An interface schema is judged by the position it sits in, because the same edit helps one side of an exchange and hurts the other.
| Position | Where | Who reads it | Widening | Narrowing |
|---|---|---|---|---|
| request | OpenAPI request bodies and parameters, MCP inputSchema |
the new provider, from an old caller | NON_BREAKING | BREAKING |
| response | OpenAPI responses, MCP outputSchema |
an old consumer, which may switch over every enum value |
BREAKING | NON_BREAKING |
| record | AsyncAPI message payload and headers schemas |
old and new code at once, in both directions | BREAKING | BREAKING |
| Edit | Effect |
|---|---|
enum value added, oneOf or anyOf variant added or type member added |
widens |
type changed from integer to number, or nullable: true set |
widens |
maxLength, maximum, maxItems or maxProperties raised, or a min* bound lowered |
widens |
a bound, pattern, format, const, enum, allOf or items removed |
widens |
additionalProperties: false relaxed to a schema or removed |
widens |
required entry or the whole required list removed, or allOf member removed |
widens |
| the mirror of each row above | narrows |
enum or required entries reordered |
no change |
pattern, format, const or multipleOf rewritten, or type changed to an unrelated type |
breaks in every position |
anything inside not or if, a default, readOnly or discriminator changed, any other keyword |
breaks in every position |
a $ref Pacto cannot resolve |
POTENTIAL_BREAKING in every position |
an x- extension keyword added, removed or changed |
POTENTIAL_BREAKING in every position |
description, title, summary, example, examples, $comment, externalDocs, deprecated or tags |
NON_BREAKING in every position |
Properties have their own rules:
- Adding an optional property is
NON_BREAKINGin every position, because a reader tolerates a field it does not know. - Removing an optional property is
BREAKINGfrom a response or a record whether or notrequiredlisted it. Most payload schemas list nothing, and the field is data a reader may use. - Removing one from a request is
POTENTIAL_BREAKING: the provider drops the caller's field unread. - An object with
additionalProperties: falseaccepts no undeclared field, so adding a property widens it and removing one narrows it. - Inside a response's
headersmap every grade stops atPOTENTIAL_BREAKING, because removing the whole header isPOTENTIAL_BREAKING.
References and components¶
Each $ref is resolved first, so a component is judged where it is used:
- A component used in more than one position, such as a schema in a request body and in a response, is judged in each one. Pacto reports one change with the worst grade, at the usage path where that grade applies. The reason names the component and its usages.
- Pacto compares what a ref resolves to. Renaming a component with identical content is no change, and a ref pointed at a different schema is graded by the structural difference.
- Local refs (
#/components/...,#/definitions/...,#/$defs/...) and relative file refs inside the bundle resolve, and a ref inside another file resolves against that file. Request bodies, responses, parameters and headers resolve the same way as schemas. - A remote (
httporhttps) ref, or one whose target is missing, isPOTENTIAL_BREAKING, and the reason says Pacto could not resolve it. The same unresolved ref in both revisions reports no change, because Pacto sees nothing change. A ref whose text stays the same while its target goes away, such as a renamed component or a file dropped from the bundle, isPOTENTIAL_BREAKINGat the usage path. - A ref to another ref resolves through the chain. A chain that loops never resolves.
- A path item written as a
$ref, local or to a file, is followed to the operations it holds. - A recursive schema is compared once per position, so cycles terminate.
- A component that no path, channel or operation reaches through a
$refis reported as oneNON_BREAKINGchange, because no consumer reads it. Security schemes are named without a$ref, so that report leaves them out. - In AsyncAPI 3 a ref from an operation to its channel or message is compared as text, because the channel is compared on its own.
Readiness¶
| Field | Change | Classification |
|---|---|---|
readiness |
Added / Removed | NON_BREAKING |
readiness.expires |
Added / Modified / Removed | NON_BREAKING |
readiness.minScore |
Added / Modified / Removed | NON_BREAKING |
readiness.partialCredit |
Added / Modified / Removed | NON_BREAKING |
readiness.claims[] |
Added / Modified / Removed | NON_BREAKING |
All readiness changes are NON_BREAKING, because readiness tracks operational
maturity and leaves runtime compatibility unchanged. Adding or removing the whole
readiness block surfaces as a single readiness change; otherwise the gate
fields (expires, minScore, partialCredit) are compared individually. Claims
are keyed by id, and a claim whose status, weight, evidence or any other
field changed is reported as a modification of that claim (for example
readiness.claims[dashboard]). The diff skips the readiness.history revision
log, an append-only changelog that changes on every release.
Fields the diff skips¶
| Section | Behavior |
|---|---|
metadata |
Skipped. Free-form metadata keys appear in documentation and the dashboard. |
capabilities[].binding |
Skipped. Capabilities are keyed by type (and ref), so pacto diff reports no change for a new binding.interface or binding.path on an otherwise unchanged capability. |
readiness.history[] |
Skipped. An append-only changelog that changes on every release; see Readiness. |
To flag changes to these fields in CI, gate on them separately, for example with a policy or a custom check.
SBOM¶
SBOM changes are reported separately from contract changes. They are informational and leave the overall diff classification unchanged.
| Change | Description |
|---|---|
| Package added | A new package appears in the SBOM |
| Package removed | A package left the SBOM |
| Package version modified | A package's version changed |
| Package license modified | A package's license changed |
| Package supplier modified | A package's supplier changed |