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:
# rules.yml, next to buf.yaml
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:
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.
Add HOUSE to lint.use in buf.yaml, and that is all:
lint:
use:
- STANDARD
- COMMENTS
- AIP_NOLANG
- DEDO
- ZALANDO
- HOUSECode 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.
except: [HOUSE_REST_ROOT]turns it off for a repository.// buf:lint:ignore HOUSE_REST_ROOTsuppresses one finding, with a reason.
No plugin to write, no binary to build, no release to cut.
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 |
method | name, full_name, file, proto_package, service, input, output, client_streaming, server_streaming, idempotency, http_verb, http_path, 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. CEL’s string extensions are on, so split, lowerAscii, replace and trim all work.
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)
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:
# rules.yml in a service repository
imports:
- git: https://github.com/acme/api-standards.git # the organisation's shared rules
ref: v3 # a tag or a commit
- path: platform-rules/rules.yml # a folder (its rules.yml) or a file
disable: [HOUSE_MESSAGE_SIZE] # imported 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)
The shape that works:
- A platform team keeps
api-standardswith onerules.yml. - They tag it:
v1,v2,v3. - Each service imports it at a tag.
- A service adds rules of its own, and opts out of imported ones by ID with
disable.
Imports nest. A commerce file can import the company file. dedo reports an import cycle when it finds one.
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.
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 into.tools/rules/at its ref.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 names one version. To move to new shared rules, change the
ref, and that change goes through review like any other.

The order of authority
House rules sit at the bottom of the order of authority.

A house rule may add constraints. It may never contradict a rule above it.
Two examples of rules that would:
- Demanding
snake_caseJSON names contradictsDEDO_NO_JSON_NAME. - Banning versions in paths contradicts
DEDO_HTTP_PATH_VERSION.
No contract could pass both. Every engineer would have to suppress one.
The proof that your rules agree with the standard is one contract that lints clean under all of them in CI. Your own service works. So does dedo’s starter service.
dedo itself never ships a HOUSE_* rule. The prefix is yours, and an upgrade will never collide with it.
Where house rules run
- Native plugin. House rules run here. It reads
rules.ymlfrom disk. - BSR WebAssembly plugin. It cannot read files, so a repository without Go does not run house rules.
dedo initwarns you. Add ago.modand run it again to switch to the native plugin. 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.
Your reviewers spend their attention on the design.
Next
The last 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.
- Part 5: Multi-Tenant SaaS Auth: the tenant boundary, declared in the proto and enforced on every transport.

Leave a Reply