·

[dedo] 3: Adding dedo to an Existing Service

Raveesh Agarwal

·

·

9–14 minutes
Five stacked layers: annotations, lint, generators, runtime and agent guidance.

Most of us work on services that already exist.

  • You have a Go service with protos.
  • It may already use Buf, with remote plugins and managed mode.
  • It has callers who depend on it today.

dedo is built for that service. It installs in layers. Each layer works on its own.

You can stop after any layer and still be better off than before.

This post walks through the layers in order. Then it shows the one command that does them all.

The layers

LayerWhat you addWhat you get
1. AnnotationsThe dedo.v1 options moduleA place in the proto for scopes, errors, cache, deprecation, tool text
2. Lintbuf-plugin-dedo-lintFindings in the editor and in CI, and breaking checks against main
3. Generatorsprotoc-gen-dedo-mcp, protoc-gen-dedo-openapiMCP tools and REST docs from the proto
4. Runtimepolicy, interceptor, flowid, Vanguard, mcpserverScopes enforced, headers set, every transport on one server
5. Agent guidanceA section in AGENTS.md or CLAUDE.mdCoding agents that follow the same rules

Before you start:

  • You need Go 1.26 or later and the Buf CLI.
  • buf.yaml must be version v2. If yours is v1, run buf config migrate first.

Layer 1: Annotations

Add the options module and the two modules it builds on:

# buf.yaml
version: v2
deps:
  - buf.build/googleapis/googleapis
  - buf.build/bufbuild/protovalidate
  - buf.build/r-a/dedo:v0.32.2Code language: YAML (yaml)
buf dep updateCode language: Bash (bash)

Pin the label to the dedo release you use. The options, the lint rules that read them and the runtime that enforces them then agree. The label also names the lint plugin’s release.

Then import the options where you need them:

import "dedo/v1/options.proto";Code language: Protocol Buffers (protobuf)

Nothing changes at runtime yet. Your protos still compile.

You have a vocabulary.

Layer 2: Lint

The lint plugin is not pinned in go.mod. The label from Layer 1 names its release. dedo tools installs it at that release into $GOBIN and copies it into .tools/:

dedo tools                       # installs the plugin at buf.yaml's release, copies it into .tools/
printf '.tools/\n' >> .gitignoreCode language: Bash (bash)

The plugin runs as a native binary. Buf starts it about a dozen times per lint, and the editor needs it fast.

Naming its release in buf.yaml means every laptop and CI run the same version.

Run dedo tools again after a fresh clone, an upgrade, or a change to the rules you choose below. dedo install only puts the plugin in $GOBIN. Without the dedo command, go install github.com/raveesh-me/dedo/lint/cmd/[email protected] and a copy into .tools/ do the same as dedo tools.

List the plugin and its two categories in buf.yaml: DEDO for its lint rules and DEDO_BREAKING for its breaking rules. Choose its rules in the lint and breaking sections of dedo.yaml next to it:

# buf.yaml
lint:
  use:
    - STANDARD
    - COMMENTS
    - DEDO       # dedo's lint rules
breaking:
  use:
    - FILE
    - DEDO_BREAKING  # dedo's breaking rules
plugins:
  - plugin: .tools/buf-plugin-dedo-lint

# dedo.yaml
lint:
  use:           # the rule sets that run
    - AIP_NOLANG
    - ZALANDO
    - DEDO
    - HOUSE
# breaking: without use, DEDO and HOUSE runCode language: YAML (yaml)

buf.yaml names the plugin once; dedo.yaml says which of its rules run, and which this service opts out of. The plugin skips a few AIP rules on purpose by itself, and RULES.md gives the reason for each.

Now run it:

make check     # buf format, buf lint, buf breaking against main, dedo modulesCode language: Bash (bash)

The breaking check runs Buf’s FILE rules and dedo’s ten breaking rules. They catch what FILE cannot see: an RPC that drops a scope, a REST path or an event type, or a field that becomes required. dedo modules checks that each module’s code stays in internal/<module>/, with no cycles between modules.

Expect a long list the first time.

That list is the most useful thing a migration produces.

It is the distance between your API today and the standard, one missing declaration per line. You do not have to clear it in one pull request.

Working through the list

Take one service at a time. For each RPC, ask the questions the lint asks:

  • Who owns this service, and who may call it?
  • Which scope grants access?
  • Which errors can it return?
  • Is it safe to retry? Is it safe to cache?
  • How would an agent describe it?

Some answers will surprise you.

Most teams have never written down which errors an RPC returns. The first time they do, they find two that no client handles.

When a rule does not fit, say so in the file:

// buf:lint:ignore DEDO_HTTP_PATH_VERSION
// Legacy partners call /products directly. Retired with v2 in Q3.Code language: Protocol Buffers (protobuf)

A suppression with a reason is a decision a reviewer can agree with, or question.

Layer 3: Generators

Pin the generators as Go tools:

