The wire protocol
You need this chapter if you are writing a client in a language the project does not ship an SDK for, or debugging one that exists.
The IDL is the source of truth.
protocol/vitrin-v0.xml
defines every interface, and where this book and an IDL <description>
disagree, the IDL wins.
docs/protocol/00-conventions.md
is the normative conventions page this chapter summarises.
Shape
Wayland-influenced and deliberately so: object-oriented, per-connection
object ids, requests and events over a Unix socket, file descriptors passed
by SCM_RIGHTS. If you have written a Wayland client, this will feel
familiar.
What is different is that authority is a first-class object. vitrin_grant
is on the wire, with a lifecycle you can observe.
Framing
One message, one frame. All multi-byte integers little-endian. Every frame opens with an 8-byte header:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+---------------------------------------------------------------+
| object_id (u32) |
+-------------------------------+---------------+---------------+
| size (u16) | opcode (u8) | fd_count (u8) |
+-------------------------------+---------------+---------------+
| argument payload |
| Field | Notes |
|---|---|
object_id | The target. Per-connection; referencing an unknown id is fatal invalid_object. |
size | The whole frame including this 8-byte header. Below 8, or a payload shorter than declared, is fatal oversized. The u16 ceiling of 65535 binds senders: do not construct a larger frame. |
opcode | The request or event opcode within the target’s interface, at the negotiated version. |
fd_count | How many descriptors accompany this frame. |
fd_count living in the header is a deliberate framing invariant, not an
accident of the current signatures: a receiver can drop a frame it cannot
interpret and still consume exactly the right number of descriptors, so an
unknown message never desynchronises the fd stream. If the header’s
fd_count disagrees with the target message’s signature, that is fatal.
At most one fd per message. Nothing in v0 needs two.
Argument types
| Type | Encoding |
|---|---|
int | signed 32-bit LE |
uint | unsigned 32-bit LE |
object | u32 object id — may be 0 only where the IDL declares allow-null |
new_id | u32 id the sender allocates for the object being created |
string | length-prefixed, see the conventions page for padding |
fd | not in the payload — carried out-of-band, counted by fd_count |
Two connection classes, one wire format
| Class | Reaches the core by | Interfaces |
|---|---|---|
| Agent principal | Connecting to the core’s listening socket and authenticating | vitrin_handshake, vitrin_principal, vitrin_realm, vitrin_grant, vitrin_consent, vitrin_view, vitrin_actuator_*, vitrin_launcher, vitrin_layout_*, vitrin_egress |
| Shim | Inheriting a socketpair from the core across fork/exec | vitrin_shim_session, vitrin_shim_surface, vitrin_shim_seat |
The classes are mutually unreachable. A message using the other class’s
opcodes dies as fatal invalid_opcode, with no special-casing anywhere.
Note what a shim’s identity is: holding the inherited descriptor. There is no shim credential and no shim handshake — the socketpair is the authentication, because the core created both ends itself.
The interfaces
| Interface | Purpose |
|---|---|
vitrin_handshake | Version + identity hello, resolving to a bound principal |
vitrin_principal | The authenticated principal — root of the connection’s authority chain |
vitrin_realm | Realm address; an authority-free scope handle (realm-0 is the well-known realm and is always served; a deployment may configure more) |
vitrin_grant | Capability handle — the wire projection of one grant-table row |
vitrin_consent | Consent-prompt visibility for one petition (events only, no authority) |
vitrin_view | Observation facet — poll-model frame capture |
vitrin_actuator_pointer | Pointer actuation facet |
vitrin_actuator_text | Text actuation facet |
vitrin_shim_session | Shim connection bootstrap |
vitrin_shim_surface | Shim-to-core buffer path |
vitrin_shim_seat | Input delivery to the shim (events only, origin-tagged) |
vitrin_launcher | Realm-launch facet (since wire version 2) — fork a new realm instance from an operator-written template, under a core-minted id; launch carries no arguments, so the command never crosses the wire |
vitrin_layout_focus | Focus facet (since wire version 2) — bind the output to the granted realm and send the human’s own input there, one act |
vitrin_layout_arrange | Arrangement facet (since wire version 2) — fill the output, or compose at the app’s own size; place, resize, raise and stacking are absent rather than refused |
vitrin_powerbox | Designation facet (since wire version 2) — ask the human to pick one file or one directory subtree and have the descriptor delivered to the realm; no path crosses the wire in either direction. Vocabulary only so far: no deployment serves the verb, so vitrind mints the facet (issue #322) and refuses every ask not_granted until the picker lands |
vitrin_egress | Egress facet (since wire version 2) — one outbound connection to the single host:port the grant names, handed back as a socket fd. No deployment serves the egress verb: vitrind mints the facet and refuses every request_connect not_granted (issue #322), because the out-of-core mediating proxy does not exist |
Each has a prose page under
docs/protocol/.
The petition, in one request
A grant petition co-mints, in a single request: the grant, a consent
observer, and the facets (view, pointer, text). The client allocates a
new_id for each.
The facets are born inert. They are real objects from the moment the request is sent, and they confer nothing until the grant resolves. That shape is why there is no window in which a client holds a live handle to an unapproved capability — the alternative, minting facets after approval, would need a second round trip and a state machine on both sides.
client core
│ request_grant(new_id grant, │
│ new_id consent, │
│ new_id view, ptr, text) │
├────────────────────────────────────────────>│
│ │ petition registered,
│ consent.state(shown) │ prompt raised
│<────────────────────────────────────────────┤
│ │
│ grant.resolved(outcome, verbs, │ human decides
│ expiry_ms, ...) │
│<────────────────────────────────────────────┤
│ │
│ view.capture_frame() │
├────────────────────────────────────────────>│
│ view.frame_ready(+ memfd) │
│<────────────────────────────────────────────┤
resolved carries the effective verbs and expiry, which may be narrower
than requested. A client that assumes it got what it asked for is wrong.
The error razor
The single most important thing to get right.
Fatal — you violated the protocol. The core closes the connection. No retry helps, because the bug is in your client.
invalid_object · invalid_opcode · invalid_argument · oversized ·
fd_violation · pre_handshake · version_unsupported · auth_failed ·
internal · resource_exhausted
Recoverable — your request failed. The connection is healthy and you may try something else.
not_granted · expired · revoked · rate_limited · preempted ·
consent_held · no_surface · internal · capacity
Those are the IDL’s own spellings, which is what a client in another language
has to match. The Python SDK’s exception names are deliberately not all
identical to them — expired is GrantExpired, internal (7, recoverable) is
OperationFailed, capacity is AtCapacity, and the fatal internal (8) is
InternalError — so transcribe from the IDL, never from an SDK.
The line is: did the client send something incoherent, or did a coherent
request get refused? Setting an out-of-range verb bit is incoherent —
fatal. Asking for a verb the core does not serve is coherent — refused,
unsupported. This is exactly why the Python SDK carries every defined verb
bit, served or not, and every defined outcome and refusal code: one it did not
know would turn a recoverable answer into a dead socket.
Ordering
Requests on one connection are processed in order, and events for one object
arrive in order. sync is a barrier: it returns once all prior requests are
processed and their events delivered — petition resolution excepted, since
that waits on a human.
Versioning
Wayland-style. Interfaces grow by appending messages and appending enum values; existing opcodes never change meaning. The negotiated version comes out of the handshake, and each side serves only what that version defines.
Version 0 is frozen for Phase 1 — not forever. The wire integer is now 2, and it appends:
- the
realm_launchverb, andvitrin_principal’sattentionevent; - five structural mints on
vitrin_grant—get_launcher,get_layout_focus,get_layout_arrange,get_powerbox,get_egress; - the five interfaces they mint —
vitrin_launcher(launch/launched),vitrin_layout_focus(focus),vitrin_layout_arrange(set_fullscreen),vitrin_powerbox(request_file,request_dir,designated,refused),vitrin_egress(request_connect,connected,connect_failed) — and thevitrin_layout_arrange.mode,vitrin_powerbox.mode,.kindand.refusal, andvitrin_egress.failureenums; - the
designate_fileverb, thefile:/dir:resource prefixes, andvitrin_shim_session.designation— the powerbox vocabulary, refusedunsupportedby every deployment until the core-drawn picker and its consent copy exist. This core dispatches the messages as of issue #322 —get_powerboxmints, and both requests on the facet it mints refusenot_grantedrecoverably. Until then it had no arm for any of the three, so sending one was fatalinvalid_opcoderather than a mint or a refusal; - the
egressverb (128, at P2.7.2) and thenet:HOST:PORTvalue its authority is named with. That half landed as a verb bit and aresourcegrammar, and no message at all; its facet followed separately. Every deployment still refuses the verbunsupported, because the out-of-core proxy a connection would be made through does not exist — a facet is a request to ask through, not a mechanism to answer with.get_egressandvitrin_egress.request_connectwere unhandled on the same terms, and issue #322 closed both halves at once: the five requests this core did not dispatch were the two mints and the three facet requests behind them — not the two mints alone, which is what this list said while the two facets were landing on parallel branches, and the count is why one fix had to cover all five; - the
capacityrefusal code and thelayout_heldoutcome; - on
vitrin_shim_session, the cross-realm clipboard, the pointer constraints and the idle inhibit (request_selection,offer_selection,pointer_constraint_state,selection,pointer_constraint,idle_inhibit), and onvitrin_shim_seat,relative_motionand the four gesture events — plus the enums their arguments carry.
It changes nothing else: every version-1 signature is byte-identical at
version 2. The complete, normative enumeration — every message, and every
enum with a count to check the list against — is
00-conventions.md §7.3,
and this list restates it. Phase 2 brings semantic trees and epoch/CAS action
semantics. Expect to move.
Validating your understanding
The IDL is machine-checked and so is the code generated from it:
xmllint --noout --relaxng protocol/vitrin-v0.rng protocol/vitrin-v0.xml
cargo xtask codegen --check # generated Rust + C header match the IDL
crates/vitrin-golden holds golden frame vectors, and the Python SDK’s
tests/test_golden_vectors.py checks its encoder against the same bytes.
Those vectors are the cheapest way to validate a new client’s codec.