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:
$ 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:
$ protoc --include_imports \ -I . -I /usr/local/include \ -o orders.binpb orders.protoDeploying a descriptor
$ 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:
$ 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 --latesterror: version 1.5.0 already existsTo move latest to an already-published version, deploy the next version with --latest, or
pin flows explicitly with proto_version:.
Inspecting what you deployed
$ nexus proto list --tenant acmeorders 1.4.0 latestorders 1.5.0$ nexus proto show --tenant acme --name orders --version 1.5.0acme.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 valueproto: orders@1.4.0 # name@version, same form as on egressservice: acme.orders.OrderServicemethod: SubmitOrderAll 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
$ nexus serve --port 9090 --grpc-port 9091A 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:9090NexusFabric 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 underOK, or aStatuswith no message. ADR-025’s rule is that the verdict must not beOKwhen the flow failed, so the verdict wins and the composed body is dropped. A property of the protocol, not an omission — writeresponse_status: 200if the body is the answer. ctx.headersis 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.