The request is a document

An RPC surface built on kaiv, worked end to end for a fictitious forum platform: a request envelope smaller than JSON-RPC’s, a params block the schema can actually see, error messages with line numbers, requests you can save, diff, and replay — and measured numbers against gRPC from the real implementation, brotli included.


Kyklos is a forum platform — boards, threads, posts, organised into themed communities (the agora naming is a house joke that stuck). Its admin API speaks JSON-RPC 2.0: a method string, a params object, an id, and the "jsonrpc": "2.0" marker, POSTed to one endpoint.

{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "Post.MultiGet",
  "params": {"ws": "AGORA", "ids": ["t3_9fq2k", "t1_9fq2m"]}
}

JSON-RPC has earned its keep. But look at what the envelope spec can say about that request: the method is “a String”, and params is “a Structured value”. That is the whole contract. Everything that makes this request valid — that ws names one of the platform’s communities, that a post id looks like t3_9fq2k, that you can ask for at most a hundred posts at once — lives somewhere else: in prose documentation, and in hand-written guard code on the server, each clause maintained twice and drifting independently.

This article rebuilds the surface in kaiv. The interesting part is not the transport — it is what happens to all that prose when the request itself becomes a typed document.

The domain types come first

The platform’s identifiers are constrained strings, so they go into a type library once, under names the rest of the API refers to:

.!taiv kyklos/core

// Community code — the platform's themed communities.
!str{AGORA,STOA,LYKEION,PNYX,KEPOS}
&ws=

// Post id — a typed fullname: `t3_` posts, `t1_` replies.
/^t[13]_[a-z0-9]+$/
&postid=

// API method name — `Family.Method`.
/^[A-Za-z][A-Za-z0-9]*\.[A-Za-z][A-Za-z0-9]*$/
&method=

(Plus threadid, boardid, and tag, elided here — six types cover the whole platform.)

The envelope, minus the parts HTTP already does

A kaivrpc request drops three things JSON-RPC carries:

What remains is a method and a params block:

$ cat multiget.req.kaiv
.!kaiv

// One kaivrpc request — self-contained and replayable:
//   curl --data-binary @multiget.req.kaiv \
//        -H 'Content-Type: application/kaiv' \
//        https://api.kyklos.example/v2/kaivrpc
method=Post.MultiGet
/params::ws=AGORA
/params/@ids;=t3_9fq2k;t1_9fq2m

Note what this file is: not a serialization of a request object, but the request itself, readable in a text editor, replayable with curl --data-binary, diffable in a bug report. The comment rides along legally — it is a kaiv document.

One schema sees the whole request

Here is the part JSON-RPC structurally cannot do. Its envelope schema and its per-method params validation live in different worlds — params is any, and the envelope spec is done. In kaiv, the method’s request schema types the envelope and the params in one document:

.!saiv kyklos/req/post-multiget strict
.!types kyklos/core

!str{Post.MultiGet}
method=

&ws
/params::ws=

[/params/@ids min=1 max=100]
[]
&postid
/params/@ids;=

Read it top to bottom: the method field is pinned — this schema validates Post.MultiGet requests and nothing else. ws must name a real community. The id batch is between one and one hundred entries, each a typed fullname. The prose paragraph and the server-side guard function from the JSON version have become five schema declarations.

Compiled, the contract is small enough to read whole:

$ kaiv schema multiget-req.saiv
.!csaiv kyklos/req/post-multiget strict
!str {Post.MultiGet}'::method=
!kyklos/core/ws {AGORA,STOA,LYKEION,PNYX,KEPOS}'/params::ws=
/params/@ids [min=1] [max=100]
!kyklos/core/postid /^t[13]_[a-z0-9]+$/'/params/@ids::=

And the good request passes:

$ kaiv validate multiget.req.kaiv multiget-req.saiv
pass

Refusals name the line

Send a request for a site that does not exist:

$ printf '.!kaiv\nmethod=Post.MultiGet\n/params::ws=SPARTA\n/params/@ids;=t3_9fq2k\n' | kaiv validate - multiget-req.saiv
kaiv: ConstraintViolationError: /params::ws=SPARTA (type !str) violates !kyklos/core/ws {AGORA,STOA,LYKEION,PNYX,KEPOS} (line 3)