go get -tool google.golang.org/protobuf/cmd/protoc-gen-go
go get -tool connectrpc.com/connect/v2/cmd/[email protected]
go get -tool github.com/raveesh-me/dedo/mcp/cmd/[email protected]
go get -tool github.com/raveesh-me/dedo/openapi/cmd/[email protected]Code language: Bash (bash)

Add dedo’s two generators to buf.gen.yaml, next to yours:

plugins:
  # ...your existing generators stay as they are
  - local: ["go", "tool", "protoc-gen-dedo-mcp"]
    out: gen/go
    opt: paths=source_relative
  - local: ["go", "tool", "protoc-gen-dedo-openapi"]
    out: docs
    strategy: all
    opt:
      - title=Your API
      - server=https://api.example.comCode language: YAML (yaml)

If you use managed mode, keep dedo’s Go package out of your prefix. The last matching override wins:

managed:
  enabled: true
  override:
    - file_option: go_package_prefix
      value: github.com/you/your-service/gen/go
    - file_option: go_package_prefix
      module: buf.build/r-a/dedo
      value: github.com/raveesh-me/dedo/gen/goCode language: YAML (yaml)

Run buf generate. You now have:

  • <Service>MCPTools() for every service.
  • A docs/openapi.json in OpenAPI 3.2. When the API spans several proto packages, it nests each service under its package, so the reference reads like the domain.

Layer 4: Runtime

This is the layer that touches production, so it deserves care.

dedo runs on connect-go v2. One connect.Server holds the interceptor chain. Every transport mounts on it.

table, err := policy.Build(productsv1connect.ProductServiceName)
if err != nil {
    return nil, err // a missing declaration stops the service here
}
guard, err := interceptor.New(interceptor.Config{
    Table:         table,
    Scopes:        auth.ScopesFromContext,
    EnforceScopes: false, // audit mode first
    Log:           log,
})
if err != nil {
    return nil, err
}

server := connect.NewServer(
    flowid.Interceptor(),
    auth.Interceptor(),
    guard,
    validate.NewServerInterceptor(),
)
productsv1connect.RegisterProductServiceHandler(server, products.NewProductService()) // internal/products

mux := http.NewServeMux()
connecthttp.Mount(mux, server) // Connect, gRPC, gRPC-Web
if err := vanguard.Mount(mux, server); err != nil { // REST
    return nil, err
}Code language: Go (go)
Connect and gRPC, REST and MCP all mount on one connect.Server whose interceptor chain runs flowid, auth, the dedo guard and validate before your handler.

Three things matter here.

Auth belongs in an interceptor

Your existing service may verify tokens in HTTP middleware. Move that into a connect.ServerInterceptor.

MCP tool calls reach the server in-process, through connectinprocess. They never pass through HTTP middleware.

An auth check in HTTP middleware would let every agent through.

The interceptor’s job is small:

  1. Verify the token.
  2. Put the caller’s scopes in the context.
  3. Let dedo’s guard read them from there.

Audit before you enforce

EnforceScopes: false is audit mode.

The guard checks every call against the declared scopes. When a caller lacks one, it logs a warning with the procedure and the flow ID. Then it lets the call through.

Ship that, and watch the logs for a release cycle. Every warning is one of three things:

  1. A scope you named wrong in the proto.
  2. A token issuer that grants the wrong scopes.
  3. A caller that should not have access.

Fix the first two and decide about the third. When the logs are quiet, set EnforceScopes: true.

You learn what enforcement would do before it does it.

Fail closed at startup

Keep the policy-table test dedo init writes:

func TestPolicyTable(t *testing.T) {
    if _, err := policy.Build(productsv1connect.ProductServiceName); err != nil {
        t.Fatal(err)
    }
}Code language: Go (go)

Lint already requires scopes. The runtime checks again, because a missing scope at runtime means an open endpoint.

This test moves that failure from deploy to go test.

MCP and docs

Once the runtime is in, the last two doors are a few lines each:

client := connect.NewClient(connectinprocess.New(server))
tools := mcp.NewServer(&mcp.Implementation{Name: "products", Version: "v1"}, nil)
if err := mcpserver.AddTools(tools, client, productsv1.ProductServiceMCPTools()...); err != nil {
    return nil, err
}

mux.Handle("GET /openapi.json", docs.Spec())
mux.Handle("GET /docs", docs.Page())
mux.Handle("/mcp", flowid.Middleware(http.NewCrossOriginProtection().Handler(
    mcp.NewStreamableHTTPHandler(
        func(*http.Request) *mcp.Server { return tools },
        &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}))))Code language: Go (go)

Every tool call goes over Connect through the same server, with the same auth, scopes and validation.

/docs is Scalar, pinned by an integrity hash, with its hosted extras and telemetry off. make docs serves the reference alone on 127.0.0.1:8099, and make docs-tunnel shares it at a public trycloudflare.com URL until Ctrl-C. Neither shares the service itself: development tokens are often public. make docs-static writes the reference as one HTML file that opens from disk with no network, for a plane or an email; it inlines the OpenAPI document and the page’s script, checked against its integrity hash. dedo upgrade leaves an existing docs/index.html alone. When it differs from the release’s template, other than in its <title>, dedo upgrade says so and prints the command that refreshes it, dedo templates docs/index.html > docs/index.html. Then set its <title> again.

