[dedo] 4/5: House Rules, Your Conventions as Lint

Raveesh Agarwal

·

·

5–8 minutes
A rules.yml with a HOUSE_REST_ROOT CEL check, importing shared rules from a git tag and a local path.

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:

  • id is the rule’s name. It always starts with HOUSE_.
  • on picks the kind of element the rule checks.
  • check is a CEL expression. It is true when the element passes.
  • message is what the engineer sees when it fails.
  • doc is 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_ROOT suppresses 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:

onVariables
filename, proto_package, imports
servicename, full_name, file, proto_package, methods
methodname, full_name, file, proto_package, service, input, output, client_streaming, server_streaming, idempotency, http_verb, http_path, scopes, errors, deprecated
messagename, full_name, file, proto_package, fields
fieldname, full_name, file, proto_package, message, type, repeated, optional, behaviors, json_name
enumname, full_name, file, proto_package, values
resourcename, 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:

  1. A platform team keeps api-standards with one rules.yml.
  2. They tag it: v1, v2, v3.
  3. Each service imports it at a tag.
  4. 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 tools fetches every git import into .tools/rules/ at its ref. dedo init and dedo upgrade call 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.
rules.yml imports shared rules from a git tag and a local path; dedo tools fetches git imports; buf lint runs offline on every keystroke and in CI.

The order of authority

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

Order of authority: DEDO, then AIP, then ZALANDO, then HOUSE. The higher source wins a conflict.

A house rule may add constraints. It may never contradict a rule above it.

Two examples of rules that would:

  • Demanding snake_case JSON names contradicts DEDO_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.yml from disk.
  • BSR WebAssembly plugin. It cannot read files, so a repository without Go does not run house rules. dedo init warns you. Add a go.mod and 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

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

Leave a Reply

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