Files
zeekay d36ef57f4a docs: add Capabilities page + Target-model pipelining in RPC
- New content/docs/capabilities.mdx: cap model (issue/attenuate/verify/
  verifyChain/revoke), SPEC §2.3/§3/§4 delegation rules, fail-closed scheme
  dispatch, per-language status (Go/Python/Rust/TS all ship caps), and verified
  per-runtime examples. Wired into meta.json nav after RPC.
- rpc.mdx: replace the thin pipelining blurb with the canonical Target model
  (promiseID/target, Session + Pipeliner) and a real Go/Python/TS example.
  All API names verified against go/cap, py/src/zap/cap, spec/src/cap.rs,
  js/src/cap.ts and the pipeline runtimes.
2026-06-21 07:44:02 -07:00

251 lines
8.4 KiB
Plaintext

---
title: RPC System
description: ZAP's capability RPC — the call envelope, generated clients and servers, and promise pipelining.
---
# RPC System
An `interface` in a [schema](/docs/schema) is not just a struct — it is a set of
callable methods. ZAP's RPC system turns those declarations into a typed client
and an abstract server, carried over the wire by a small, fixed **call
envelope**. It is a capability system: a call can target a specific object, and
results can be used as arguments to further calls before they have even arrived
(promise pipelining).
## The call envelope
Every request is a ZAP message tagged as a router frame. Its fixed fields are:
| Field | Type | Meaning |
| --- | --- | --- |
| `method` | `UInt32` | The interface method ordinal — the schema's `@N`. |
| `promiseID` | `UInt32` | Identifies a result to pipeline against, or `0`. |
| `target` | `UInt32` | The capability this call is addressed to (`NO_TARGET = 0`). |
| `cap` | `Data` | The capability token, when one is required. |
| `payload` | `Data` | The request struct's bytes. |
A response carries:
| Field | Type | Meaning |
| --- | --- | --- |
| `status` | `UInt32` | `200` ok; `4xx`/`5xx` on error. |
| `promiseID` | `UInt32` | Correlates the response to its request. |
| `body` | `Data` | The response struct's bytes. |
Because the method is addressed by ordinal — never by name — appending a method
to an interface never renumbers the existing ones, so a new client and an old
server interoperate as long as they share the methods they both know.
## Status codes
| Code | Name |
| --- | --- |
| `200` | OK |
| `400` | BadRequest |
| `401` | Unauthorized |
| `403` | Forbidden |
| `404` | NotFound |
| `500` | Internal |
## Generated client and server
From an `interface`, code generation emits a typed client, an abstract server,
and the method-ordinal table. For this schema:
```zap
struct EchoReq
msg Text
struct EchoResp
msg Text
interface Echo
echo (req EchoReq) -> (resp EchoResp)
notify (n EchoReq)
```
the TypeScript backend ([`zapgen`](/docs/codegen)) emits an `EchoClient` with one
`async` method per declaration and an abstract `EchoServer` whose `dispatch`
decodes the ordinal and routes to the matching handler.
### Server
Implement the abstract handlers; `dispatch` does the envelope decode and ordinal
routing for you.
```typescript
import { EchoServer } from './gen/echo_zap.js';
class Echo extends EchoServer {
async echo(req: EchoReq): Promise<EchoResp> {
return newEchoResp({ msg: req.msg() });
}
async notify(n: EchoReq): Promise<void> {
console.log('notified:', n.msg());
}
}
```
### Client
```typescript
import { ZapClient } from '@zap-proto/zap/node';
import { EchoClient } from './gen/echo_zap.js';
const conn = await ZapClient.connect('localhost:9000');
const echo = new EchoClient(conn);
const resp = await echo.echo({ msg: 'hello' });
console.log(resp.msg()); // "hello"
conn.close();
```
## Driving the envelope directly
Generated clients are the normal path, but the envelope is a public API. Build a
request, send the bytes over any transport, and decode the response:
```typescript
import { buildRequest, parseResponse, NO_TARGET, Status, Message } from '@zap-proto/zap';
const reqBytes = buildRequest({
method: 1, // the interface method ordinal
promiseID: 0,
target: NO_TARGET,
cap: new Uint8Array(0),
payload, // the request struct's bytes
});
// ... send reqBytes, receive responseBytes ...
const res = parseResponse(responseBytes);
if (res.status === Status.OK) {
const result = Message.parse(res.body).root(); // response struct, zero-copy
}
```
## Transport framing
The envelope is transport-agnostic; the Node TCP client
(`@zap-proto/zap/node`) frames it as `[len UInt32 LE][payload]` with a 10 MB cap,
opens with a nodeID handshake, and correlates responses to requests by
`promiseID`. The same envelope rides over WebSocket and HTTP transports — see
[Transports](/docs/transports).
## Promise pipelining
ZAP RPC is Level-1: the result of one call can be used as the input of another
before the first result has returned. This collapses a dependent two-round-trip
exchange into one.
### The Target model
There is **one** canonical pipelining model, byte-identical across the Go,
TypeScript, and Python runtimes. It rides on two envelope fields already shown
above — `promiseID` and `target` — and needs no new wire format:
- A call carries a caller-assigned **`promiseID`** — the id its answer resolves
to.
- A **dependent** call sets **`target`** to a prior call's `promiseID`. That
means: *before dispatching me, substitute the resolved body of the call that
answered to that `promiseID` as my payload.*
The two calls ship back to back; the server chains them. The dependent never
waits for the first answer to round-trip back to the caller. A non-pipelined call
sets `target = NO_TARGET` (`0`), so a pipelining server and a plain dispatcher
are wire-compatible — a non-pipelining peer in any language interoperates by
simply never setting `target`.
Two pieces implement it, each in one place:
- **`Session`** (client side) allocates `promiseID`s and stamps `target` onto a
dependent call — `Origin`/`origin` builds the first call, `Pipeline`/`pipe`
builds the dependent.
- **`Pipeliner`** (server side) is a per-connection promise table. It records
every OK answer under its `promiseID`, substitutes a resolved target's body for
a dependent's payload before dispatch, and **queues** a dependent whose target
has not resolved yet until it does. It **refuses** (`400 BadRequest`) a
dependent whose target answered non-OK or was finished — so it never hangs.
`Finish` bounds the table by dropping an answer once no further call will
pipeline on it.
### Example
Authenticate, then immediately pipeline a second call on the auth result:
<Tabs items={["Go", "Python", "TypeScript"]}>
<Tab value="Go">
```go
import "github.com/zap-proto/go/rpc"
sess := rpc.NewSession()
srv := rpc.NewPipeliner(DispatchAccount(handler)) // wraps a generated Dispatch<Iface>
// A: authenticate (Target = NoTarget). Its answer resolves to promise p.
p := sess.Next()
aResp, _ := srv.Handle(rpc.BuildRequest(sess.Origin(p, AuthOrdinal, capToken, authReq)))
// B: pipeline on A — the server feeds A's resolved body in as B's payload.
q := sess.Next()
bResp, _ := srv.Handle(rpc.BuildRequest(sess.Pipeline(q, p, GetBalanceOrdinal, capToken, nil)))
```
</Tab>
<Tab value="Python">
```python
from zap import Session, Pipeliner, build_request
sess = Session()
srv = Pipeliner(dispatch) # dispatch(envelope) -> response envelope
# A: authenticate (target = NO_TARGET). Its answer resolves to promise p.
p = sess.next()
a_resp = srv.handle(build_request(sess.origin(p, AUTH_ORDINAL, cap_token, auth_req)))
# B: pipeline on A — the server feeds A's resolved body in as B's payload.
q = sess.next()
b_resp = srv.handle(build_request(sess.pipeline(q, p, GET_BALANCE_ORDINAL, cap_token, b"")))
```
</Tab>
<Tab value="TypeScript">
```typescript
import { Session, Pipeliner, buildRequest } from '@zap-proto/zap';
const sess = new Session();
const srv = new Pipeliner(dispatch); // (envelope) => Promise<responseEnvelope>
// A: authenticate (target = NO_TARGET). Its answer resolves to promise p.
const p = sess.next();
const aResp = await srv.handle(buildRequest(sess.origin(p, AUTH_ORDINAL, capToken, authReq)));
// B: pipeline on A — the server feeds A's resolved body in as B's payload.
const q = sess.next();
const bResp = await srv.handle(buildRequest(sess.pipe(q, p, GET_BALANCE_ORDINAL, capToken)));
```
</Tab>
</Tabs>
The request/response envelopes (`BuildRequest`/`build_request`/`buildRequest`)
are byte-for-byte identical across these three runtimes, so the same pipelined
exchange round-trips between any of them. The Rust stack (`zap-rpc`) implements
the richer Cap'n-Proto `PromisedAnswer` (transform-path) model — a superset that
interoperates at the envelope level for non-pipelined calls. The Node TCP
transport additionally exposes a two-connection `pipeline` helper
(`@zap-proto/zap/node`) that ships the dependent leg on a second socket so two
calls are genuinely in flight at the wire level.
## Next Steps
<Cards>
<Card title="Schema Language" href="/docs/schema">
Declare the interfaces RPC is generated from
</Card>
<Card title="Code Generation" href="/docs/codegen">
Generate the typed clients and servers
</Card>
<Card title="Transports" href="/docs/transports">
Carry the envelope over TCP, WebSocket, or HTTP
</Card>
</Cards>