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 will break 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 doesn't affect compatibility
Any change not matched by a specific rule below defaults to POTENTIAL_BREAKING.
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 surfaces the specific change rather than an
opaque whole-owner modification. Contacts are keyed by type:value; a purpose
change on an existing contact is a modification. service carries identity only —
there is no image or chart field to diff. pactoVersion is not diffable
either: "2.0" is the only value that loads, so both sides always match and a
version change is a load error rather than a change entry.
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 are keyed by name. The type enum is openapi/asyncapi/grpc, and the spec pointer is ref.
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 against anything. Introducing one where there was none is
POTENTIAL_BREAKING rather than safe, because a configuration nothing checked
before can newly fail validation.
Inline configuration values are the provider's own defaults, not part of the
consumer-facing contract surface, so value changes are diffed key by key (e.g.
configurations[app].values.replicas) and classified 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 policy nothing validated before can
newly 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. There is no runtime, lifecycle
or scaling section to diff — scheduling and scaling are delivery concerns the
contract does not declare.
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). Only membership of that set is diffed: binding is not
part of the key and is not compared, so re-pointing an existing capability's
binding.interface or binding.path produces no change entry at all.
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 |
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 (any other) | POTENTIAL_BREAKING |
Requiring a parameter is the one parameter change that escalates: introducing a new
required parameter, or flipping an existing one from optional to required, is
BREAKING because existing clients omit it. Relaxing required to optional is not.
Parameters are identified by name + in (location: query, path, header, cookie). A parameter renamed or moved to a different location is treated as a removal + 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 never reported as one opaque modification. It is deep-diffed field by field and each inner difference is classified by the JSON Schema rules below, so there is no such change for a Modified rule to answer.
Which side of the exchange the body belongs to changes the verdict, because a
required entry means the opposite thing on each side:
| Where | Change | Classification | Why |
|---|---|---|---|
| request body | required field added |
BREAKING | existing callers omit it |
| request body | required field removed |
NON_BREAKING | the caller's obligation relaxed |
| response | required field added |
NON_BREAKING | a stronger guarantee to the reader |
| response | required field removed |
BREAKING | a guarantee consumers read is withdrawn |
| response | property removed | BREAKING | data a consumer could read is gone |
A property removed from a response is BREAKING whether or not it was listed in
required. Most response schemas have no required array at all, and deleting
a field consumers read is the commonest way a REST provider breaks them. Every
other inner difference (a property added, a type or constraint changed) is
POTENTIAL_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 |
A channel present on both sides is never reported as one opaque modification. It is compared field by field, so a new payload property, a changed property type or a new required entry each surface as their own change. The JSON Schema rules below classify each one: a required change is BREAKING, every other inner difference is POTENTIAL_BREAKING. That is why there is no asyncapi.channels Modified row — the engine has no such change to emit.
Operations¶
| Field | Change | Classification |
|---|---|---|
asyncapi.operations |
Added | NON_BREAKING |
asyncapi.operations |
Removed | 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 (POTENTIAL_BREAKING) rather than a modification of the operation as a whole.
Top-level operations are an AsyncAPI 3.x concept. A 2.x document has no operations map, so only its channels are compared.
Only channels and operations are compared. info, servers, components and defaultContentType are ignored for the same reason metadata is: they churn on every release without changing what a consumer can publish or subscribe to.
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 and field 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 |
proto3 has no required, so an added rpc, message or field is always wire-compatible with existing clients. 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. That is why a modified field is BREAKING rather than POTENTIAL_BREAKING.
Change paths pinpoint the exact location, for example:
grpc.services[FraudService]
grpc.rpcs[FraudService.EvaluateTransaction]
grpc.messages[EvaluateTransactionRequest].fields[metadata]
What the proto comparison does not do¶
The comparison is a text scan of proto3 source, not a protobuf compile. In practice:
imports are not resolved. Only the declarations in the referenced file are compared. A message that moves into an imported file reads as a removal.- Nested messages and
oneofbodies are not descended into. Their fields are skipped rather than misread as fields 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 does not resolve names against
packageorimport, 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]is not a change, while a retype behind one still is. 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 — so adding such an option to an existing field reads as a removal and is classified BREAKING. - Comments and string literals are inert. A
//, a}or a/*inside a string literal — a URL in anoptionline, say — neither truncates the line nor closes a block early nor opens a comment.
JSON Schema (configuration & policy schemas)¶
Schema files referenced by configurations[].schema, policies[].schema or the auto-detected policy/schema.json are compared recursively. Every structural difference — properties, types, constraints, defaults, enums, etc. — is detected and classified.
| Field | Change | Classification |
|---|---|---|
schema.properties.* |
Added | POTENTIAL_BREAKING |
schema.properties.* |
Removed | POTENTIAL_BREAKING |
schema.properties.* |
Modified | POTENTIAL_BREAKING |
schema.required |
Added / Removed | BREAKING |
schema.* (any other path) |
Added / Removed / Modified | POTENTIAL_BREAKING |
The same recursive comparison classifies OpenAPI request bodies and responses,
AsyncAPI payloads and these schema files — but not identically. A required
entry is a promise in one direction and an obligation in the other, so the
verdict depends on which side supplies the data.
- OpenAPI request bodies and responses carry a known direction and use the mirrored table under Responses above.
- Configuration schemas, policy schemas and AsyncAPI payloads do not. A
configuration schema constrains a value the platform supplies; an AsyncAPI
channel is published by one service and subscribed by another. With no
direction to read, both
requiredtransitions take the conservative answer and stayBREAKING, and a removed property isPOTENTIAL_BREAKINGlike any other structural difference.
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 classified as NON_BREAKING — readiness tracks operational
maturity and does not affect runtime compatibility. 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 (e.g.
readiness.claims[dashboard]). The readiness.history revision log is not diffed:
it is an append-only changelog that changes on every release and would only add
noise.
Not currently compared¶
| Section | Status |
|---|---|
metadata |
Not diffed. Free-form metadata keys are carried through to documentation and the dashboard but are ignored by the diff engine. |
capabilities[].binding |
Not diffed. Capabilities are keyed by type (and ref), so a changed binding.interface or binding.path on an otherwise unchanged capability is invisible to pacto diff. |
readiness.history[] |
Not diffed. An append-only changelog that changes on every release; see Readiness. |
If you rely on any of these being flagged 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 only and never affect the overall diff classification.
| Change | Description |
|---|---|
| Package added | A new package appears in the SBOM |
| Package removed | A package no longer appears in 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 |