The gRPC bridge
Geas connects languages in two shapes. Inside one process, contracts glue languages over the C ABI: every language loads libgeasrt, signs, fulfills, and breaks in shared memory, and nothing is serialized. Across processes, contracts speak gRPC: the compiler emits the wire surface, stock tooling builds the clients, and the contract’s lifecycle rides a protocol every language already carries. One contract, two boundaries, one lifecycle.
Geas keeps no wire of its own. The whole gRPC ecosystem, every language, every load balancer, every proxy, is the transport.
The emitted surface
Section titled “The emitted surface”geas emit-proto file.geas runs the whole front end and writes three artifacts into target/geas-out, each byte stable for a given source and pinned as a golden:
<stem>.proto, the wire surface. Every public contract is its own service, every pledge its own typed rpc, so a consumer in any gRPC language gets the contract’s argument and result types from protoc alone, with no generic value envelope and no dispatch on a name string.<stem>_session.go, the Go session wrapper, living in the same package as protoc’s output.<stem>_session.ts, the TypeScript session wrapper, structural interfaces over@grpc/proto-loader, no protoc step and no native code, and node runs it directly under type stripping.
For the payment contract, the emitted service reads:
service PaymentService { rpc Session(SignRequest) returns (stream SessionEvent); rpc Resume(ResumeRequest) returns (stream SessionEvent); rpc ValidateCard(ValidateCardRequest) returns (BoolIntResult); rpc ValidateAmount(ValidateAmountRequest) returns (BoolIntResult); rpc Charge(ChargeRequest) returns (BoolIntResult); rpc NotifyUser(NotifyUserRequest) returns (BoolIntResult); rpc GetPartial(PartialRequest) returns (PartialReply); rpc Break(BreakRequest) returns (BreakReply);}The wrappers exist because protoc gives a consumer every typed call and cannot give it the one thing the surface turns on: the stream whose lifetime is the instance’s. Each wrapper is a page with one idea in it, and another language’s wrapper is a morning, not a milestone.
The session
Section titled “The session”Signing is a server streaming rpc, and that is the surface’s one load bearing decision. Session takes what sign takes, the vow overrides as optional fields and an expected_hash where 0 skips the check, and answers with a stream whose first event is the signature: the instance id every pledge rpc is keyed by, the effective vows, the shape hash, the signing time, and the park token when the server parks.
The stream then stays open for as long as the instance lives. Holding it is how a client says still mine; closing it is how a client says done; dying is a fact the server has in milliseconds. No timer ever decides a contract’s ending.
The pledge rpcs are unary and typed, keyed by the instance id. GetPartial reads the partial surface, the name lists repeated in the runtime’s insertion order, which is normative. Break ends the contract explicitly; the row outlives the break behind its stream, so a broken instance still says broken instead of saying it never existed, and leaves when the stream does.
The shape hash
Section titled “The shape hash”The wrappers pin the contract’s shape hash as a constant, the same value the compiled module registers, computed by the same calls the module emitter makes:
export const PaymentServiceShapeHash: bigint = 0xc4261f5e32ecf727n;A client that signs under the pinned hash has proven the wire surface and the loaded module describe one contract before the first pledge runs. A hash that disagrees is refused at sign, before an instance exists. This is the same discovery time defense the in process iname table gives a C host, carried across the wire.
Business errors and transport errors
Section titled “Business errors and transport errors”A contract’s own Err is a value. It crosses as one arm of the result oneof on an OK rpc, exactly as it returns in process, and never becomes a transport error. A Geas status is the transport and lifecycle layer, a fulfillment that did not run, and crosses as a gRPC code:
| Geas status | gRPC code |
|---|---|
GEAS_ERR_STATE | FAILED_PRECONDITION |
GEAS_ERR_NAME | NOT_FOUND |
GEAS_ERR_TYPE | INVALID_ARGUMENT |
GEAS_ERR_VERSION | ABORTED |
GEAS_ERR_UNBOUND | FAILED_PRECONDITION |
The split is the language’s own line, drawn once and held across the wire. See Errors as values for the in language half.
The type set
Section titled “The type set”Scalars cross as proto scalars, Int and UInt as their 64 bit forms, Float as double, Bool and String as themselves. A declared record is a message with its fields in declaration order. A declared sum whose arms are all bare is an enum numbered in declared arm order, the ABI’s own tag space; a sum with a payload arm is a message whose oneof carries one message per arm in the same order. Unit is the empty message. Option and List ride oneof arms as wrapper messages and sit in fields as optional and repeated.
Two shapes get special treatment rather than proto’s defaults:
- A Map crosses as a wrapper message holding a repeated entry pair, key and value fields in one small message, never as proto’s own map type. Geas pins map iteration as insertion ordered and proto’s map carries no order at all, so the bridge spells a map as the ordered entry list it actually is, and the order a program built is the order the wire carries.
- A tuple crosses as a wrapper message with one numbered item field per position.
Both wrappers are emitted once per file under deterministic names, the value kind last, StringIntMap and IntStringTuple, the same rule that names BoolIntResult.
Some spellings are refused with named build errors instead of silently mangled. The rpc names Session, Resume, GetPartial, and Break belong to the surface, and a pledge under one of those names is refused by name; a pledge spelled sign keeps its rpc and moves its request message alone to SignPledgeRequest.
The server
Section titled “The server”The reference bridge server is a Python host over libgeasrt: it loads the compiled module through the C ABI, binds host implementations over abstract pledges, and serves the emitted surface with grpc. Nothing about it is privileged. Any language that reaches the ABI can hold the runtime, and any language with a gRPC server can serve the surface, because the bridge is ordinary host code on one side and ordinary protobuf on the other.
A server started with a park store makes the session survive its stream, its server, and its process; that story has its own page.
The clients
Section titled “The clients”Clients in Python, Go, and Node drive the whole lifecycle from the emitted artifacts alone. The Go wrapper opens and resumes sessions in one call each:
func OpenPaymentServiceSession(ctx context.Context, c PaymentServiceClient, req *SignRequest) ( *PaymentServiceSession, error)
func ResumePaymentServiceSession(ctx context.Context, c PaymentServiceClient, req *ResumeRequest) ( *PaymentServiceSession, error)The TypeScript wrapper does the same over @grpc/proto-loader with structural interfaces, and a client in any other gRPC language builds against the .proto with stock tooling and never learns Geas exists.
# the payment contract served over gRPC, driven from Pythonmake test-grpc-bridge
# the same lifecycle driven from Go and from Node, built from emitted artifactsmake test-grpc-gomake test-grpc-nodeWhat the bridge does not do
Section titled “What the bridge does not do”There is no relay and no registry: a client dials a server that holds the contract, and a server serves the contracts its runtime registered. There is no cross server transaction and no shared session bus; one instance lives behind one stream or one park row at a time, never both, never neither. Discovery, load balancing, and transport security are gRPC’s own ground, and the bridge stands on it rather than beside it.