pacto diff and change classification: what counts as a breaking change¶
pacto diff classifies every detected change using a deterministic rule table. This is what powers breaking change detection in CI pipelines, and 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, classified by the JSON Schema rules below: 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, and 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. A field's trailing
[...]block is parsed off and dropped, so 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 skipped entirely, on both sides, so it never appears in the compared surface. - Comments and string literals are handled by one pre-scan. Before anything is matched, the source is walked once and every comment, along with the contents of every closed string literal, is blanked to spaces. Newlines survive, so the line structure and every byte offset are unchanged. A
//, a}or a/*inside a closed string (a URL in anoptionline, say) is therefore inert: it neither truncates the line, nor closes a block early, nor opens a comment. Both quote styles and backslash escapes are understood, and the single pass settles comment-vs-string precedence, so a/*written inside a//comment does not open a block comment. A quote with no closing quote before the line break is not a proto string literal at all, since a literal cannot span a raw newline; the scan blanks from that quote to the end of the line, keeping any;it finds. The blanking makes the tail's braces and comment openers inert, and the surviving;still ends the statement, so the declaration on the next line is read normally.
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 |