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:
"jsonrpc": "2.0"— the format declaration and the schema name version the protocol.id— HTTP correlates request and response; a document is one call.- Batching — HTTP/2 multiplexes, and batch methods like
Post.MultiGetexpress “many at once” where it belongs, in the contract.
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.