Each standard dedo uses knows one thing well:
- The AIPs know how a resource should be named.
- Zalando knows how a scope should be shaped.
- dedo knows what one proto needs to serve four transports.
None of them knows your company. They do not know that:
- every REST path starts with
/commerce/; - resource types live under your domain;
- timestamps end in
_time; - your platform team caps a message at fifty fields.
Those conventions exist in every organisation. They usually live in a wiki page, and in the memory of the three reviewers who enforce them.
dedo gives them a home in the linter.
They are called house rules.
A house rule
Here is one, from dedo’s todo example:
# dedo.yaml, next to buf.yaml
lint:
rules:
- id: HOUSE_REST_ROOT
on: method
check: http_path == '' || http_path.startsWith('/commerce/')
message: REST paths start with /commerce/, the commerce platform's root.
doc: https://wiki.acme.com/api/rest-rootsCode language: YAML (yaml)
Five fields, and two optional ones:
idis the rule’s name. It always starts withHOUSE_.onpicks the kind of element the rule checks.checkis a CEL expression. It is true when the element passes.messageis what the engineer sees when it fails.docis where they go to learn why.overridesnames the rules this one replaces. More on that below.finallocks the rule, so no other rule can replace it.
dedo.yaml decides which rules run. buf.yaml lists the plugin’s lint rules as one entry, DEDO, and its breaking rules as another, DEDO_BREAKING. It leaves the choosing to dedo.yaml:
# buf.yaml
lint:
use:
- STANDARD # Buf's own rules
- COMMENTS
- DEDO # every lint rule of dedo's plugin
breaking:
use:
- FILE # Buf's own breaking rules
- DEDO_BREAKING # every breaking rule of dedo's pluginCode language: YAML (yaml)
The lint section of dedo.yaml names the rule sets that run, HOUSE among them. dedo init writes it this way:
# dedo.yaml
lint:
use:
- AIP_NOLANG
- ZALANDO
- DEDO
- HOUSE # the house rules: this file's, and the ones it imports
rules:
- id: HOUSE_REST_ROOT
...Code language: YAML (yaml)
The rule then behaves like every other Buf rule:
- The finding appears in the editor as you type, with your message and your link.
- It blocks the merge in CI.
disable: [HOUSE_REST_ROOT]in thelintsection of a repository’sdedo.yamlturns it off there.// buf:lint:ignore HOUSE_REST_ROOTsuppresses one finding, with a reason.
No plugin to write, no binary to build, no release to cut, and one file that says what runs.
Why CEL
Earlier versions of dedo asked organisations to write a Buf plugin of their own for house rules. That meant a Go module, a build, a release, and a pin in every repository.
That is a lot of machinery for “paths start with /commerce/“.
CEL fits the job:
- Small. A rule is one expression, and a platform team can review it in one line.
- Familiar. It is the language behind
buf.validate, Kubernetes admission policies and Google’s IAM conditions. - Safe. It cannot loop forever or touch the network.
What a check can see
on picks the element, and each element exposes a fixed set of names:
on | Variables |
|---|---|
file | name, proto_package, imports |
service | name, full_name, file, proto_package, methods, resources, roots, title |
method | name, full_name, file, proto_package, service, input, output, client_streaming, server_streaming, idempotency, http_verb, http_path, http_bindings, scopes, errors, deprecated |
message | name, full_name, file, proto_package, fields |
field | name, full_name, file, proto_package, message, type, repeated, optional, behaviors, json_name |
enum | name, full_name, file, proto_package, values |
resource | name, full_name, file, proto_package, type, patterns, singular, plural |
Lists are lists of strings, except http_bindings, a list of {verb, path} maps. CEL’s string extensions are on, so split, lowerAscii, replace and trim all work. So is cel.bind, which names a value once instead of repeating it.
Notice what the method row includes: scopes, errors, idempotency, http_path.
House rules can read dedo’s own declarations, so your conventions build on the contract.
A few more rules
These are illustrations. Adapt them to your organisation.
Resource types live under your domain. This one ships in dedo’s examples:
- id: HOUSE_RESOURCE_TYPE_DOMAIN
on: resource
check: type.split('/')[0].endsWith('.raveesh.me')
message: Resource types live under raveesh.me, such as todo.raveesh.me/Todo.Code language: YAML (yaml)
Timestamps end in _time.
- id: HOUSE_TIMESTAMP_SUFFIX
on: field
check: type != 'google.protobuf.Timestamp' || name.endsWith('_time')
message: Timestamp fields end in _time, such as create_time.Code language: YAML (yaml)
Writes need a write scope.
- id: HOUSE_WRITE_SCOPE
on: method
check: idempotency == 'NO_SIDE_EFFECTS' || scopes.exists(s, s.endsWith('.write'))
message: An RPC that changes state needs a .write scope.Code language: YAML (yaml)
Messages stay small.
- id: HOUSE_MESSAGE_SIZE
on: message
check: fields.size() <= 50
message: Split messages with more than 50 fields.Code language: YAML (yaml)
A service manages one aggregate. Since dedo v0.30.0, a service rule sees resources, the resource types its methods take or return, and roots, those not nested under another. A domain service has neither, so it passes:
- id: HOUSE_SERVICE_ONE_ROOT
on: service
check: roots.size() <= 1
message: A service manages one aggregate root, with its child resources.Code language: YAML (yaml)
Each rule is one line of logic, and each one replaces a comment someone used to leave in review.
Sharing rules across services
A convention that lives in one repository is a local habit. An organisation needs one source. So house rules import:
# dedo.yaml in a service repository
imports: # shared rules, lint and breaking
- git: https://github.com/acme/api-standards.git # the organisation's shared rules
ref: v3 # a tag or a commit
- git: https://github.com/acme/commerce-platform.git # the platform's, which build on them
ref: 4f9c2a1
path: api/rules.yml
- path: ../shared-rules # a folder (its rules.yml) or a file
lint:
use: [AIP_NOLANG, ZALANDO, DEDO, HOUSE] # the rule sets that run
disable: [HOUSE_MESSAGE_SIZE] # rules this service opts out of
rules:
- id: HOUSE_REST_ROOT # this service's own
on: method
check: http_path == '' || http_path.startsWith('/commerce/')
message: REST paths start with /commerce/, the commerce platform's root.Code language: YAML (yaml)
dedo’s repository has a complete one, every key used, as templates/dedo.example.yaml; dedo templates dedo.example.yaml prints it.
The shape that works:
- A platform team keeps
api-standardswith onerules.yml. It has the sameimports,lintandbreakingas adedo.yaml, but nouse: only the service chooses which of dedo’s rules run. - They tag it:
v1,v2,v3. - Each service imports it at a tag.
- A service adds rules of its own, opts out of rules by ID with
disable, imported ones or dedo’s, and replaces them withoverrides.
A service can import as many sources as it needs: the company’s rules, the commerce platform’s, a local folder. Imports nest, too. A commerce file can import the company file. dedo reports an import cycle when it finds one.
A git import needs read access to the repository, which CI often lacks for a private one. Since dedo v0.19.0, a release of the rules can be published over HTTPS instead, and pinned by its SHA-256:
imports:
- url: https://acme.github.io/api-standards/v3/rules.yml
sha256: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08Code language: YAML (yaml)
GitHub Pages can serve a site publicly from a private repository. Publish one folder per release. dedo tools fails when the file does not match its sha256, and when the sha256 is missing.
The same folder can hold your organisation’s guidance for coding agents. Since v0.20.0, dedo.yaml‘s annexure names it, by url and sha256 or by path. dedo init and dedo upgrade copy it into AGENTS.md and CLAUDE.md, after dedo’s own guidance. Where the two conflict, the annexure wins.
disable is the escape hatch, and it is visible. A service that opts out of a rule says so in a file a reviewer reads.
Since dedo v0.15.0, that file is the only place the choice is made. Earlier releases also chose rules in buf.yaml, whose lint.use and except sat outside the order of authority below, where they could switch off even a final rule. Now buf.yaml names DEDO and DEDO_BREAKING, and nothing else of dedo’s. dedo upgrade moves an older buf.yaml‘s choice into dedo.yaml for you: its sets become lint.use, its exceptions lint.disable. A repository set up before v0.20.0 kept its choice in a rules.yml; dedo init and dedo upgrade move that into dedo.yaml too.
dedo v0.32.1 changed the layout once more. imports moved to the top of dedo.yaml, since an import brings both kinds of rules, and a lint section took the place of rules. dedo upgrade moves an older dedo.yaml, keeping its comments. A shared rules.yml changes the same way, and never has rules at its top. One in the old layout fails to load, saying where its keys go, until its owners publish it anew.
Fast, and offline
The lint runs on every keystroke, so it cannot clone a repository each time.
Fetching and linting are separate steps.
- Fetch.
dedo toolsfetches every git import at its ref, and every url import checked against itssha256, into.tools/rules/, then checks every override (below).dedo initanddedo upgradecall it too. - Lint. The lint reads only local files and never touches the network. A missing import fails the lint with “run dedo tools”.
- Upgrade. A ref or a
sha256names one version. To move to new shared rules, change it, and that change goes through review like any other.

