Skip to content

gRPC and protobuf descriptors

A gRPC call needs the provider’s contract. This page covers how that contract gets into the platform, and exactly how protobuf types map onto the message a flow works with. For the step keys that make the call, see gRPC calls.

The platform works in both directions: a flow can call a gRPC method, and a flow can be one. Both use the same descriptors and the same type mapping; only the frontmatter differs. See Exposing a service for the inbound half.

NexusFabric does not generate code from .proto files. It loads a compiled descriptor set at run time and reads the shape of each message from it. A contract change is a deploy, not a rebuild of the platform.

Producing a descriptor set

Use your own protoc:

Terminal window
$ protoc --include_imports -o orders.binpb orders.proto

--include_imports is not optional. Without it, type references in your file are left dangling and the descriptor cannot be linked. The platform refuses such a file at deploy time rather than failing on every call.

If your .proto imports well-known types, protoc needs its include path:

Terminal window
$ protoc --include_imports \
-I . -I /usr/local/include \
-o orders.binpb orders.proto

Deploying a descriptor

Terminal window
$ nexus proto deploy orders.binpb \
--tenant acme --name orders --version 1.4.0 --latest
Flag Required Meaning
<path> yes The .binpb file protoc produced
--tenant yes Tenant the descriptor belongs to
--name yes The name flows refer to in proto:
--version yes Contract version. Publishing the same version twice is an error
--latest no Also point latest at this version
--db no Registry path. Default ~/.nexus/registry.db

A version is a contract, not a mutable slot. Deploying 1.4.0 a second time fails, so what a flow sent yesterday is what it sends today.

--latest is separate on purpose. Moving latest changes what every flow of that tenant sends on the wire, at once. Publishing a version to try it out must not repoint production:

Terminal window
$ nexus proto deploy orders.binpb --tenant acme --name orders --version 1.5.0
$ # flows pinned to proto_version: 1.5.0 now use it; everything on `latest` is untouched
$ nexus proto deploy orders.binpb --tenant acme --name orders --version 1.5.0 --latest
error: version 1.5.0 already exists

To move latest to an already-published version, deploy the next version with --latest, or pin flows explicitly with proto_version:.

Inspecting what you deployed

Terminal window
$ nexus proto list --tenant acme
orders 1.4.0 latest
orders 1.5.0
Terminal window
$ nexus proto show --tenant acme --name orders --version 1.5.0
acme.orders.v1.OrderService
GetOrder unary
SearchOrders server streaming
ImportOrders client streaming

--version defaults to latest. Run show before writing a flow: it gives you the exact service name and method name the step keys need, and it tells you which methods are streaming.

Type mapping

Fields are matched by name, not by number. A message field named orderId fills a protobuf field named orderId.

Scalars

Protobuf Message value Notes
string string
bool boolean Accepts true/false; a string "true" is accepted as a map key only
int32, sint32, sfixed32 number
uint32, fixed32 number
float number
double number
int64, sint64, sfixed64 string See below
uint64, fixed64 string See below
bytes base64 string Standard alphabet, with padding
enum string Value name; see below

64-bit integers travel as decimal strings, in both directions. A JSON number cannot hold every int64 exactly — above 2^53 it silently loses precision, so an identifier read from one system and written to another would come out different. Writing them as strings makes that impossible.

{ "orderId": "9007199254740993", "quantity": 12 }

On the way in, a plain number is also accepted for a 64-bit field, but only when it is integral and inside the range a double represents exactly. Above that it is refused rather than rounded. On the way out, a 64-bit field is always a string, even when the value is small — so write your expressions accordingly:

{{ if $.orderId == "42" }}...{{ end }}

bytes is base64. Both directions. Note that base64 inflates by roughly a third, which matters when you size max_recv_bytes — and max_recv_bytes starts from the message ceiling in force at the step, so raising max_message_bytes: once raises it too. See Size limits.

Enums are names. Outbound, you may write either the value name or its number as a string. Inbound, you get the name — unless the descriptor you deployed does not know that number, in which case you get the number as a string. A provider adding an enum value does not break a running flow.

Composite types

Protobuf Message value
message object
repeated T array of T
map<K, V> object; keys are stringified per the key type
oneof the set branch appears as an ordinary field
optional T (explicit presence) key present or absent
T (implicit presence, proto3) key always present, zero value when unset

A oneof with two branches set is an error, not a silent pick. Choosing for you would send something you did not write.

Explicit presence is honoured. An optional string that is not set comes back as an absent key, not null, so is_empty($.middleName) and a check on presence are different questions. A field with implicit presence always appears, carrying its zero value — "", 0, false.

Well-known types

Type Message value
google.protobuf.Timestamp RFC 3339 string, sub-second precision preserved
google.protobuf.Empty empty object {}
{ "placedAt": "2026-08-10T09:14:22.481Z" }

Other well-known types are treated as ordinary messages: you get their field structure.

Unknown fields

The two directions are deliberately asymmetric.

A key in your message with no counterpart in the request type is an error. A typo in a field name would otherwise mean silently sending an incomplete request and getting an answer computed from it.

A field in the response with no counterpart in your deployed descriptor is ignored. That is what lets a provider add a field to a response without every flow failing until you redeploy.

When a provider changes the contract

Change What you do
Field numbers renumbered, names unchanged Deploy the new descriptor. Flows are untouched — matching is by name
A field added to a response Nothing. It is ignored until you deploy a descriptor that knows it
A field added to a request, optional Nothing, until you want to send it
A field renamed Deploy the new descriptor and update the flows that name it
An enum value added Nothing. It arrives as its number until you redeploy
A method added Deploy the new descriptor; the method is then callable

