Wire Protocol Specification
Wire version 1 — Normative — Reconciled with vgi-rpc Python 0.47.2
This is the web edition of the cross-language contract. The canonical Markdown specification contains additional implementor rationale and worked examples.
1. Overview & Conventions
vgi-rpc is an RPC framework where serialization uses the Apache Arrow IPC Streaming Format.
- All integers are little-endian unless stated otherwise
- All metadata strings are UTF-8 encoded
- Wire protocol version:
"1"(ASCII0x31) - Metadata keys in the
vgi_rpc.*namespace are framework-reserved
| Term | Definition |
|---|---|
| IPC stream | A complete Arrow IPC streaming-format sequence: schema + record batches + EOS marker |
| Batch | An Arrow RecordBatch — zero or more rows conforming to a schema |
| Custom metadata | Per-batch KeyValueMetadata (distinct from schema-level metadata) |
| Zero-row batch | A batch with num_rows == 0. Used for log, error, pointer, and completion signals |
| Data batch | A batch with num_rows > 0, or a zero-row batch without log/error metadata |
2. Arrow IPC Framing
Each logical message exchange uses one or more IPC streams written sequentially on the same byte stream (pipe, TCP socket, HTTP body, etc.).
An IPC stream consists of:
- Schema message — describes the columns and their Arrow types
- Zero or more RecordBatch messages — each optionally carrying per-batch custom metadata
- EOS marker — the 8-byte sequence
0xFF 0xFF 0xFF 0xFF 0x00 0x00 0x00 0x00
Multiple IPC streams are written sequentially on the same underlying byte stream. Each reader opens one stream, reads until EOS, and stops. The next reader picks up immediately after.
3. Metadata Key Reference & Protocol Routing
Request metadata
| Key | Value | Description |
|---|---|---|
| vgi_rpc.method | UTF-8 method name | Target RPC method. Required. |
| vgi_rpc.protocol | UTF-8 protocol name | Which hosted protocol the method belongs to — the routing key. Required, including against a server hosting exactly one protocol. |
| vgi_rpc.request_version | "1" | Wire protocol version. Required. |
| vgi_rpc.protocol_version | Semver | Application protocol version. Required when the Protocol declares one. |
| vgi_rpc.request_id | 16-char hex | Per-request correlation ID. Optional. |
| vgi_rpc.cancel | "1" | Best-effort stream cancellation signal. Optional. |
| traceparent | W3C Trace Context | OpenTelemetry trace propagation. Optional. |
| tracestate | W3C Trace Context | OpenTelemetry vendor state. Optional. |
| vgi_rpc.shm_segment_name | UTF-8 OS name | Shared memory segment name. Optional. |
| vgi_rpc.shm_segment_size | Decimal integer | SHM segment total size in bytes. Optional. |
| vgi_rpc.transport.shm | "true" / "false" | Peer SHM capability during transport negotiation. |
Response / log / error metadata
| Key | Value | Description |
|---|---|---|
| vgi_rpc.log_level | EXCEPTION, ERROR, WARN, INFO, DEBUG, TRACE | Severity level. Present on log/error batches. |
| vgi_rpc.log_message | UTF-8 string | Human-readable message text. |
| vgi_rpc.log_extra | JSON string | Additional structured data. Optional. |
| vgi_rpc.error_kind | UTF-8 token | Stable, machine-readable error category. Optional and open-ended. |
| vgi_rpc.server_id | 12-char hex | Server instance identifier. |
| vgi_rpc.request_id | UTF-8 string | Echoed request correlation ID. |
| vgi_rpc.transport.shm | "true" / "false" | Server SHM capability on the transport-options response. |
| vgi_rpc.stream_state#b64 | Base64 AEAD token | Per-turn HTTP stream cursor. |
| vgi_rpc.call_state#b64 | Base64 AEAD token | Immutable HTTP call state, minted once at stream initialization. |
Pointer batch metadata
| Key | Context | Description |
|---|---|---|
| vgi_rpc.shm_offset | SHM pointer | Absolute segment offset as a decimal integer. |
| vgi_rpc.shm_length | SHM pointer | Serialized batch length in bytes. |
| vgi_rpc.shm_source | Resolved SHM batch | Source segment name retained for diagnostics. |
| vgi_rpc.location | External pointer | URL containing an Arrow IPC stream. |
| vgi_rpc.location.sha256 | External pointer | SHA-256 of the uncompressed payload; verification is mandatory when present. |
| vgi_rpc.location.fetch_ms | Resolved external batch | Fetch duration in milliseconds. |
| vgi_rpc.location.source | Resolved external batch | Original source URL retained for diagnostics. |
3.1 Protocol routing
A server hosts one or more protocols. Every request names the one it addresses, and dispatch resolves the pair (protocol, method). Method names may collide across protocols — that is what lets protocols be authored independently, and a port that merges them into one namespace is not conformant.
A protocol name is an identifier, optionally dot-qualified — [A-Za-z_][A-Za-z0-9_.]*, at most 255 bytes UTF-8. The vgi_rpc. prefix is reserved for protocols the framework itself defines; a server MUST refuse to register an application protocol claiming it, because an application that could claim vgi_rpc.Reflection.v1 could shadow the one surface a client trusts before it knows anything else about the server.
The major version is part of the name — vgi_rpc.Reflection.v1, vgi_rpc.Identity.v1 — following gRPC (AIP-185), Kubernetes API groups, and D-Bus. An incompatible major is therefore a different protocol, so addressing it is a routing failure every proxy and load balancer understands without an Arrow parser, and .v1 and .v2 can be served side by side while clients migrate.
On HTTP the protocol rides twice: in vgi_rpc.protocol and as a path segment, {prefix}/{protocol}/{method}. The metadata field is canonical — it is the only carrier on the stdio, Unix-socket, and named-pipe transports. The path segment is a required faithful projection, present so an edge device can act on the protocol without parsing Arrow. A server MUST reject a request whose two carriers disagree, and a % anywhere in the protocol path segment is rejected without decoding — the name charset never requires percent-encoding, so a percent sign is a bug or an attempt to make the edge and the worker read different strings.
Stream continuations re-verify. Implementations bind the protocol into the AEAD associated data of the cursor and call tokens, so a cross-protocol continuation fails the tag check and is rejected exactly as an invalid token. Binding it into the cursor token is what covers the call-state cache-hit path, where the call token is never opened at all.
vgi_rpc.protocol is required even when the server hosts exactly one protocol. An exemption would let an intermediary that rebuilds a request and drops the field land silently on whichever protocol happened to be first, rather than being told. The three answers are distinct and a client depends on the difference:
| Condition | Answer | error_kind |
|---|---|---|
| No routing key on the request | Refuse | protocol_not_specified |
| Named protocol not hosted here | 404 | protocol_not_supported |
| Protocol hosted, method absent | 404 | method_not_implemented |
The last row is the documented capability-probe signal: a client testing for an optional method must be able to tell “you do not speak this protocol” from “you speak it but lack this method”.
4. Type Mapping
RPC parameters and return values are serialized as Arrow columns. Cross-language implementations MUST use these Arrow types for interoperability.
| Abstract Type | Arrow Type | Notes |
|---|---|---|
| string | utf8 | UTF-8 encoded |
| bytes / binary | binary | Raw byte sequence |
| int / integer | int64 | 64-bit signed integer |
| float / double | float64 | IEEE 754 double precision |
| bool | bool | — |
| list[T] | list(T) | Recursive |
| dict[K, V] | map(K, V) | Serialized as (key, value) tuples |
| set[T] | list(T) | Order undefined |
| enum | dictionary(int16, utf8) | Serialized as member name |
| optional[T] | T (nullable=true) | null = absent value |
| dataclass | binary | Serialized as IPC stream |
A dataclass used directly as an RPC parameter or return value is a binary column containing a complete Arrow IPC stream. Inside that dataclass stream, nested dataclasses are Arrow structs. Container conversion is recursive, including lists, maps, sets, enums, nullable values, and nested dataclasses.
5. Request Batch Format
Every RPC request is a single IPC stream containing exactly one batch with one row:
IPC Stream:
Schema message:
- One field per method parameter
- Field types per the type mapping
RecordBatch message:
- Exactly 1 row
- custom_metadata:
vgi_rpc.method = "<method_name>" (REQUIRED)
vgi_rpc.protocol = "<protocol_name>" (REQUIRED)
vgi_rpc.request_version = "1" (REQUIRED)
vgi_rpc.protocol_version = "1.4.0" (when declared)
EOS markerFor methods with no parameters, the schema has zero fields and the batch should represent one row with zero columns. Because Arrow cannot encode a row count for a zero-field batch consistently across runtimes, servers accept any row count for that special case.
6. Response Format (Unary)
A unary response is a single IPC stream on the result schema:
IPC Stream:
Schema message:
- Single field named "result" (or empty for void)
0..N log batches (zero-row, with log metadata)
1 result or error batch:
- Result: 1-row batch with return value
- Void: 0-row batch on empty schema
- Error: 0-row batch with EXCEPTION metadata
EOS markerLog batches MUST appear before the result/error batch.
7. Batch Classification Algorithm
receive(batch, custom_metadata):
IF custom_metadata is NULL → DATA batch
IF batch.num_rows > 0 → DATA batch
// Zero rows, metadata exists:
IF has "vgi_rpc.log_level" AND "vgi_rpc.log_message":
IF level == "EXCEPTION" → ERROR batch → raise RpcError
ELSE → LOG batch → on_log callback
IF has "vgi_rpc.location" → EXTERNAL POINTER batch
IF has "vgi_rpc.shm_offset" → SHM POINTER batch
IF has "vgi_rpc.stream_state#b64" → STATE TOKEN batch
→ DATA batch (void return, stream-finish)8. Log & Error Batch Format
A log batch is a zero-row batch on the response stream's schema with vgi_rpc.log_level and vgi_rpc.log_message in custom metadata.
When log_level is "EXCEPTION", the client MUST raise/throw an error with:
- error_type: from
log_extra.exception_type - error_message: from
vgi_rpc.log_message - remote_traceback: from
log_extra.traceback - request_id: from
vgi_rpc.request_id - error_kind: stable, open-ended classification — a client MUST treat an unrecognised value as an unclassified error rather than rejecting the batch
| Well-known error_kind | Meaning |
|---|---|
| method_not_implemented | The named protocol is hosted but has no such method. The intended signal for capability detection with fallback. |
| protocol_not_specified | The request carried no vgi_rpc.protocol routing key. |
| protocol_not_supported | This server does not host the named protocol, or the path and the metadata named different ones. Also the answer for an incompatible major, since the major is part of the name. |
| protocol_version_mismatch | The client's vgi_rpc.protocol_version is incompatible with the server's, for the protocol the resolved method belongs to. |
| session_lost | An HTTP sticky-session token could not be honoured — expired, evicted, misrouted, or presented under a different principal. |
| server_draining | The server is shutting down and refuses new sticky-session opens. |
On protocol_not_supported a client SHOULD call vgi_rpc.Reflection.v1/list_protocols to learn what the server does host, and MUST surface both what it asked for and what it was told. Section 16 defines five further kinds specific to vgi_rpc.Identity.v1.
9. Stream Protocol (Pipe / Subprocess)
Streaming methods use a multi-phase exchange over a bidirectional byte stream.
Phase 1: Request parameters
Identical to a unary request: IPC stream with params schema, 1 request row, EOS.
Phase 1.5: Optional header stream
When the stream method declares a header type, the server sends a header IPC stream (header schema, log batches, 1 header row, EOS) before the main data exchange.
Phase 2: Lockstep data exchange
Producer: Exchange: Client → tick (0-row) Client → input batch Server ← log* + data Server ← log* + output Client → tick (0-row) Client → input batch Server ← log* + data Server ← log* + output Client → tick Client → [EOS] Server ← [EOS] (finish) Server ← [EOS]
Lockstep is normative: one client tick or input batch drives exactly one server process() invocation and produces zero or more log batches followed by at most one data batch. A producer that finishes closes its output stream instead of returning a data batch for the final tick.
HTTP follows the same rule. The {prefix}/{protocol}/{method}/init request drives the first producer turn; every continuation request carries one tick and drives exactly one additional turn. The request batch's custom metadata is delivered to that turn after stripping vgi_rpc.stream_state#b64, vgi_rpc.call_state#b64, and vgi_rpc.cancel.
A client may cancel either stream by sending a zero-row input batch carrying vgi_rpc.cancel = "1". Presence of the key is the signal; readers must not depend on its value. Cancellation is best-effort, does not invoke process(), invokes the stream state's cancellation hook, and closes cleanly rather than producing an exception batch.
10. HTTP Transport
The HTTP transport maps the pipe-based protocol to stateless HTTP request/response pairs. All requests use Content-Type: application/vnd.apache.arrow.stream.
| Endpoint | Method | Description |
|---|---|---|
| {prefix}/{protocol}/{method} | POST | Unary RPC call |
| {prefix}/{protocol}/{method}/init | POST | Stream initialization (producer and exchange) |
| {prefix}/{protocol}/{method}/exchange | POST | Stream continuation, exchange, and cancel |
| {prefix}/__upload_url__/init | POST | Pre-signed upload/download URL generation, when a provider is configured |
| {prefix}/health | GET, HEAD, OPTIONS | Health check and capability discovery |
| {prefix}/__session__ | DELETE | Optional sticky-session teardown |
The first three rows are the RPC surface and are namespaced by protocol; the path segment must agree with vgi_rpc.protocol (Section 3.1). Introspection is not a special path — it is {prefix}/vgi_rpc.Reflection.v1/list_protocols and {prefix}/vgi_rpc.Reflection.v1/describe, reached the same way as anything else, and identity is likewise {prefix}/vgi_rpc.Identity.v1/introspect_token. The reserved framework endpoints are deliberately not namespaced: they belong to the server rather than to any one protocol. A GET to a two-segment path whose first segment cannot be a protocol name is 404, not 405 — without that rule any unrelated two-segment path, a /.well-known/… document among them, matches the RPC route and is answered “method not allowed” by it.
Capability headers are stamped on every response and can be discovered cheaply at HEAD {prefix}/health. Browser clients must use HEAD for discovery so the probe passes the CORS method check. GET and OPTIONS also carry capability headers. There is no __capabilities__ endpoint. An absent capability means unsupported or unconfigured, except that a missing VGI-Supported-Encodings indicates a legacy server for which clients assume zstd; a present but empty value explicitly disables compression.
| Capability header | Meaning |
|---|---|
| VGI-Max-Request-Bytes | Maximum accepted inline request body. |
| VGI-Max-Response-Bytes | Decoded Arrow IPC body cap, before HTTP content coding. Hard for every response shape. |
| VGI-Accept-Max-Response-Bytes-Support | Always "true". The server honours the client's strict per-response limit request header, VGI-Accept-Max-Response-Bytes. |
| VGI-Max-Externalized-Response-Bytes | Hard cap on bytes uploaded externally during one response. |
| VGI-Externalization-Enabled | Whether responses may contain external pointer batches. |
| VGI-Supported-Encodings | Comma-separated response codecs. |
| VGI-Upload-URL-Support / VGI-Max-Upload-Bytes | Upload-URL availability and optional size cap. |
| VGI-Proxy-Proof-Required | Whether the worker requires a per-request proxy proof. |
| VGI-Sticky-Enabled / VGI-Sticky-Default-TTL / VGI-Sticky-Echo-Headers | Sticky-session availability and routing contract. |
Clients can send VGI-Accept-Max-Response-Bytes as one ASCII positive decimal integer from 65536 through 253 − 1. Signs, leading zeroes, comma lists, and duplicate values are invalid and receive HTTP 400 before dispatch. The effective decoded response cap is the minimum of the client limit and the application and hosting caps. A batch still too large after externalization produces a cursor-free ResponseTooLargeError. Each HTTP producer turn invokes the producer once; a response target does not combine multiple turns.
Request and response compression are independent. Requests use Content-Encoding; responses are negotiated with Accept-Encoding or the higher-priority X-VGI-Accept-Encoding. The corresponding response header is Content-Encoding or X-VGI-Content-Encoding. Codec tokens are zstd, gzip, and identity; the portable conformance worker supports zstd and gzip in both directions.
HTTP streams split state into two XChaCha20-Poly1305-sealed tokens: immutable call state minted once by /init, and a per-turn cursor. Clients must echo both on every continuation, exchange, and cancellation request. Servers authenticate the cursor first, use its authenticated call ID for cache lookup, then open the call token on a cache miss and require matching call IDs.
Base64-decoded state token envelope: byte 0 independent token version (cursor: 5, call: 1) bytes 1..24 random XChaCha20-Poly1305 nonce bytes 25.. authenticated ciphertext + 16-byte tag Sealed payload: byte 0 self-describing codec (0x00 raw, 0x01 zstd in reference) bytes 1.. framed state, compressed before encryption when smaller
Token AAD binds the caller's domain and principal. Implementations must bound decompression, reject unknown codecs, and return the same HTTP 400 classification for invalid, expired, mismatched, or unresolvable tokens.
Authentication runs before method dispatch. Rejections use HTTP 401 with a machine-readable VGI-Auth-Reason; workers may additionally require a per-request HMAC proxy proof while preserving the caller's end-user credential.
11. Shared Memory (SHM) Transport
The shared memory side-channel enables zero-copy batch transfer between co-located processes. The pipe carries control messages; large batches are written to shared memory and replaced with pointer batches.
SHM is an optional side channel for pipe, subprocess, and Unix-socket transports. Before advertising a segment or writing a pointer, both peers must negotiate vgi_rpc.transport.shm = "true" through __transport_options__. Missing support or any negotiation error requires an inline fallback.
Segment header (64 KiB): Offset Size Field 0 4 magic: "VGIS" (0x56 0x47 0x49 0x53) 4 4 version: uint32 = 1 8 8 data_size: uint64 16 4 num_allocs: uint32 20 4 padding: uint32 = 0 24 N*16 allocations: (offset, length) pairs
SHM pointer batches are zero-row batches with vgi_rpc.shm_offset and vgi_rpc.shm_length in custom metadata. The allocator uses first-fit with implicit coalescing. Maximum 4,094 allocations. A resolver validates bounds, opens the IPC stream from the referenced region, retains the mapped owner for the Arrow buffers' lifetime, annotates the resolved batch with vgi_rpc.shm_source, and releases the allocation when it is safe to reuse.
12. External Storage Pointer Batches
When batches exceed a configurable size threshold, they can be externalized to remote storage (HTTPS, S3, or GCS) and replaced with pointer batches containing a vgi_rpc.location URL.
Writers may attach vgi_rpc.location.sha256; readers that receive it must verify the uncompressed payload. Resolution enforces scheme, host, redirect, encoded-size, decoded-size, and retry limits before opening the fetched IPC stream.
13. Version Negotiation & Error Handling
Every request batch MUST carry vgi_rpc.request_version = "1".
Three independent things can be skewed between a client and a worker, and each has its own mechanism. None substitutes for another, and a port that implements two of the three has a silent failure mode.
| Skew | Caught by | Answer |
|---|---|---|
| Incompatible major version | Routing — the major is part of the protocol name, so this is a different protocol | protocol_not_supported, HTTP 404 |
| Method absent on the server | Method resolution within the named protocol | method_not_implemented, HTTP 404 |
| Signature changed under an unchanged method name | The protocol_version gate | protocol_version_mismatch, HTTP 400 |
The third row is the one nothing else covers. Protobuf's field numbers and unknown-field semantics make a renamed or retyped field self-detecting; Arrow has neither. A parameter renamed between releases, or retyped under the same name, produces a request the server will happily coerce through its own declared type and act on. The gate is what turns that into a directional error.
Application protocols may independently declare vgi_rpc.protocol_version as canonical semver — no prereleases, no build metadata. Major and minor must match exactly; patch differences interoperate. A server MUST also reject a request carrying a parameter its protocol does not declare: a strict version gate sitting on a lenient deserializer is two policies in one codepath.
The gate is per binding. A server hosting several protocols has a version per protocol and no single “server version”; gating against the primary rejects correct callers of a secondary and names the wrong protocol when it does, so the error message MUST name the protocol. vgi_rpc.Reflection.v1 is exempt — it is what a version-mismatched client calls to learn what mismatched, and the exemption is a property of the binding, not of a method name.
| Condition | HTTP Status |
|---|---|
| Bad IPC, missing metadata, request/protocol-version mismatch, invalid state token, request decompression failure | 400 |
Path and vgi_rpc.protocol name different protocols | 400 |
| Authentication failure, including proxy proof | 401 |
| Unknown method on a hosted protocol | 404 |
Protocol not hosted, a path segment that cannot be a protocol name, or a % in the protocol path segment | 404 |
| Request exceeds advertised limit | 413 |
| Wrong Content-Type or unsupported Content-Encoding | 415 |
| Method implementation error or hard response-cap overshoot | 200 + X-VGI-RPC-Error |
An application failure never reaches the client as a 5xx: intermediaries and HTTP client libraries routinely discard or replace response bodies on 5xx, and the body is precisely where the typed error lives. Clients MUST therefore treat 200 as “a response arrived”, not “the call succeeded”, and classify by inspecting the body — the batch-classification algorithm in Section 7 already does this, so X-VGI-RPC-Error is a fast path, not a second source of truth.
14. Reflection (vgi_rpc.Reflection.v1)
Introspection is an ordinary co-hosted protocol, not a special method name. That is what lets every port generate it from the same pipeline as any other method rather than hand-maintain a bespoke format — which is how the ports drifted before.
A server that offers introspection hosts vgi_rpc.Reflection.v1 and it appears in that protocol's own output. A server that does not simply does not host it, and a client asking gets the ordinary protocol_not_supported — not a bespoke “introspection is disabled” to special-case.
The retired __describe__ method. No server answers it. It is refused with a message naming its replacement rather than with a plain “unknown method”, because a stale client told only the latter has no way to learn that introspection moved — and “retired” and “this server was built without introspection” need opposite fixes. Every other reserved name keeps the plain capability answer. The vgi_rpc.describe_version metadata key is gone with it.
Methods
list_protocols() -> ProtocolList describe(protocol: utf8) -> ServiceDescription
list_protocols is the cheap question — what is here, and has it changed — and is the only one a client needs on a warm path, because protocol_hash answers “has it changed” without transferring any schema. describe is the expensive one, asked once. A client that does not already know a protocol name needs both, in that order. Both are ordinary unary methods: they carry vgi_rpc.protocol = "vgi_rpc.Reflection.v1", their replies ride as serialized bytes in a single result column, and a server that externalizes payloads externalizes these too.
Payload
ProtocolList carries server_id, server_version, request_version, and protocols: list<ProtocolSummary> — every hosted protocol, reflection included.
| ProtocolSummary field | Arrow type | Notes |
|---|---|---|
| protocol | utf8 | Wire name — the routing key. |
| protocol_version | utf8 | Declared semver, or empty when the protocol opts out. |
| protocol_hash | utf8 | 64 lowercase hex. See below. |
| deprecated | bool | Whether callers should migrate off. |
| deprecation_message | utf8 | What to migrate to. Empty unless deprecated. |
| features | list<utf8> | Open set of capability tokens. |
ServiceDescription is those fields plus methods: list<MethodInfo>, sorted by name. It deliberately carries no server identity: two processes serving one protocol must describe it identically, or the description is not a property of the protocol. Server identity lives on ProtocolList, which is a statement about a server.
| MethodInfo field | Arrow type | Notes |
|---|---|---|
| name | utf8 | — |
| method_type | utf8 | "unary" or "stream". |
| has_return | bool | — |
| has_header | bool | — |
| stream_kind | utf8 | Empty for unary; otherwise unknown / producer / exchange. The only field that says whether a stream accepts input, since a stream's input schema arrives at init time. A port that can classify a method MUST state it. |
| params_schema_ipc | binary | Arrow IPC. |
| result_schema_ipc | binary | Arrow IPC; empty when has_return is false. |
| header_schema_ipc | binary | Arrow IPC; empty when has_header is false. |
| idempotency | utf8 | unknown / no_side_effects / idempotent, following gRPC's idempotency_level. unknown is the default and means a caller must assume the worst. |
| deprecated | bool | — |
| deprecation_message | utf8 | — |
Schemas travel as serialized Arrow IPC rather than as a structural description: a client's whole purpose in asking is to get a schema it can hand to its own Arrow implementation. An absent schema is empty bytes rather than null, so no port pays a null check on a value it will only ever treat as absent.
Decoding is tolerant — normative
A decoder MUST read fields by name, not by position; ignore columns it does not know; and default columns that are absent and have a default. This is what makes minor skew survivable in both directions, and it is load bearing: a strict decoder fails at exactly the moment a client most needs a good answer. A decoder MUST NOT default a field that has no default — it errors instead. One rule follows, binding every port: a field added in a minor version MUST carry a default, or the addition is a breaking change wearing a minor version number.
protocol_hash — normative
The hash is a fingerprint of a protocol's wire surface, so a client and a worker can say which one they have without transferring the whole description. It is defined over what Arrow decodes to, not what an encoder emits: each language's Arrow implementation may legitimately produce different bytes for the same logical schema.
protocol_hash = lowercase_hex(sha256(
b"vgi_rpc.protocol_hash.v1|" + canonical_json(description)))The canonicalisation profile is RFC 8785 (JCS), chosen for its published test vectors. The structure is restricted to objects, arrays, strings, and booleans: every numeric parameter is folded into a type token, so the preimage contains no JSON numbers and JCS's number-canonicalisation rule — its hardest, and the likeliest place for seven ports to diverge — never applies.
{"protocol":"vgi_rpc.Reflection.v1","methods":[
{"name":"describe","type":"unary","has_return":true,"has_header":false,
"params":[{"name":"protocol","nullable":false,"type":"utf8"}],
"result":[{"name":"result","nullable":false,"type":"binary"}]},
{"name":"list_protocols","type":"unary","has_return":true,"has_header":false,
"params":[],
"result":[{"name":"result","nullable":false,"type":"binary"}]}]}- Methods are sorted by name; a port iterating a hash map must still produce this order.
- Field order within a schema is declaration order and is significant.
resultandheaderare omitted when the method has none. A method returning nothing is not a method returning an empty struct.- Server identity, docstrings, parameter defaults, language-specific type names, and
request_versionare not in the preimage. stream_kindis not either, for a different reason: which methods a port can classify depends on how that port's registration works, so two ports can disagree while neither is wrong, and a field one port can state and another cannot is not a contract.- The
v1in the domain tag is the only version the hash carries, and it moves only when the hash definition moves.
All seven ports produce identical digests. Conformance asserts it and ships the canonical preimage beside each digest so a failing port diffs JSON rather than guessing. The 87-method conformance service hashes to 5cc768771c2e8a54e19ebb7546c97c119823eb13e20a5ff62ca5ce7ed2a1334e and vgi_rpc.Reflection.v1 itself to 3c7db4cae8cdfc93dc4a76e73b8b759e18e45e6a5811adba4e520366344b919a. Earlier revisions of this page disclaimed cross-language hash stability, because the hash was then taken over encoder bytes. That disclaimer no longer holds.
Types appear in the preimage as lowercase ASCII tokens — utf8, decimal128(p,s), timestamp(unit,tz=Z), list<item?:T>, struct<a:T,b?:U> — with parameters in parentheses, children in angle brackets, and a trailing ? on a nullable child. List and map child field names are normalised to item / key / value, because Arrow's own type equality ignores them; everything Arrow does treat as part of the type is kept, child nullability included. A timezone is carried verbatim — UTC and +00:00 are distinct Arrow types. A type with no token MUST raise rather than fall back to the Arrow implementation's own string form. The canonical specification carries the full token table.
protocol_hash is also the access log's registry key for decoding archived records. Each access record carries the owning binding's protocol and protocol_hash — the protocol that owns the dispatched method, not a server-wide default. Framework endpoints owned by no protocol log the server's primary. A mislabelled record is well-formed, passes the schema, and produces a plausible dashboard, so it is the field most worth getting right. Method names may collide across protocols, so anything aggregating on method alone must group by (protocol, method) or it silently merges two protocols' traffic.
15. Transport Capability Negotiation (__transport_options__)
Before using the shared-memory side channel over pipe, subprocess, or Unix socket transports, peers negotiate once through the synthetic unary method __transport_options__. It is a framework built-in, resolved before routing and owned by no protocol, so it carries no vgi_rpc.protocol routing key. HTTP deployment capabilities use response headers instead; the discovery probe there is HEAD {prefix}/health.
shm_enabled = client["vgi_rpc.transport.shm"] == "true"
&& server["vgi_rpc.transport.shm"] == "true"Capabilities use the open-ended vgi_rpc.transport.* namespace. Unknown keys are ignored. If the method is absent, errors, or reports false, the client stays inline.
16. Identity (vgi_rpc.Identity.v1)
Two optional methods, hosted as an ordinary co-hosted protocol. This was previously an HTTP-only JSON route, POST {prefix}/__introspect_token__, which meant it existed on one transport only and had to be hand-written in every port.
introspect_token(token: utf8) -> TokenIdentity issue_grant(purpose: utf8, scopes: list<utf8>, ttl_seconds: int64) -> IssuedGrant
A method whose hook the deployment did not configure is not hosted at all, and the binding's method set — and therefore its protocol_hash — narrows with it. A worker that resolves credentials but does not mint grants hosts introspect_token alone, and a client learns that from reflection rather than by calling and reading an error. Absent beats routed-and-refusing: only a narrowed digest distinguishes a worker that cannot mint from one that will not, and it is what keeps a dependency upgrade from growing a credential-to-identity oracle on every existing worker.
introspect_token — an oracle, and guarded as one
Resolves an opaque bearer credential to the principal it authenticates as, for a reverse proxy that terminates the only public listener and must know the caller's identity before it can authorize anything. The answer is an identity assertion made by the thing being protected, which the asker then acts on using credentials the worker does not hold — so it must be trusted more than the worker, not merely as much. Hence four normative guards:
- An introspector allowlist with no permissive default. A server whose resolver is configured without one MUST refuse to start. “Any authenticated caller” lets any user resolve any other user's credential to its owner.
- Authorization is checked before the credential is touched, so an unauthorized caller learns nothing about it — including how long looking at it took.
- Rejections are uniform. Unknown, expired, malformed, and over-long are one answer; distinguishing them confirms that a guessed credential exists.
- A JWS-shaped subject is refused before the resolver runs. Three dot-separated base64url segments are validated locally against a key set.
Rate limiting (20 per caller per second by default) bounds, rather than closes, the oracle an allowlisted-but-compromised caller still has. TokenIdentity carries principal, token_name, and ttl_seconds, and never claims — a pass-through claims field would let a worker choose its caller's tenant routing, row scope, and policy branch. ttl_seconds is how long the answer may be cached: an authorization window, and therefore the revocation lag.
issue_grant — not an oracle, so not guarded as one
Mints a standing delegation credential for the calling user. OAuth cannot express durable delegation: it fuses the grant, the credential, and the session into one refresh token, so an IdP shortening session lifetime shortens the grant. There is no subject parameter — the subject is always the caller's authenticated principal, so cross-subject minting is closed by construction rather than by a check one of seven ports can forget. That is also why this method needs no allowlist and no rate limit while introspect_token has both.
A credential with no verifiable auth_time cannot mint. That single rule is what stops a grant being used to mint another grant and escaping the identity provider permanently, and it makes subprocess and Unix transports fail closed for free, since there is no authenticated principal there at all. A static bearer proves a machine holds a secret, never that a human just authenticated, so it is refused here too. Unlike the introspection rejections, these are deliberately actionable: they are always about the caller themselves, so naming the reason leaks nothing, and it is the only way a console learns to re-prompt.
IssuedGrant carries token, expires_at, and grant_id. The token format is the worker's entirely — a sealed envelope, a database row, or a credential brokered from the IdP are equally valid and equally invisible here. It is never parsed and never logged.
Error kinds
These were an HTTP route whose callers classified definitive-versus-transient on the status code (404 vs 503). As protocol methods every handler exception surfaces the same way, so error_kind carries the whole distinction and is load-bearing rather than decorative.
| error_kind | Meaning | Caller |
|---|---|---|
| introspection_refused | The caller may not introspect. | Definitive; MAY cache. |
| token_unresolved | The subject credential did not resolve. | Definitive; MAY cache. |
| stale_auth | The caller has not authenticated recently enough to mint. | Definitive, and actionable — re-prompt. |
| grant_refused | The worker declined to mint. | Definitive. |
| identity_unavailable | The answer is not knowable — a store is down. | Transient; MUST NOT negative-cache. |
A caller that negative-caches a transient failure locks out valid users; one that retries a definitive rejection hammers the worker.
17. Sticky Sessions (HTTP, Optional)
Sticky sessions bind handle-bearing process-local state—such as an open cursor, model, or file—to the worker that created it. Both peers opt in; the state stays in process memory and only an AEAD-sealed token crosses the wire.
| Header | Purpose |
|---|---|
| VGI-Session-Accept: true | Client opts in to opening a session. |
| VGI-Session | Server mints, client echoes to resume. |
| VGI-Session-Close: true | Client drops its captured token. |
| VGI-Echo-<name> | Header value the client replays for session routing. |
Teardown is an idempotent DELETE {prefix}/__session__. Lost sessions surface in-band with error_kind = "session_lost"; frameworks do not retry them transparently.
18. Iroh & HTTP over Iroh
Iroh provides encrypted QUIC connections addressed by an EndpointId. The URI scheme selects one of two RPC modes. Both preserve Arrow IPC payloads and the protocol routing described above.
| Mode | Wire protocol | State and deployment |
|---|---|---|
iroh://<endpoint-id> | vgi-rpc/arrow-mux/1 | Each logical RPC transport occupies a long-lived bidirectional QUIC stream. Several independent transports share one connection; stream state stays on the selected worker. |
httpi://<endpoint-id>[/prefix] | iroh-http/2 | HTTP request/response semantics, including discovery, compression, authentication, external payloads, and sealed continuation tokens. A bridge forwards to a fixed HTTP origin. |
Clients and optional bindings
Python uses the iroh extra; Rust offers vgi-rpc-iroh and the optional HTTP-Iroh client path. TypeScript provides irohConnect and httpiConnect with native Node/Bun bindings. Java adds vgirpc-iroh. C# exposes RpcClient.ConnectIrohAsync and HttpRpcClient.ConnectIroh. C++ enables VGI_RPC_WITH_IROH_CABI=ON with a matching native library. Go requires an explicitly supplied native or community provider. Browser Iroh uses a separate adapter; the Node/Bun native binding cannot be bundled into a browser application.
# pip install 'vgi-rpc[iroh]'
from protocol import Calculator
from vgi_rpc import iroh_connect, httpi_connect
# Replace with the worker bridge's 64-hex EndpointId.
endpoint_id = "<endpoint-id>"
with iroh_connect(Calculator, f"iroh://{endpoint_id}") as client:
print(client.add(a=2.0, b=3.0))
with httpi_connect(Calculator, f"httpi://{endpoint_id}/vgi") as client:
print(client.add(a=2.0, b=3.0))Host an existing worker behind the bridge
Rust can embed a native Iroh server. Workers in all seven languages can instead listen on loopback behind vgi-iroh-bridge. Configure the worker’s Iroh bridge trust and issuer, then point the bridge at its raw TCP and/or HTTP listener. For example, with workers already listening on ports 9400 and 9401:
vgi-iroh-bridge \
--secret-key-file /run/secrets/vgi-iroh-key \
--raw-upstream tcp://127.0.0.1:9400 \
--http-upstream http://127.0.0.1:9401The bridge prints its EndpointId. A persistent key preserves that address across restarts. Peer-key authentication supplies identity evidence; application policy still decides access. The bridge strips incoming identity assertions and forwards the authenticated peer through a dedicated PROXY-v2 identity TLV for raw workers or VGI-Forwarded-Iroh-Endpoint for HTTP workers. Only explicitly trusted bridge peers may supply that evidence.
Configure relays and direct-address hints through the client transport options. For raw Iroh, choose among worker EndpointIds on the client and drain existing streams before shutdown. HTTP-Iroh can balance requests behind the bridge’s fixed origin when replicas share the required token keys and external storage.
Cross-language setup and operations · Native Rust adapter · Bridge configuration