The order of authority
Since dedo v0.12.0, your rules sit at the top of the order of authority, not the bottom.

Each layer outranks the ones below it:
- Your service’s own
dedo.yaml. It knows its own service best. - The house rules it imports. A rules file outranks the files it imports, so the commerce file outranks the company file it builds on.
- DEDO, which makes one proto serve every transport.
- AIP, because the contract is a protobuf API.
- ZALANDO, the edge rules a proto can express.
Outranking a rule is not enough to replace it. A rule replaces another only by naming it.
dedo does not try to detect conflicts between rules. It would guess wrong. Instead, a rule says which rules it replaces:
- id: HOUSE_CART_RESOURCE_TYPE
on: resource
check: type.startsWith('cart.acme.com/')
message: Cart resource types live under cart.acme.com.
overrides: [HOUSE_RESOURCE_TYPE_DOMAIN]Code language: YAML (yaml)
Here a service narrows a company rule to its own domain. The company rule stops reporting, and the service’s rule reports in its place. The same works against dedo’s rules: if your organisation versions its APIs some other way, a house rule can override DEDO_HTTP_PATH_VERSION, and say so where every reviewer reads it.
dedo uses the same mechanism itself. DEDO_HTTP_RESPONSE_BODY overrides the AIP rules that want Get to return the bare resource, since every dedo RPC returns its own <Rpc>Response. Those rules used to sit in every service’s buf.yaml as exceptions. Now the override lives with the rule that causes it.
What the linter checks
Every time it starts, the plugin reads dedo.yaml, gathers every rule and every override, and checks them all. It refuses:
- a
use,disableor override that names a rule or set that does not exist; - an override of a rule that does not rank below it, such as a company rule overriding a service’s, or two imported files overriding each other;
- an override of a final rule, or a
disableof one.
Three of dedo’s rules are final: ZALANDO_SERVICE_RULES_REQUIRED, DEDO_METHOD_RULES_REQUIRED and DEDO_MULTI_TENANT_REQUEST_TENANT. dedo’s runtime refuses to start without what they check, so overriding them would only move the failure from the editor to production.
Since dedo v0.13.0, your rules can be final too. A platform team marks the conventions it does not negotiate:
- id: HOUSE_RESOURCE_TYPE_DOMAIN
on: resource
check: type.split('/')[0].endsWith('.acme.com')
message: Resource types live under acme.com.
final: trueCode language: YAML (yaml)
No service that imports this file can override the rule or disable it, however many files sit in between. The commerce platform’s rules can’t, and neither can a service’s own dedo.yaml. A final rule can still override rules below it.
Either way, a rule can demand more than a final rule does, never less. Add yours beside it. A final rule also runs whatever its section’s use says: leaving DEDO out of lint.use does not switch off DEDO_METHOD_RULES_REQUIRED.
dedo tools runs the same check, and lists the sets in use, every override and every final rule in force:
rules in force:
lint: 366 rules, 3 of them house rules; 341 run, 8 overridden, 17 not used, skipped or disabled
use: AIP_NOLANG, ZALANDO, DEDO, HOUSE
DEDO_HTTP_RESPONSE_BODY overrides AIP_0131_RESPONSE_MESSAGE_NAME
...
HOUSE_TODO_RESOURCE_TYPE (dedo.yaml) overrides HOUSE_RESOURCE_TYPE_DOMAIN
DEDO_METHOD_RULES_REQUIRED is final
...
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)Code language: plaintext (plaintext)
A mistake shows up there with the rule and the file that made it, and every mistake at once:
dedo.yaml: HOUSE_OPEN overrides DEDO_METHOD_RULES_REQUIRED, which is final:
policy.Build refuses a method without scopes, so a contract without it would
fail at startup instead of in lint. Keep DEDO_METHOD_RULES_REQUIRED and add
your rule beside itCode language: plaintext (plaintext)
The check costs well under a millisecond, so dedo.yaml is never compiled into the plugin. Edit a rule and the next lint sees it.
Every exception to a standard is now a line in a file, with the rule that made it.
dedo itself never ships a HOUSE_* rule. The prefix is yours, and an upgrade will never collide with it.
House rules for breaking changes
Lint looks at one version of the contract. Some conventions are about two: an enum keeps its values, a method served under /commerce/ stays there.
Since dedo v0.32.1, dedo.yaml has a breaking section beside lint. Its rules are CEL checks too. buf breaking runs them on each element the earlier version also has, and a check reads that earlier element as against:
breaking:
rules:
- id: HOUSE_ENUM_VALUES_KEPT
on: enum
check: against.values.all(v, v in values)
message: An enum keeps its values, which clients store; add new ones instead.Code language: YAML (yaml)
They get everything lint house rules get: use and disable, imports, overrides, final and doc. They rank the same way, in fewer layers: your dedo.yaml, the rules it imports, then dedo’s own. A breaking rule never overrides or disables a lint rule, nor the other way round.
dedo ships ten breaking rules of its own, for what buf breaking cannot see. Part 8 covers them, and house breaking rules in full.
Where house rules run
- Native plugin. House rules run here. It reads
dedo.yamlfrom disk. - BSR WebAssembly plugin. It cannot read files, so it runs dedo’s default rule sets and no house rules.
dedo initreplaces it with the native plugin, which needs Go on the machine and in CI, but nogo.mod. A platform team that must lint without Go can build its own withdedo.yaml‘s rules built in,dedo tools -wasm, and push it to the BSR. DEDO_RULES. Points the lint at a different rules file. Use it to test a change to the shared rules before you tag it.
Watching them fail
examples/bad-todo imports the same shared rules as examples/todo. The good service follows them. The bad one breaks both:
21 HOUSE_REST_ROOT
51 HOUSE_REST_ROOT
...
189 HOUSE_RESOURCE_TYPE_DOMAINCode language: plaintext (plaintext)
The bad todo’s REST paths start at /v1/. Its resource type is todo.example.com/Todo.
Both are fine by the AIPs and by dedo. Both are wrong for this organisation.
Only a house rule can say so.
The point
A standard is only as good as the place it lives.
dedo puts the public standards in the linter. House rules put your own conventions right beside them, with:
- the same editor squiggles;
- the same CI gate;
- the same audit trail for every exception, and for every override.
Your reviewers spend their attention on the design.
Your conventions never need a fork of dedo: they live in your dedo.yaml. If your organisation needs a fork anyway, part 7 covers it.
Next
The next post covers multi-tenant SaaS auth.
It declares the tenant boundary in the proto, checks it in lint, and enforces it on every transport before your handler runs.
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 (this post): your organisation’s conventions as CEL checks, shared from one repository, overriding the standards where you decide.
- 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: what
buf breakingcannot see, checked by dedo’s breaking rules and your own.

Leave a Reply