Ask for zero posts:

$ printf '.!kaiv\nmethod=Post.MultiGet\n/params::ws=AGORA\n' | kaiv validate - multiget-req.saiv
kaiv: CardinalityViolationError: /params/@ids has 0 elements, schema requires min=1 max=100

Call the wrong method against this schema:

$ printf '.!kaiv\nmethod=Post.Get\n/params::ws=AGORA\n/params/@ids;=t3_9fq2k\n' | kaiv validate - multiget-req.saiv
kaiv: ConstraintViolationError: ::method=Post.Get (type !str) violates !str {Post.MultiGet} (line 2)

The server’s request handling collapses to: validate against the schema the method names; on failure, return the message. The line numbers point into the caller’s own document.

The response is nullable where the data is

The response schema declares the envelope’s error half and the post shape. Kyklos posts have genuinely optional fields — a reply has no title, a cross-board digest row has no board — and kaiv makes null explicit rather than conventional: a field is nullable only if its type says !null|T, and a null line is still a line (!null'…=), distinct from an empty string.

!str{ok,error}
status=

!null|int
code?=

[/data/@posts max=100]
&postid
id=
!int[1,2]
level=
!null|kyklos/core/boardid
board?=
!null|str
title?=
…
[]
$ kaiv validate multiget.resp.kaiv multiget-resp.saiv
pass

Build the reply to canonical form and a property appears that JSON cannot offer:

$ kaiv build multiget.resp.kaiv
.!daiv
.!registry kyklos=./types
!str'::status=ok
!null'::code=
!null'::message=
!str'/data/@posts/0::id=t3_9fq2k
!str'/data/@posts/0::ws=AGORA
!str'/data/@posts/0::t=9fq2
!str'/data/@posts/0::level=1
!kyklos/core/boardid'/data/@posts/0::board=t5_lyre
!str'/data/@posts/0::title=Restring a lyre without detuning the rest
!str'/data/@posts/0::body=Loosen the neighbors a quarter turn first.
!str'/data/@posts/0::createdAt=2026-07-20T09:12:03Z
!str'/data/@posts/0::nreplies=2
!str'/data/@posts/0::uvotes=5
!str'/data/@posts/0::dvotes=1
!str'/data/@posts/0/@tags::0=music
!str'/data/@posts/0/@tags::1=repair
!str'/data/@posts/1::id=t1_9fq2m
!str'/data/@posts/1::ws=AGORA
!str'/data/@posts/1::t=9fq2
!str'/data/@posts/1::level=2
!null'/data/@posts/1::board=
!null'/data/@posts/1::title=
!str'/data/@posts/1::body=Or cap the bridge while you work.
!str'/data/@posts/1::createdAt=2026-07-21T14:40:11Z
!str'/data/@posts/1::nreplies=0
!str'/data/@posts/1::uvotes=2
!str'/data/@posts/1::dvotes=0

Every line is complete on its own — type, address, value. A server can flush this response line by line, and a client can act on each line as it arrives, because the grammar is regular: there is no closing brace the meaning depends on. JSON.parse needs the last byte before it can give you the first field. And notice the second post: b and title are null on the wire, by type — not absent-and-maybe-forgotten, not "" pretending. The reply row says “no board, no title” in the data itself.

What the server does

Both halves of the exchange go through the reference library — no JSON detour on either side. Requests decode into the same typed structs the JSON dispatcher already uses; responses are built, not serialized:

// Decode: the validated request document → the params struct.
let doc = kaiv::Doc::parse(&daiv)?;
let params = MultiGetParams::from_daiv(&doc.root().view("/params"))?;

// Encode: the result struct → canonical lines, streamed out.
let mut b = kaiv::DaivBuilder::new();
b.leaf("::status", "str", "ok", None)?;
for (i, post) in posts.iter().enumerate() {
    b.leaf(&format!("/data/@posts/{i}::id"), "str", &post.id, None)?;
    // …
}

The dispatcher in the middle never changes: same methods, same structs, same business logic. kaivrpc is a rendering of the contract, not a rewrite of the service.

What it costs, what it buys — numbers

The fair opponent is gRPC — typed entities, compiled contracts, streaming in its bones. (JSON-RPC is not: strings with no schema, no types, no streaming — comparing against it flatters everyone.) So: the same platform, the same service, the same database queries, answered over its gRPC surface and this one. In-process harness, ONE persistent connection per transport, 25 calls per cell after warmup, medians; payload bytes are the protobuf message (encoded_len, the wire body before HTTP/2 framing) against the canonical .daiv body. The “full posts” rows are IDENTICAL payloads — the same rows from the same indexed query, batch-fetched by each surface’s idiomatic call.

case gRPC kaivrpc proto bytes kaiv bytes
full posts ×1 55 ms 53 ms 182 1,032
full posts ×10 56 ms 54 ms 2.1 KB 9.7 KB
full posts ×100 57 ms 68 ms 35.2 KB 108.7 KB
summary list ×1000 74 ms 111 ms — 774.0 KB

And the part a raw-bytes column hides — the same payloads through brotli -q 5 and gzip -6:

payload raw brotli gzip
full posts ×100, protobuf 35,243 6,368 6,972
full posts ×100, kaiv 108,721 10,235 13,886

Three findings:

Latency is parity until payloads get big. Singles and tens: the text surface is at or slightly ahead (52–54 vs 55–56 ms medians) — at interactive sizes the wire format is not the cost. At a hundred full posts protobuf pulls ~12% ahead, and the platform’s 1000-row summary list — which still crosses its generic value-translation path rather than the native serde codec — is kaiv’s worst cell. That last gap is an implementation address, not a format one: the native struct→lines path is what the singles and tens are riding.

Raw, binary wins by 3× — as it must. Protobuf ships field tags and varints; a canonical line ships its whole address: /data/@posts/57::nreplies=0 spells out what a tag byte implies. Nobody should be surprised, and nobody serving payloads this size serves them uncompressed.

Compressed, the gap nearly closes — and the fine print is about flushing, so here is the fine print. brotli finds the namepath repetition and takes the buffered 100-post response from 108.7 KB to 10.2 KB (11:1), landing at 1.6× protobuf’s compressed size. But a buffered ratio is not a streamed ratio: compression efficiency depends on how often the server flushes. Measured on the same payload, same shared brotli context:

flush cadence compressed
one shot (buffered response) 10.2 KB
per element run (one post per flush, 101 flushes) 13.3 KB
per line (2,363 flushes) 37.9 KB
per element, NO shared context 38.5 KB

Three lessons fall out. Streaming and compression coexist — brotli is itself a streaming format, browsers decompress Content-Encoding: br incrementally, and flushing once per element run keeps lines arriving as lines at a ~30% ratio cost (13.3 KB, call it 2× protobuf’s buffered size). Flushing every line forfeits compression — the flush cadence for a streamed kaiv response is the element run, and that is now a design rule, not a preference. And the last row is the interesting one: it is the per-MESSAGE compression model — each unit compressed with no memory of the previous — which is what standard gRPC message compression does, its context dying at every message boundary. A kaiv stream is one HTTP body, so its compression context spans the whole response: streamed-and-compressed, the text format’s bytes can come in UNDER a per-message-compressed binary stream of the same rows. Batching messages buys gRPC its context back, at the same granularity cost kaiv pays with flush cadence — the physics is symmetric, but kaiv’s default posture (one body, one context) starts on the right side of it.

That, plus the difference no table shows — gRPC’s streaming does not reach browser JavaScript, and this surface’s does, same regular grammar over fetch or a WebSocket. One deployment, one day, one platform’s shapes; rerun before believing any specific cell.

What moved

Tally against the JSON-RPC column. The envelope went from four fields to one. The params block went from any to fully typed — down to the shape of a post id — in a schema a client can fetch, read, and validate against before ever sending a request. The validation prose and the server guard code merged into declarations that generate both the refusals and the documentation. Nulls became typed values instead of a convention. And the request became an artifact: a thing you can attach to a ticket, replay against staging, and diff against the request that worked yesterday.

The transport was never the point. The point is that when the request is a document, everything the platform knows about a valid request has one home — and it is the same home the data lives in.