Here is a pull request that locks a customer out on the next deploy:
rpc CancelOrder(CancelOrderRequest) returns (CancelOrderResponse) {
option (dedo.v1.method) = {
// scopes: "orders.order.write" <- this line was deleted
scopes: "orders.admin"
};
}Code language: Protocol Buffers (protobuf)
A partner’s integration holds a token with orders.order.write. From the next deploy, every CancelOrder it sends is denied. The reviewer sees one deleted line. CI is green, because buf breaking passes it.
That isn’t a bug in Buf. buf breaking compares descriptors, never option values. To Buf, dedo.v1.method is a custom option, and it has no way of knowing that one of its strings is an access list. dedo does know, so now it checks.
Buf guards the shape of your contract. dedo’s breaking rules guard what it promises.
What buf breaking can’t see
We wanted to know exactly where the gap was, so we ran an experiment with buf breaking 1.65 and its FILE rules. One change at a time against the same contract, each a change that breaks somebody. The control was renaming a field, which Buf is built to catch.
| The change | buf breaking exit code |
|---|---|
| Rename a field (the control) | 100: reported |
| Remove a scope | 0 |
| Change the status code of an error reason | 0 |
| Narrow a service’s audience | 0 |
| Change a service’s tenancy | 0 |
Remove cross_tenant from an RPC | 0 |
| Change an HTTP path | 0 |
Make an existing field REQUIRED | 0 |
| Change a resource pattern | 0 |
| Move a sunset earlier | 0 |
Rename an event type in emits | 0 |
Ten changes, ten passes. Every one of them lives in an option: dedo’s own, or google.api.http, google.api.field_behavior and google.api.resource. Every one of them breaks a caller who relied on it, and none of them would be noticed until that caller noticed.