A tool call carries only the MCP request’s Authorization and X-Flow-ID headers. If your interceptors read another, such as an API version, forward it by name:

if err := mcpserver.AddToolsWith(tools,
    mcpserver.Options{Client: client, ForwardHeaders: []string{"Version"}},
    productsv1.ProductServiceMCPTools()...); err != nil {
    return nil, err
}Code language: Go (go)

Events, when a command declares them

Most services publish no events. When a command declares one in emits, its handler publishes through the events package. The bus is a driver you inject in main.go, in the pattern of database/sql/driver. dedo ships no broker driver, only one for tests.

An event carries the resource’s name and no payload. If the bus does not take it before the deadline, Publish returns UNAVAILABLE with EVENT_NOT_PUBLISHED, so the caller retries. Lint checks that the RPC declares that error.

Layer 5: Agent guidance

Coding agents write a lot of protos now. Teach them the rules too:

dedo init adds dedo’s guidance to AGENTS.md and CLAUDE.md, between <!-- dedo:begin guidance … --> and <!-- dedo:end guidance --> lines. A domains block follows it, mapping domain-driven design onto the API: the repository is the bounded context, a proto package a module, a service an aggregate. It also says where each module’s code and tables live, and how a service announces domain events, outward only. dedo upgrade rewrites both blocks, so keep your own notes outside them.

Your organisation’s own guidance goes in dedo.yaml‘s annexure. dedo adds it after its own, and where the two conflict, the annexure wins.

Or: one command

Everything above is what dedo init does:

go install github.com/raveesh-me/dedo/cmd/dedo@latest
dedo initCode language: Bash (bash)

It adds what is missing and keeps what is there. Your deps, plugins and comments stay, and Buf’s own categories and exceptions. A buf.yaml that chose dedo’s rules itself, as releases before v0.15.0 did, has that choice moved into dedo.yaml. So does a rules.yml from before v0.20.0. Running it twice changes nothing.

It reads your repository first:

Your repositoryWhat dedo init does
Go module with protos and buf.gen.yamlKeeps your generators, remote plugins included. Adds dedo’s two, writing to the same out with the same paths= option. Leaves managed mode as it is.
Go module with protos, no buf.gen.yamlWrites one with managed mode and four generators through go tool.
Protos and buf.yaml, no GoAdds the options module and the native lint plugin, in place of the BSR one. Go installs the plugin, but no go.mod is added.
Go module, no protosWrites a starter service that passes lint.

A few more details:

  • It writes main.go, and a handler stub for each service in its module’s package, internal/<module>/, only when you have none. When you have a main.go, it prints dedo’s beside yours to compare.
  • It names the services that declare nothing for dedo yet. Until they do, the lint lists what each RPC needs, and the policy-table test fails on purpose.
  • dedo init -offline edits the files and prints the go, buf and make commands, so you can see what it will run first.

Staying current

dedo update     # installs the latest dedo
dedo upgrade    # moves this repository to itCode language: Bash (bash)

dedo upgrade moves four things together, as one release:

  • the options module;
  • the lint plugin;
  • the generators;
  • the runtime.

Then it refreshes buf.lock, copies the plugin at the new release into .tools/, rewrites the agent guidance and regenerates the code. A dedo.yaml with the old single rules section has it renamed lint, and its imports moved to the top of the file. buf.yaml gets DEDO_BREAKING beside FILE.

Coming from connect-go v1? It also runs connect-go’s own connect-go-v2-migrate and prints the new wiring beside yours.

A suggested order

For a team with a live service:

  1. Week 1. Annotations and lint. Make the findings visible. Fix nothing yet.
  2. Weeks 2–3. Declare one service end to end. Clear its findings.
  3. Week 4. Runtime in audit mode. Read the warnings.
  4. One release later. Enforce scopes.
  5. Then. Turn on MCP and docs. They come almost for free by now.

Each step ships on its own, and each one is reversible.

Next

dedo’s rules are a baseline. Your organisation has conventions of its own: domain names, REST roots, field naming.

The next post shows how to write those as lint rules, in a few lines of YAML.


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] 3: Adding dedo to an Existing Service”

  1. […] Part 3: Adding dedo to an Existing Service: one layer at a time, with an audit mode before you enforce. […]

  2. […] Part 3: Adding dedo to an Existing Service: one layer at a time, with an audit mode before you enforce. […]

  3. […] Part 3: Adding dedo to an Existing Service: one layer at a time, with an audit mode before you enforce. […]

  4. […] Part 3: Adding dedo to an Existing Service: one layer at a time, with an audit mode before you enforce. […]

  5. […] Part 3: Adding dedo to an Existing Service: one layer at a time, with an audit mode before you enforce. […]

  6. […] Part 3: Adding dedo to an Existing Service: one layer at a time, with an audit mode before you enforce. […]

  7. […] Part 3: Adding dedo to an Existing Service: one layer at a time, with an audit mode before you enforce. […]

Leave a Reply

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