[dedo] 4: House Rules, Your Rules as Lint

Raveesh Agarwal

·

·

11–17 minutes
A dedo.yaml with a HOUSE_REST_ROOT CEL check in its lint section, 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:

# 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:

  • 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.
  • overrides names the rules this one replaces. More on that below.
  • final locks 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 the lint section of a repository’s dedo.yaml turns it off there.
  • // buf:lint:ignore HOUSE_REST_ROOT suppresses 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:

onVariables
filename, proto_package, imports
servicename, full_name, file, proto_package, methods, resources, roots, title
methodname, full_name, file, proto_package, service, input, output, client_streaming, server_streaming, idempotency, http_verb, http_path, http_bindings, 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, 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:

  1. A platform team keeps api-standards with one rules.yml. It has the same imports, lint and breaking as a dedo.yaml, but no use: only the service chooses which of dedo’s rules run.
  2. They tag it: v1, v2, v3.
  3. Each service imports it at a tag.
  4. A service adds rules of its own, opts out of rules by ID with disable, imported ones or dedo’s, and replaces them with overrides.

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 tools fetches every git import at its ref, and every url import checked against its sha256, into .tools/rules/, then checks every override (below). 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 or a sha256 names one version. To move to new shared rules, change it, and that change goes through review like any other.
dedo.yaml imports shared lint and breaking rules from a local path, a git tag and a URL pinned by sha256; dedo tools fetches them; its lint section feeds buf lint, offline on every keystroke and in CI, and its breaking section feeds buf breaking, against an earlier version.

The order of authority

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

Order of authority, from the top: REPO (your service's dedo.yaml), HOUSE (imported rules; a file outranks what it imports), DEDO, AIP, ZALANDO. A rule overrides only rules below it, and only the ones it names. Final rules cannot be overridden.

Each layer outranks the ones below it:

  1. Your service’s own dedo.yaml. It knows its own service best.
  2. The house rules it imports. A rules file outranks the files it imports, so the commerce file outranks the company file it builds on.
  3. DEDO, which makes one proto serve every transport.
  4. AIP, because the contract is a protobuf API.
  5. 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, disable or 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 disable of 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.yaml from disk.
  • BSR WebAssembly plugin. It cannot read files, so it runs dedo’s default rule sets and no house rules. dedo init replaces it with the native plugin, which needs Go on the machine and in CI, but no go.mod. A platform team that must lint without Go can build its own with dedo.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

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

7 responses to “[dedo] 4: House Rules, Your Rules as Lint”

  1. […] Part 4 covers house rules in depth, and rules.example.yml uses every key: several shared imports, opt-outs, overrides and a final rule. […]

  2. […] Part 4: House Rules, Your Conventions as Lint: your organisation’s conventions as CEL checks, shared from one repository. […]

  3. […] Part 4: House Rules, Your Conventions as Lint: your organisation’s conventions as CEL checks, shared from one repository. […]

  4. […] Part 4: House Rules, Your Conventions as Lint: your organisation’s conventions as CEL checks, shared from one repository. […]

  5. […] Part 4: House Rules, Your Conventions as Lint: your organisation’s conventions as CEL checks, shared from one repository. […]

  6. […] have our own conventions.” Wonderful. So does everyone. That’s what house rules are for. Your rules, your agent annexure, your overrides of dedo’s rules and the rules you […]

  7. […] are house rules, the same CEL checks as in part 4, with one new name: against. It holds the same variables as the […]

Leave a Reply

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