Skip to content

Commit 7fb9298

Browse files
authored
chore(adr): record protobuf rpc over nats micro binding decision (#471)
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent d840eb8 commit 7fb9298

2 files changed

Lines changed: 193 additions & 0 deletions

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
---
2+
number: "0016"
3+
slug: protobuf-rpc-over-nats-micro-binding
4+
status: accepted
5+
date: 2026-07-04
6+
---
7+
8+
# ADR 0016: Protocol Buffers RPC over NATS micro Binding
9+
10+
## Context
11+
12+
[ADR 0009](./0009-protocol-buffers-wire-contracts.md) makes Protocol Buffers the
13+
wire contract for first-party owned service and message contracts.
14+
[ADR 0011](./0011-jsonrpc-over-nats-binding.md) defines how the JSON-RPC family
15+
(ACP, MCP, A2A) is carried over the NATS backbone, but it is explicitly scoped to
16+
JSON-RPC. There is no equivalent rule for how a first-party **protobuf** service
17+
is carried over NATS, so any such service would have to invent its own subject
18+
scheme, success/error signaling, and discovery. ADR 0011 exists because three
19+
JSON-RPC mappings diverged and lost errors; a protobuf service on NATS is exposed
20+
to the same class of drift with no governing rule.
21+
22+
`trogon-proto` already ships
23+
[`trogon.nats.micro.v1alpha1`](https://github.com/TrogonStack/trogon-proto/blob/main/proto/trogon/nats/micro/v1alpha1/options.proto),
24+
a protocol-neutral set of options that attach NATS Services metadata to any
25+
protobuf `service` (`ServiceOptions`: `version`, `description`, `metadata`,
26+
`content_type`) and `rpc` method (`MethodOptions`: endpoint `metadata`). Its own
27+
example is a generic `OrderService`. This shared mechanism has no ADR governing
28+
what it means on the wire.
29+
30+
NATS Services (NATS micro,
31+
[ADR-32](https://github.com/nats-io/nats-architecture-and-design/blob/main/adr/ADR-32.md))
32+
already provides the RPC substrate a protobuf service needs: request/reply,
33+
queue-group load balancing, discovery (`$SRV.PING|INFO|STATS`), versioning,
34+
per-endpoint stats, and a standard error channel (`Nats-Service-Error`,
35+
`Nats-Service-Error-Code`). This ADR binds annotated protobuf services to that
36+
substrate as one shared rule, the protobuf-family sibling of ADR 0011.
37+
38+
This is a naming binding, not a protocol tunnel. It borrows gRPC's *idiom*
39+
(method-in-path routing, canonical status semantics) so the shape is familiar. It
40+
does not run gRPC: there is no HTTP/2, no gRPC framing, and no gRPC library on the
41+
path. The transport is core NATS; the RPC semantics are NATS micro.
42+
43+
## Decision
44+
45+
### 1. An annotated protobuf service is a NATS micro service
46+
47+
A protobuf `service` carrying `option (trogon.nats.micro.v1alpha1.service)` is
48+
registered as one NATS micro service. Each `rpc` becomes a micro **endpoint**.
49+
`ServiceOptions.version`, `description`, and `metadata` populate the service's
50+
discovery record; `MethodOptions.metadata` populates the endpoint's. The proto
51+
`service` is the canonical wire contract, and the registered micro service must
52+
expose exactly its methods as endpoints.
53+
54+
### 2. The subject is derived from the service and the method
55+
56+
An endpoint's subject is `<group>.<EndpointName>`, where the group is a configured
57+
prefix plus the service name and the endpoint name is the `rpc` method name.
58+
59+
| Field | NATS location | Rule |
60+
| --- | --- | --- |
61+
| Service | subject group | `<subject_prefix>.<service-name>` |
62+
| Method | endpoint subject suffix | The `rpc` method name. Mirrors gRPC `/package.Service/Method` |
63+
| Request | request payload | The request message bytes |
64+
| Reply | reply payload | The response message bytes |
65+
66+
The request **message type** is a property of the endpoint (held from the service
67+
descriptor), never recovered by parsing the subject. Because micro subscribes only
68+
to declared endpoints, routing is structural: an unknown method has no
69+
subscription and is unroutable at the client.
70+
71+
### 3. Success and error discriminate on the micro error header
72+
73+
A reply is an error if, and only if, `Nats-Service-Error-Code` is present. That
74+
header is also the decode rule for the body: when it is absent the body is the
75+
method's response message; when it is present the body is one **complete**
76+
serialized
77+
[`google.rpc.Status`](https://github.com/googleapis/googleapis/blob/master/google/rpc/status.proto)
78+
(`code`, `message`, and `details` together), encoded per the negotiated
79+
`Content-Type` under the same rule as any other payload (section 4). The body is
80+
never a fragment of `Status`: `details` is a `repeated google.protobuf.Any` field
81+
*inside* the one `Status` message and is never serialized on its own.
82+
83+
The micro error headers are copies of two `Status` fields, not replacements for
84+
them; the fields stay in the body so the payload is self-contained:
85+
86+
| `google.rpc.Status` field | Header mirror | Rule |
87+
| --- | --- | --- |
88+
| `code` | `Nats-Service-Error-Code` | The canonical [`google.rpc.Code`](https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto) numeric value; never `OK` (0) |
89+
| `message` | `Nats-Service-Error` | The developer-facing description |
90+
| `details` | none | [`google.rpc` error details](https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto) (`ErrorInfo`, `BadRequest`, `RetryInfo`, `QuotaFailure`, ...); read only by decoding the body |
91+
92+
The duplication is deliberate. The headers exist so middleware can route on the
93+
subject and meter on the error header without decoding the body, exactly as
94+
ADR 0011 requires for `Jsonrpc-Error-Code`; the body exists so a client, a log
95+
pipeline, or a dead-letter inspector holding only the payload still has the whole
96+
error. The shared binding layer builds the headers and the body from one `Status`
97+
value, so they agree by construction; on any disagreement the headers are
98+
authoritative. Error codes use the canonical `google.rpc.Code` space; HTTP
99+
familiarity comes from the canonical-to-HTTP mapping documented in `code.proto`,
100+
not from a second vocabulary on the wire.
101+
102+
The micro error channel signals **service faults**: malformed input, timeouts,
103+
and handler failures, which micro also counts in `num_errors`. A defined
104+
*application-level negative outcome that still executed successfully* (for example
105+
a validation result the caller acts on) belongs in the typed response body, not on
106+
the error channel, so that `num_errors` stays a health signal rather than a
107+
business-outcome counter. Each specializing ADR defines its own outcome taxonomy
108+
over this rule.
109+
110+
### 4. Content type is negotiated from the service option
111+
112+
`ServiceOptions.content_type` governs the request and reply `Content-Type` header.
113+
`CONTENT_TYPE_UNSPECIFIED` accepts both `application/protobuf` and
114+
`application/json` on the same endpoints; `CONTENT_TYPE_PROTOBUF` or
115+
`CONTENT_TYPE_JSON` restrict it. JSON is `google.protobuf.Any`-style canonical
116+
JSON handled at the host edge (transcoded to and from protobuf via a
117+
`FileDescriptorSet`); the authoritative contract remains protobuf, consistent with
118+
[ADR 0009](./0009-protocol-buffers-wire-contracts.md).
119+
120+
### 5. Discovery, versioning, and scaling come from micro
121+
122+
Discovery (`$SRV.INFO/PING/STATS` and their `.<name>` and `.<name>.<id>`
123+
variants), version reporting, and per-endpoint stats are provided by NATS micro,
124+
so a service does not hand-roll a routing inventory. Endpoints subscribe on the
125+
micro default queue group `q` so replicas share load; the queue group is
126+
overridable at the service, group, or endpoint level.
127+
128+
### 6. The binding is a shared layer
129+
130+
The subject derivation, content-type negotiation, error-channel mapping, and
131+
discovery wiring are one shared component, not reimplemented per service. Domain
132+
crates inject only what is domain-specific: the typed request and response
133+
messages, the subject prefix, and any outcome taxonomy layered on the error rule.
134+
135+
## Invariants
136+
137+
- A reply is a service error if, and only if, `Nats-Service-Error-Code` is
138+
present. Never infer success or failure by structurally deserializing the body.
139+
- An error reply's body is one complete `google.rpc.Status`, never a fragment of
140+
it, whose `code` and `message` match the micro error headers; the headers are
141+
authoritative on disagreement. The code space is canonical `google.rpc.Code`,
142+
never `OK` (0) on an error reply.
143+
- The method is carried by the subject; the request message type is a property of
144+
the endpoint, never parsed from the subject.
145+
- The registered micro service exposes exactly the annotated `service`'s methods
146+
as endpoints.
147+
- `Content-Type` is authoritative for payload encoding and is constrained by
148+
`ServiceOptions.content_type`.
149+
- The authoritative message contract is protobuf; JSON is an edge encoding.
150+
151+
## Alternatives Considered
152+
153+
### Carry protobuf services over the JSON-RPC binding (ADR 0011)
154+
155+
Rejected: protobuf services are not JSON-RPC, so 0011's codec (envelope fields,
156+
`Jsonrpc-*` headers, `id` semantics) does not apply. ADR 0011 is scoped to the
157+
JSON-RPC family by its own terms.
158+
159+
### Let each protobuf service invent its own NATS mapping
160+
161+
Rejected for the reason ADR 0011 exists: independent mappings diverge, disagree on
162+
success-versus-error, and lose structured errors. A single shared rule over the
163+
shared options proto prevents that.
164+
165+
### Run real gRPC (HTTP/2) tunneled over NATS
166+
167+
Rejected: NATS is the transport under [ADR 0003](./0003-ai-protocol-transport-taxonomy.md),
168+
and NATS micro already supplies request/reply, discovery, and error semantics.
169+
Tunneling a second transport inside NATS adds framing and a dependency for no
170+
gain. gRPC is referenced only as a naming idiom.
171+
172+
## Consequences
173+
174+
- First-party protobuf services get one discoverable, versioned, load-balanced
175+
NATS surface without hand-rolling routing or error signaling.
176+
- Routing and health metering happen on the subject and the standard micro error
177+
header without decoding protobuf payloads.
178+
- The `trogon.nats.micro.v1alpha1` options proto now has a governing wire
179+
contract; annotating a service is sufficient to define its NATS binding.
180+
- Future first-party service APIs inherit this binding; specializations (for
181+
example decider commands) are defined in their own ADRs.
182+
183+
## References
184+
185+
- [`trogon.nats.micro.v1alpha1` options](https://github.com/TrogonStack/trogon-proto/blob/main/proto/trogon/nats/micro/v1alpha1/options.proto)
186+
- [NATS Service API (ADR-32)](https://github.com/nats-io/nats-architecture-and-design/blob/main/adr/ADR-32.md)
187+
- [`google.rpc.Status`](https://github.com/googleapis/googleapis/blob/master/google/rpc/status.proto),
188+
[`google.rpc.Code`](https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto),
189+
and [`google.rpc` error details](https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto)
190+
- [ADR 0003: AI Protocol Transport Taxonomy](./0003-ai-protocol-transport-taxonomy.md)
191+
- [ADR 0009: Protocol Buffers Wire Contracts](./0009-protocol-buffers-wire-contracts.md)
192+
- [ADR 0011: JSON-RPC over NATS Binding](./0011-jsonrpc-over-nats-binding.md)

docs/adr/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ future implementation work.
2020
- [ADR 0013: Origin Stream Sequence Header](./0013-origin-stream-sequence-header.md)
2121
- [ADR 0014: Command and Query Naming](./0014-command-and-query-naming.md)
2222
- [ADR 0015: Rust TLS Library](./0015-rust-tls-library.md)
23+
- [ADR 0016: Protocol Buffers RPC over NATS micro Binding](./0016-protobuf-rpc-over-nats-micro-binding.md)

0 commit comments

Comments
 (0)