[dedo] 8: Dedo Breaking Rules

Raveesh Agarwal

·

·

9–14 minutes
A scope removed from CancelOrder: buf breaking with its FILE rules exits 0, because the descriptors did not change; DEDO_SCOPES_NO_DELETE reports that CancelOrder no longer accepts orders.order.write; a house rule in dedo.yaml can check the same with against.scopes.all(s, s in scopes).

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 changebuf breaking exit code
Rename a field (the control)100: reported
Remove a scope0
Change the status code of an error reason0
Narrow a service’s audience0
Change a service’s tenancy0
Remove cross_tenant from an RPC0
Change an HTTP path0
Make an existing field REQUIRED0
Change a resource pattern0
Move a sunset earlier0
Rename an event type in emits0

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.

An OrderService proto. The descriptors (names, numbers, types) are what buf breaking's FILE rules compare. The option values are marked in red, each with the dedo breaking rule that checks it: audience, tenancy, the REST path, scopes, an error reason's code, emits, a field made REQUIRED and the resource pattern. With buf 1.65, renaming a field exits 100; each red change tried exits 0.

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.

RuleCatchesWhy it breaks callers
DEDO_SCOPES_NO_DELETEAn RPC stops accepting a scope it acceptedA caller whose token holds only that scope is denied from the next deploy
DEDO_ERROR_REASON_SAME_CODEAn error reason changes its status codeClients handle errors by reason and code, and retry on some codes but not others
DEDO_AUDIENCE_NO_NARROWA service’s audience narrows, say from public to company-internalCallers outside the new audience were let in before, and are shut out after
DEDO_TENANCY_SAMEA service’s tenancy is added, removed or moved to another collectionThe tenant is the root of every resource name and request path, so all of them change
DEDO_CROSS_TENANT_NO_DELETEAn RPC stops being cross_tenantThe runtime starts checking the request’s tenant, so calls that span tenants, or name none, fail
DEDO_HTTP_BINDING_SAMEA REST binding moves, or changes its body or response_bodyREST clients call the old path and send and read the old shape
DEDO_FIELD_NO_NEW_REQUIREDAn existing field becomes REQUIREDCallers that left it out, as they were allowed to, start failing (AIP-180)
DEDO_RESOURCE_SAMEA resource changes its type or drops a patternStored resource names stop resolving, and every reference to the old type breaks
DEDO_SUNSET_NO_EARLIERA deprecated RPC’s sunset_time moves earlierCallers planned their migration by the date they were told
DEDO_EMITS_NO_DELETEAn RPC stops declaring an event typeConsumers 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:

RPC “CancelOrder” no longer accepts scope “orders.order.write”, so callers whose token holds only that scope lose access.

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 ref or by a URL with its sha256. One import brings both kinds.
  • use and disable. breaking.use names the sets that run, DEDO and HOUSE by default. disable turns 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.
  • doc links. A finding links to your page, so the engineer learns why.
  • dedo tools lists 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:

rules in force: lint: … breaking: 11 rules, 1 of them house rules; 11 run, 0 overridden, 0 not used, skipped or disabled use: DEDO, HOUSE (the default; dedo.yaml does not say breaking.use)

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

Raveesh Agarwal is a full stack builder writing about Kubernetes, agentic AI and distributed systems. He runs a four-node Kubernetes cluster on his desk. More about me · Follow via RSS

Comments

3 responses to “[dedo] 8: Dedo Breaking Rules”

  1. […] ships ten breaking rules of its own, for what buf breaking cannot see. Part 8 covers them, and house breaking rules in […]

  2. […] Part 8: Dedo Breaking Rules: what buf breaking cannot see, and the rules that catch it before a client does. […]

  3. […] Part 8: Dedo Breaking Rules: what buf breaking cannot see, and the rules that catch it before a client does. […]

Leave a Reply

Your email address will not be published. Required fields are marked *