In dedo, the red parts are most of the contract. They decide who may call, what errors mean, where REST lives and which events go out. A tool that compares only the black parts was never going to be enough.
dedo’s ten breaking rules
They live in the same plugin as dedo’s lint rules, buf-plugin-dedo-lint, in a new category, DEDO_BREAKING. dedo init and dedo upgrade add it to buf.yaml beside Buf’s own:
# buf.yaml
breaking:
use:
- FILE # Buf's own breaking rules
- DEDO_BREAKING # every breaking rule of buf-plugin-dedo-lintCode language: YAML (yaml)
Nothing else changes. buf breaking runs where it already ran, in make check and in CI, and reports dedo’s findings next to Buf’s. Each rule pairs an element with the one of the same name in the version you compare against. An element that was added isn’t checked, and one that was deleted is already FILE‘s job.
| Rule | Catches | Why it breaks callers |
|---|---|---|
DEDO_SCOPES_NO_DELETE | An RPC stops accepting a scope it accepted | A caller whose token holds only that scope is denied from the next deploy |
DEDO_ERROR_REASON_SAME_CODE | An error reason changes its status code | Clients handle errors by reason and code, and retry on some codes but not others |
DEDO_AUDIENCE_NO_NARROW | A service’s audience narrows, say from public to company-internal | Callers outside the new audience were let in before, and are shut out after |
DEDO_TENANCY_SAME | A service’s tenancy is added, removed or moved to another collection | The tenant is the root of every resource name and request path, so all of them change |
DEDO_CROSS_TENANT_NO_DELETE | An RPC stops being cross_tenant | The runtime starts checking the request’s tenant, so calls that span tenants, or name none, fail |
DEDO_HTTP_BINDING_SAME | A REST binding moves, or changes its body or response_body | REST clients call the old path and send and read the old shape |
DEDO_FIELD_NO_NEW_REQUIRED | An existing field becomes REQUIRED | Callers that left it out, as they were allowed to, start failing (AIP-180) |
DEDO_RESOURCE_SAME | A resource changes its type or drops a pattern | Stored resource names stop resolving, and every reference to the old type breaks |
DEDO_SUNSET_NO_EARLIER | A deprecated RPC’s sunset_time moves earlier | Callers planned their migration by the date they were told |
DEDO_EMITS_NO_DELETE | An RPC stops declaring an event type | Consumers subscribe by type, and silently stop hearing about the change |
Notice what passes. Adding a scope passes, because a caller needs only one. Widening the audience passes. A new field may be REQUIRED, and an old one may stop being so. A new REST binding, a new resource pattern and a new event type all pass. The rules don’t stop a contract from growing. They stop it from taking back what it gave.
Here is the scope change from the top of this post, as buf breaking now reports it:
Every finding says what changed and who it hurts, and links to the rule’s section of RULES.md.
A word on events
v0.32.0 gave dedo domain events, outward only. An RPC declares the events it may publish in emits:
rpc CancelOrder(CancelOrderRequest) returns (CancelOrderResponse) {
option (dedo.v1.method) = {
scopes: "orders.order.write"
errors: {reason: "ORDER_NOT_FOUND", code: NOT_FOUND}
errors: {reason: "EVENT_NOT_PUBLISHED", code: UNAVAILABLE}
emits: "acme.orders.order.cancelled"
};
}Code language: Protocol Buffers (protobuf)
An event is a notification, not a document. It says that something happened to a resource: a type, the resource’s name, an ID and a time. No payload. Consumers fetch the resource through the API, with their own token and scopes.
So the type is the whole of the event’s contract. Rename cancelled to voided and nothing fails. The service publishes happily, the bus delivers happily, and every consumer still subscribed to acme.orders.order.cancelled just stops hearing about cancellations. That’s the quietest kind of breakage there is, and in our experiment buf breaking passed it too.
DEDO_EMITS_NO_DELETE catches it. The fix is to publish both until no consumer needs the old one:
// Fails: the type was renamed.
option (dedo.v1.method) = {emits: "acme.orders.order.voided"};
// Passes: the old type stays, and the new one joins it.
option (dedo.v1.method) = {emits: ["acme.orders.order.cancelled", "acme.orders.order.voided"]};Code language: Protocol Buffers (protobuf)
When a rule fires
A breaking rule doesn’t forbid the change. It asks you to make it without taking anything away from anyone. There are two ways.
Keep the old thing beside the new. Most fixes are this. A moved REST path keeps the old one as an additional binding:
// Before:
option (google.api.http) = {get: "/v1/{name=shelves/*/books/*}"};
// After, passes: the new path joins the old one.
option (google.api.http) = {
get: "/v1/{name=shelves/*/books/*}"
additional_bindings: {get: "/v1/{name=books/*}"}
};Code language: Protocol Buffers (protobuf)
A resource keeps its old pattern and adds the new one:
option (google.api.resource) = {
type: "acme.com/Book"
pattern: "shelves/{shelf}/books/{book}" // stored names still resolve
pattern: "books/{book}" // the new one
};Code language: Protocol Buffers (protobuf)
The rest follow the same idea. A new scope joins the old one. A new status code gets a new error reason. A field that must now be sent becomes a new field. A sunset moves later, never earlier.
Or bump the package’s major version. Some changes can’t be made side by side: a service that becomes multi-tenant, an audience that really must narrow, a scope you truly want gone. Those belong in a new major version of the package, acme.orders.v2 beside acme.orders.v1 (AIP-185). Callers move when they’re ready, and v1 keeps its promises until it’s retired.
If your organisation allows a deliberate break, say before anyone outside the team calls the service, add the label your CI reads to allow one. That’s a decision, made in the open, in the pull request. Which is the point.
House breaking rules
dedo’s ten rules cover what dedo’s options mean. Your organisation will have promises of its own. Maybe every commerce API lives under /commerce/ and must stay there. Maybe clients store enum values, so no value may ever disappear.
Those are house rules, the same CEL checks as in part 4, with one new name: against. It holds the same variables as the element being checked, taken from the version you compare with:
# dedo.yaml
breaking:
rules:
- id: HOUSE_COMMERCE_ROOT_KEPT
on: method
check: "!against.http_path.startsWith('/commerce/') || http_path.startsWith('/commerce/')"
message: A method served under /commerce/ stays there.
- id: HOUSE_ENUM_VALUES_KEPT
on: enum
check: against.values.all(v, v in values)
message: An enum keeps its values; clients store them.
doc: https://wiki.acme.com/api/enums
final: trueCode language: YAML (yaml)
Read the first check aloud: if it was under /commerce/, it still is. The second: every value it had, it still has. A rule like against.scopes.all(s, s in scopes) is DEDO_SCOPES_NO_DELETE in one line, and you can write your own variations on it.
A house breaking rule runs on each file, service, method, message, field (by number), enum or resource that both versions have. A new element has nothing to compare with, so it isn’t checked.
Everything house lint rules have, house breaking rules have too. That was the point of building them on the same machinery:
- Shared imports. The organisation’s rules file carries breaking rules next to its lint rules, and services import it pinned by a git
refor by a URL with itssha256. One import brings both kinds. useanddisable.breaking.usenames the sets that run,DEDOandHOUSEby default.disableturns off one rule by ID.- Overrides. A house breaking rule can replace one of dedo’s, or an imported one, by naming it.
final. A rule the organisation doesn’t negotiate can’t be overridden or disabled by any service that imports it.doclinks. A finding links to your page, so the engineer learns why.dedo toolslists both kinds, with every override in force.
Here is a cart service that takes the organisation’s rules, opts out of one of dedo’s breaking rules, and overrides another:
# dedo.yaml in the cart service
imports:
- git: https://github.com/acme/api-standards.git # lint and breaking rules
ref: v3
breaking:
disable:
- DEDO_SUNSET_NO_EARLIER # this service's sunsets follow the platform's calendar
rules:
- id: HOUSE_CART_SCOPES_KEPT
on: method
check: "'cart.admin' in scopes || against.scopes.all(s, s in scopes)"
message: A cart method keeps its scopes, unless cart.admin now grants it.
overrides: [DEDO_SCOPES_NO_DELETE]Code language: YAML (yaml)
The two kinds stay apart where it matters. A lint rule never overrides or disables a breaking rule, nor the other way round, and every rule ID is unique across both. Breaking rules rank as lint rules do, in fewer layers: your repository’s own, then the imported ones, then dedo’s.
Run dedo tools after any change, and it tells you what’s in force. For a repository with one house breaking rule of its own:
The new dedo.yaml
With two kinds of rules, the old single rules section had to go. dedo.yaml now has four top-level keys:
imports: [...] # shared house rules, lint and breaking
lint:
use: [AIP_NOLANG, ZALANDO, DEDO, HOUSE]
disable: [...]
rules: [...] # this repository's own lint rules
breaking:
use: [DEDO, HOUSE]
disable: [...]
rules: [...] # this repository's own breaking rules
annexure: [...] # the organisation's guidance for coding agentsCode language: YAML (yaml)
imports sits at the top because one import brings both kinds of rules. lint and breaking take the same keys, each for rules of its own kind. annexure is unchanged. A shared rules.yml has the same imports, lint and breaking, without use: only a service chooses which of dedo’s rules run.
dedo upgrade moves a service’s own dedo.yaml to the new layout and keeps its comments. A shared rules file in the old layout fails to load, and says where its keys go, until its owners publish it anew. It’s a breaking change, and dedo is still below v1, so it’s listed under Breaking in the changelog. dedo.example.yaml uses every key, if you’d like to see it all in one place.
Getting it
The release is v0.32.2, DEDO BREAKING RULES:
dedo update -version v0.32.2 && dedo upgrade
buf breaking --against '.git#branch=main'Code language: Bash (bash)
dedo upgrade adds DEDO_BREAKING to buf.yaml and moves dedo.yaml. The second line is what make check already runs. The first time, you may find a promise or two your contract broke last month without anyone noticing.
The point
A dedo contract says who may call, what each error means, where REST lives and which events go out. Those promises were always in the proto. Now they’re checked like the rest of it, by the tool you already run, with your organisation’s own promises beside dedo’s.
Grow the contract freely. Take nothing back.
Every rule, with examples and fixes, is in RULES.md.
The dedo series
- Part 1: Define Once, Deploy Everywhere: why one proto can carry the contract and its rules.
- Part 2: Getting Started with Todo Good and Todo Bad: two services, one interface, and what each lint rule protects you from.
- Part 3: Adding dedo to an Existing Service: one layer at a time, with an audit mode before you enforce.
- Part 4: House Rules, Your Conventions as Lint: your organisation’s conventions as CEL checks, shared from one repository.
- Part 5: Multi-Tenant SaaS Auth: the tenant boundary, declared in the proto and enforced on every transport.
- Part 6: How fast is Dedo?: what dedo costs and saves, measured: getting started, lint, and MCP tool calls a second.
- Part 7: Forking dedo (You Probably Don’t Need To): why house rules and pins cover most needs, and how to run a fork when policy demands one.
- Part 8: Dedo Breaking Rules (this post): what
buf breakingcan’t see, dedo’s ten rules for it, and house breaking rules of your own.

Leave a Reply