Exposing a service

A flow can also be a gRPC method. Four frontmatter keys say which one:

inbound: grpc # the only accepted value
proto: [email protected] # name@version, same form as on egress
service: acme.orders.OrderService
method: SubmitOrder

All four or none. A partial declaration, in either direction, is refused at compile time — so nexus validate catches it, not the server at boot.

service: and method: mean opposite things in frontmatter and on a step. On a step they belong to grpc_egress and name the service the flow calls. In frontmatter they name the service the flow answers.

Opening the door

Terminal window
$ nexus serve --port 9090 --grpc-port 9091

A separate port, and h2c — plaintext. TLS terminates in front of the platform, the same arrangement as the HTTP door. Without the flag nothing listens; a flow deployed with inbound: on a server started without a gRPC port leaves a WARN naming it, rather than failing quietly.

Both doors announce the address they actually bound:

NexusFabric server listening on 127.0.0.1:9090
NexusFabric gRPC ingress listening on 127.0.0.1:9091

--grpc-port 0 asks the system for a free port, exactly as --port 0 does on the HTTP door, and that second line is the only place the chosen port can be read. Useful in tests, not in an installation.

The routing table (service, method) → flow is built at startup, from the latest artifacts. Two consequences worth planning around: a flow deployed after the server started is not routed until you restart, and a withdrawn one answers exactly like a method that never existed.

What a call goes through

An unrouted method answers unauthenticated, not not_found. Which services an installation exposes is a catalogue, and it is not disclosed — not even to a caller holding a valid key for another tenant.

A routed call passes the same gate as the HTTP routes: the credential travels in authorization: Bearer <nxk_...> metadata, exactly as in the HTTP header, and it is checked before the body is decoded. The tenant comes from the routed flow, never from the caller — a caller names a method, not a tenant.

From there the call goes through the same delivery bracket as /run, so deduplication, correlation and response memoisation work on this door with nothing written for it.

How a flow’s outcome reaches the caller

A flow error comes back as a Status, never as a successful message with an error inside it:

The flow gRPC answers The message the caller gets
a validate fence rejected the message, either kind invalid_argument the rule’s own message, yours, verbatim
dedup_key: produced nothing from this message invalid_argument which field was empty, never its value
a correlation key could not be formed, or its state is too large invalid_argument the phrase that family has on HTTP
a reply arrived with nothing open to receive it failed_precondition the same
a reply arrived twice and the first was accepted already_exists the same
an exchange is already in flight for this key already_exists “an exchange is already in flight for this key”
an http_egress or grpc_egress call failed unavailable “a downstream call did not succeed”
the platform cannot honour dedup: or correlation: right now unavailable “flow state temporarily unavailable”
ran past flow_max_duration_secs, or a parallel branch timed out deadline_exceeded “the flow did not finish within its time limit”
over the rate limit resource_exhausted “rate limit exceeded”, with retry-after in the metadata
anything else — a defect in the flow, a handler that failed internal “flow execution failed”, and nothing more

This table is not the HTTP status translated, and cannot be. The platform does have one error-to-status mapping that every door reads, but the two vocabularies are not in bijection and the direction that loses is this one: HTTP answers 422 both for a validation rule and for all four correlation refusals, which split three ways here, and 500 covers eight different flow defects. So this door has its own table over the same typed errors, and where the two agree — a failed call out is 502 on HTTP and unavailable here — they agree deliberately.

The number the platform records for a gRPC call in the L1 log does come from that shared mapping, so a call that answers unavailable is logged as 502. The code on the wire and the number in the log answer different questions and are read from different places.

A step that declares response_status: steers the verdict, and so does its deprecated spelling fault_status: inside a ## Fault: section. That path is an HTTP-status translation, because a declared status is a number the flow author wrote, not a typed error: 401 → unauthenticated, 403 → permission_denied, 404 → not_found, 409 → already_exists, 429 → resource_exhausted, 502–504 → unavailable, any other 4xx → invalid_argument, any other 5xx → internal. This holds on the success path too — a flow that runs to completion but declares a non-2xx status answers with that verdict and its body is dropped — see What this door does not do. A declared 2xx changes nothing.

Response headers are a different story: response_header_<name>: on a step, and the response_headers: pass-through list, reach nowhere on this door. There are no HTTP response headers to set.

Message size

max_message_bytes reaches tonic’s decoder before decoding, at both layers of the ceiling (platform default, then the flow’s own key). A body over it never reaches the flow and leaves a delivery_refused event on channel grpc, carrying the reason. See Limits.

What this door does not do

  • Streaming methods answer unimplemented. Client-streaming, server-streaming and bidirectional are all out of scope for ingress — declared, not deferred. Server streaming on the egress side works.
  • A successful ## Fault: handler cannot deliver its body. A unary call carries either one response message under OK, or a Status with no message. ADR-025’s rule is that the verdict must not be OK when the flow failed, so the verdict wins and the composed body is dropped. A property of the protocol, not an omission — write response_status: 200 if the body is the answer.
  • ctx.headers is empty. Request metadata is not exposed to the flow; the variable holds the same empty object the queue and cron doors carry.
  • Reflection is off, deliberately. A client needs the descriptor you deployed, which it already has to have in order to build the request.

Not supported

Client-streaming and bidirectional methods are refused on both sides of the platform. Validation rules expressed as protobuf annotations are not read; write them in a validate fence instead.