Vev session protocol
This document defines the observable Protobuf session contract and its QUIC,
Unix, and SSH stdio carriage rules. Each invariant maps to a current test or
benchmark.
Message directions
internal/protocol/messages.go closes both unions. The daemon rejects a
wrong-direction frame before any mutation (sessionwire.ErrWrongDirection
→ protocol.DecodeWrongDirection).
Client → server: Hello, Input, Resize, Detach, Ping, List,
Kill, Theme, Ack, ImagePush, ClientNotice, CommandRequest,
OutputResetRequest, SelectTab, TerminalFocus, ModifierState, RemotePreviewWatch,
RouteAttentionSubscription, SamePeerSwitchRequest,
RecentRouteSnapshot, RouteNavigationFailure,
SessionCreationFailure, UIFence, NavigationInventoryRequest,
NavigationInventoryPublication, NavigationInventoryFailure,
PickerClose, PickerSelection.
Server → client: Welcome, ErrorMsg, Output, Detached, Pong,
Sessions, CommandResult, AttachTarget, RemotePreview,
CommittedRouteIdentity, RouteNavigationAction,
RouteCreateSessionAction, RouteNavigationFailure, RoutePosition,
RouteRetired, SamePeerSwitchFailure, UIReceipt, UIViewUpdate,
NavigationInventoryResponse, NavigationInventoryDemand,
NavigationInventorySelection, PickerOffer, PickerSnapshot,
PickerClosed, PickerResult, PickerFailure, KillResult.
RouteNavigationFailure is the one payload that appears in both unions.
sessionwire conversion switches (decodeProtoClient/decodeProtoServer,
encodeProtoClient/encodeProtoServer) are exhaustive over the
directional ClientEnvelope/ServerEnvelope oneof unions; unknown
or opposite-direction payloads fail with DecodeUnknownType /
DecodeMalformed before any mutation.
Handshake timeline
One absolute 15-second budget (protocol.HandshakeTimeout) covers dial
through the first committed publication, on both ends. Every
connection opens with exactly one preamble envelope
(PreambleRequest client → server, PreambleResponse server →
client) carrying magic, epoch, version (exact equality mandatory),
role, and the immutable per-connection ceilings; the preamble is
bounded to 4 KiB before allocation and is never replayed on resume.
Hello.MaxOutputInFlight stays a per-attachment claim capped by the
negotiated ceiling.
- Client (
internal/usecase/client/handshake.go): newHandshakeContext
owns a single timer; boundedDial detaches the dial context so a
successful carriage outlives the handshake; Close is the only
interruption mechanism for blocked transport operations.
- Daemon (
internal/usecase/daemon/handshake.go, daemon.go:handleConn):
newHandshakeContext + watchHandshakeTransport close the exact
admitted connection on timeout; failHandshakeAttachment retires only
that connection and purges a route-created session only when empty. An
accepted connection that implements the optional
ports.HandshakeDeadlineProvider seam supplies the absolute deadline it
was admitted with, which the daemon adopts verbatim: queue delay is
consumed instead of restarting the budget, and an already-elapsed
deadline fails promptly. Connections without the seam keep a fresh
budget from accept. Completion stays where the daemon commits its
initial publication; the sessionwire preamble's own completion is not
the handshake deadline.
First frame routing (daemon.go:handleConn): Hello → full attach;
List, CommandRequest, NavigationInventoryRequest, and Kill are
one-shot control connections answered and closed. RemotePreviewWatch
streams RemotePreview frames until the client closes. Anything else, including any
malformed first frame, gets a typed compatibility response and no session
mutation:
| First-frame failure kind |
Response |
Needs RequestID |
DecodeMessageHello, version mismatch |
ErrorMsg{ErrVersionMismatch} |
no |
DecodeMessageHello, malformed |
ErrorMsg{ErrInternal} |
no |
DecodeMessageCommand |
CommandResult{Outcome, Code, Text} |
yes, else silent close |
DecodeMessageKill |
ErrorMsg{ErrInternal} |
no — a malformed kill has no reliable RequestID to correlate a KillResult to |
DecodeMessageRemotePreview |
RemotePreview{Status: Malformed} |
no |
DecodeMessageNavigationInventory |
NavigationInventoryResponse{Invalid / VersionMismatch} |
yes, else silent close |
| other / unknown |
ErrorMsg{ErrInternal, "expected hello"} |
no |
Live checks after decode: Hello.Version, CommandRequest.Version,
NavigationInventoryRequest.Version, and the watch's RemotePreviewRequest.Version
(independent catalogue-era schema version 1) must equal the negotiated
constant or the request fails
with the version-mismatch response above. There is no multi-version
negotiation: equality is mandatory.
Hello.color (ColorCapabilities{mode, source}, field 26) is the only carrier
of terminal color capability; Theme has no capability field. mode is a
closed enum (TRUE_COLOR, ANSI256, ANSI16, MONOCHROME) and a missing
message, an UNSPECIFIED mode, or any unknown enum value is a malformed Hello
(sessionwire rejects it before any mutation). source UNSPECIFIED means
"unknown": the daemon then ignores the claimed mode and re-detects from
Hello.Env; DECLARED and FORCED claims are used verbatim. A HEURISTIC truecolor claim
upgrades a weaker daemon-side detection; any other HEURISTIC claim falls back
to detection.
(internal/domain/terminalcap.Resolve; wire tests in
sessionwire/proto_convert_test.go:TestProtoHelloColorWire.)
Command requests additionally carry a 10-second result deadline
(daemon/command_tracker.go:CommandRequestTimeout), tracked per
connection and correlated by RequestID. Every dispatched request produces
one closed CommandResult outcome: succeeded, failed, or outcome unknown.
Timeout or reply loss after dispatch is outcome unknown and is never replayed.
Broker negotiation
Broker connections are a separate conversation from the session protocol.
internal/adapters/brokerwire owns it: client tags 101-114 and server tags
201-211 are disjoint from every session tag, and each direction is its own
closed directional oneof union. A broker connection opens with exactly one
broker preamble (PreambleRequest client → server, PreambleResponse server
→ client) using broker roles 3/4. The preamble carries the same magic
(wire.PreambleMagic), epoch (wire.ProtocolEpoch), exact protocol.Version
equality, and negotiated ceilings as the session handshake (envelope
1 MiB..16 MiB, stream chunk 1..64 KiB), and the serialized preamble is
bounded to 4 KiB (wire.PreambleLimit, exposed as
brokerwire.CheckPreambleSize) before scan or allocation.
Refusals use the shared PreambleRejectionCode taxonomy. There is no
capability negotiation: nonzero capability bits are refused as code 7
(limit refused), and code 6 (out of order) is reserved for the
connection dispatcher.
internal/adapters/brokeripc carries this conversation over broker.sock
in the per-user runtime directory, with same-user peer-credential admission
and bounded per-connection queues. Each logical stream is credit flow
controlled in both directions: a sender spends StreamChunkCredit (payload
plus 64 bytes) per data frame out of a 4 MiB window
(brokerwire.StreamWindowBytes) and waits when it has too little, and the
receiver returns the credit of frames its consumer has read in
StreamWindowUpdate batches. A slow consumer therefore slows its own stream
instead of losing it. Data or cumulative credit beyond the window is a
flow-control violation that settles only that stream; a single grant of zero
or above the window is a malformed frame and fails the connection. The remote helpers _broker-mux-stdio,
_broker-mux-quic-bootstrap, and _broker-mux-quic-proxy bridge a remote
daemonmux socket to stdio or to one freshly authenticated QUIC stream.
Every daemonmux logical stream is credit flow controlled in both directions.
Each side starts with the same per-stream byte window, derived from the
negotiated ceilings (aggregate bytes / max streams, 512 KiB by default). A
MuxData frame costs its payload plus 64 bytes of credit; the writer waits
when credit runs out, and the receiver returns consumed credit with
MuxWindowUpdate once half a window is drained. A slow link or a slow
consumer therefore slows its own stream without affecting siblings. Data
beyond the granted window, or credit beyond the window, is a peer violation
and resets only that stream.
Setup is fully bounded: the dialer's one context/deadline covers the Unix
dial, preamble, Register send, and wait for Registered, and the server requires Register within
the same accept-time handshake budget that bounded the preamble and admission.
Output, ACK, and flow control
An Output state is (Epoch, Base, New, Echo, ViewRevision, Size, Full, Context?, Data). State-bearing output (New != 0) requires a validated
ViewContext (non-zero publication, committed route identity, valid tab
and pane IDs); side-effect output (New == 0) carries no context and no
base. ValidateOutput enforces the epoch/base/new chain
(New == Base+1 unless Full with Base == 0).
Ack{Epoch, State} is cumulative per epoch; epoch 0 is invalid.
- At most
MaxOutputWindow (8) states may be in flight per attachment
(daemon.go:maxUnackedOutputStates); the daemon negotiates the window
down from the transport capabilities (Hello.MaxOutputInFlight claim
capped by the transport ceiling; datagram carriage reports 1). A client
claims its carriage's preferred window, or MaxOutputWindow when the
carriage states none, so output pipelines instead of waiting a round trip
per frame.
- Unacknowledged Output data is also bounded by
MaxOutputWindowBytes
(2 MiB). State frames are charged to their own state, side effects to the
next state, and a cumulative ACK releases what it covers. The next state
frame waits when either window is full, except that a frame may always be
sent while no state frame is in flight. A frame carrying a large image may
exceed the byte window; it then holds the next frame until its ACK.
- The client coalesces ACKs (
client.go:cumulativeAckQueue) and keeps
applying + acknowledging daemon frames even while a picker lease owns
the terminal (see below) — admission without physical write.
Echo is the newest InputSeq the daemon applied at least 50 ms ago
(mosh's echo timeout), so the screen it ships with already holds that
input's echo. When no frame is due, the daemon sends it in an empty
side-effect Output (New == 0). Clients use it to confirm predicted
echo. OutputResetRequest restarts the chain; UIFence{ActionID} orders UI
operations and UIReceipt reports the processed boundary
(Processed requires epoch/state/publication; Unavailable carries
none).
Compression (sessionwire Output converters): only full snapshots at or above
1024 bytes are zlib candidates, and only when the compressed form plus
header is smaller. Decoded length is validated against
MaxOutputDataLen before allocation; decompression rejects
length-excess, trailing bytes, and non-Full compressed states.
Byte ownership: Recv returns freshly allocated, right-sized payload
copies the caller may retain; Send/SendAsync copies into the
adapter's queue. IPC reuses one grow-once read buffer internally.
Picker admission without physical write
While a client picker overlay is open over a live attachment (session
picker PickerOffer, or the first move PickerSnapshot), the client
still applies and acknowledges every accepted daemon frame but does not
write it. Closing the overlay sends OutputResetRequest, and the daemon
answers with an authoritative full paint.
The daemon sends a palette-opened picker only after a frame composed
after the palette closed has been emitted, so the erased palette is on
screen before output suppression starts. An ACK-blocked paint or a
transient freeze delays the picker; a detach or transport change drops it. The daemon side drops raw
key/mouse input for the attachment while a move picker is open; only
typed picker messages act.
Ordering, close, and admission semantics
- Each connection is one ordered reliable stream. Concurrent
Send calls
are serialized by a single writer (IPC) or a mutex (SSH stdio);
exactly one Recv caller at a time.
Close is concurrent-safe, idempotent, and unblocks in-flight
Send/Recv with a terminal error (io.EOF on clean close).
- Bounded admission, three preserved contracts (
ports +
wire.Transport): synchronous wire attempt (Send), bounded async
admission (SendAsync, 8 queued frames / 32 MiB bytes,
ErrBackpressure when full), owned bounded synchronous operation.
Buffer ownership, cancellation, and error semantics of each
return are unchanged. Synchronous sends rendezvous with the writer
and carry no async byte-budget charge; async sends are charged on
admission and released exactly once on dequeue.
Current state: the one-shot kill control paths have moved to the explicit
result protocol. Kill carries a unique nonzero RequestID, and the daemon
answers every normally decoded kill request with exactly one correlated
KillResult before the control connection closes: KillSucceeded,
KillFailed (with a bounded structured Failures list), or a definite
KillOutcomeUnknown. The client consumes that result; a close without a
result, a mismatched RequestID, or a wrong-typed reply is outcome unknown
and is never replayed, so a control failure whose response cannot be delivered
is never mistaken for success. CommandRequest follows the same correlated
model through CommandResult: succeeded, failed, or outcome unknown. A timeout,
close, cancellation, malformed reply, or mismatched RequestID after send is
outcome unknown and is never replayed.
The remaining one-shot controls (List, remote preview, inventory, and picker
control) do not treat close as success, but they have no closed outcome field.
The daemon attempts one response and then closes; a client that receives no
valid response reports a receive error. An undeliverable response is logged
only where the handler has a send-failure path, so these controls remain weaker
than KillResult and CommandResult.
Limits
| Item |
Ceiling |
Owner |
| Preamble envelope |
4 KiB (wire.PreambleLimit), exact magic/epoch/version/role |
protocol/wire/preamble.go, sessionwire/proto_handshake.go |
| Absolute envelope |
16 MiB (wire.AbsoluteEnvelopeLimit), enforced before allocation |
protocol/wire/preamble.go, streamframe |
| Control (non-Output, non-image) envelope |
1 MiB (wire.ControlEnvelopeLimit) |
protocol/wire/preamble.go |
Output.Data |
MaxOutputDataLen = 16776809 (16 MiB minus worst-case Protobuf overhead) |
protocol/session.go |
| Image envelope |
16 MiB absolute (image data rides the envelope ceiling) |
sessionwire image converters |
| Queued envelopes per connection |
8 messages, 32 MiB bytes (streamframe defaults), then backpressure; sync sends uncharged, async charged once |
adapters/streamframe |
| Encoding buffer |
bounded per-connection framing buffers owned by streamframe |
adapters/streamframe |
| Output states in flight |
8 (MaxOutputWindow) preferred for QUIC/IPC/SSH |
daemon, negotiated down |
| Unacknowledged Output bytes |
2 MiB (MaxOutputWindowBytes); one frame always allowed when none is in flight |
daemon |
| Broker stream window |
4 MiB per stream and direction (brokerwire.StreamWindowBytes), returned by StreamWindowUpdate |
adapters/brokerwire, enforced in adapters/brokeripc |
| QUIC unauthenticated peers |
4 connections, 3 s auth deadline, one bidirectional stream each; extra/unidirectional streams rejected |
adapters/quic |
| QUIC bootstrap |
32-byte token + 16-byte nonce (base64), ≤4 KiB readiness (schema v1, host-independent port, SHA-256 pin, ≤15 s expiry), one bounded (512 B) auth record, atomic one-time consumption, token erasure on close |
adapters/quic bootstrap, app/_broker-mux-quic-bootstrap + _broker-mux-quic-proxy |
| Handshake, dial → first committed publication |
15 s, single absolute deadline propagated unchanged, never reset |
both ends |
| Command result |
10 s per request |
daemon/command_tracker.go |
RemotePreview viewport |
256×128 cells, 1 MiB |
protocol/preview.go |
| Route snapshot publication |
32 entries |
protocol/routes.go |
| Route label |
256 bytes, UTF-8, no controls |
protocol/routes.go |
| SSH shutdown wait |
3 s (sshCloseTimeout) |
adapters/sshstdio |
Framing on IPC/SSH stdio/QUIC stream: 4-byte big-endian length, then one complete
serialized Protobuf envelope (no type byte). Zero-length envelopes and
lengths above the ceiling fail without allocation. The strict scanner
rejects short reads, trailing bytes, duplicate variants, and unknown
fields before generated unmarshal.
Authentication boundary
No session bytes are accepted before authentication and version
acceptance. Local carriage requires the preamble + Hello/Welcome
inside the 15-second budget; a failed handshake closes the exact
connection. Broker-owned remote carriage uses QUIC (internal/adapters/quic,
TLS 1.3, ALPN vev/1, exact SHA-256 pin, one bidirectional stream,
no 0-RTT); a host added with vev host add --transport ssh uses SSH stdio instead.
For the QUIC route the broker starts one short-lived _broker-mux-quic-bootstrap --production over SSH
which spawns a detached _broker-mux-quic-proxy (ephemeral listener +
certificate, 32-byte token, ≤4 KiB readiness with host-independent
port/pin/expiry), prints the single readiness line, and exits. The
broker resolves the SSH target host, composes host:port, pins the
certificate, and sends one bounded auth record before any preamble.
The token is consumed atomically on first use; concurrent, replayed,
or expired auth fails closed without private daemonmux access, and the proxy exits
after session termination, expiry, or setup failure with token
material erased.
Error taxonomy
- Envelope level: short payload, trailing bytes, duplicate variants,
unknown fields, invalid wire types, oversize — all fail closed before
generated unmarshal, no partial values escape.
- Typed boundary:
protocol.DecodeFailure{Category, Kind, Version, RequestID?, Err} with categories
Malformed | UnknownType | WrongDirection. The numeric Type field is
retained only for log compatibility; direction is structural (closed
oneof unions per direction). Payload bytes are never retained.
- Transport level:
io.EOF (close), ErrFrameTooLarge,
ErrZeroLengthFrame, ErrBackpressure, process-exit errors for SSH.
LinkState reports transport connectivity only, never semantic
handshake acceptance.
Benchmarks
Run the protocol, framing, and typed-connection benchmarks from the repository
root:
go test ./internal/protocol/wire -run '^$' -bench 'BenchmarkHelloEnvelope|BenchmarkInputEnvelope|BenchmarkImageEnvelope|BenchmarkOutput' -benchmem -count 5
go test ./internal/adapters/ipc -run '^$' -bench 'BenchmarkTransport' -benchmem -count 5
go test ./internal/adapters/sessionwire -run '^$' -bench 'BenchmarkTyped|BenchmarkOutputProductionRoundTrip' -benchmem -count 5
Scanner specifications are memoized by descriptor (specCache), so each
message shape pays reflection once per process. Application and QUIC performance
budgets and reproducible evidence commands live in performance.md.
- QUIC:
github.com/quic-go/quic-go v0.62.0 (MIT). Verified API surface:
TLS 1.3 (MinVersion: tls.VersionTLS13), ALPN-gated single bidirectional
stream (AcceptStream server / OpenStreamSync client,
MaxIncomingStreams: 1, MaxIncomingUniStreams: 0, extra streams
rejected), stream cancellation (CancelRead/CancelWrite), stream
deadlines (SetDeadline/SetReadDeadline/SetWriteDeadline),
keepalive/idle limits (KeepAlivePeriod, MaxIdleTimeout,
HandshakeIdleTimeout), listener ownership (Listener.Accept/Close),
explicit close mapping (CloseWithError), 0-RTT disabled by using only
DialAddr/ListenAddr (never *Early), resumption observable via
ConnectionState.Used0RTT, tracing hook for observability.
No QUIC types leak past internal/adapters/quic + internal/app
composition; no datagrams, no 0-RTT mutations.
- Protobuf:
google.golang.org/protobuf v1.36.12 runtime +
protoc-gen-go v1.36.12 pinned as a Go tool
(tool google.golang.org/protobuf/cmd/protoc-gen-go in go.mod);
generation via github.com/bufbuild/buf/cmd/buf v1.73.0 pinned as a Go
tool (tool github.com/bufbuild/buf/cmd/buf). Sources live in
internal/protocol/wire/schema/, generated Go lands directly in
internal/protocol/wire (never edited); reproducibility command:
go tool buf generate (config buf.gen.yaml), verified by
Makefile:protocol-check. No gRPC.
- Supporting dependencies are pinned in
go.mod and go.sum; QUIC requires
the corresponding x/crypto, x/net, and x/sys modules.
source
1# Vev session protocol
2
3This document defines the observable Protobuf session contract and its QUIC,
4Unix, and SSH stdio carriage rules. Each invariant maps to a current test or
5benchmark.
6
7## Message directions
8
9`internal/protocol/messages.go` closes both unions. The daemon rejects a
10wrong-direction frame before any mutation (`sessionwire.ErrWrongDirection`
11→ `protocol.DecodeWrongDirection`).
12
13Client → server: `Hello`, `Input`, `Resize`, `Detach`, `Ping`, `List`,
14`Kill`, `Theme`, `Ack`, `ImagePush`, `ClientNotice`, `CommandRequest`,
15`OutputResetRequest`, `SelectTab`, `TerminalFocus`, `ModifierState`, `RemotePreviewWatch`,
16`RouteAttentionSubscription`, `SamePeerSwitchRequest`,
17`RecentRouteSnapshot`, `RouteNavigationFailure`,
18`SessionCreationFailure`, `UIFence`, `NavigationInventoryRequest`,
19`NavigationInventoryPublication`, `NavigationInventoryFailure`,
20`PickerClose`, `PickerSelection`.
21
22Server → client: `Welcome`, `ErrorMsg`, `Output`, `Detached`, `Pong`,
23`Sessions`, `CommandResult`, `AttachTarget`, `RemotePreview`,
24`CommittedRouteIdentity`, `RouteNavigationAction`,
25`RouteCreateSessionAction`, `RouteNavigationFailure`, `RoutePosition`,
26`RouteRetired`, `SamePeerSwitchFailure`, `UIReceipt`, `UIViewUpdate`,
27`NavigationInventoryResponse`, `NavigationInventoryDemand`,
28`NavigationInventorySelection`, `PickerOffer`, `PickerSnapshot`,
29`PickerClosed`, `PickerResult`, `PickerFailure`, `KillResult`.
30
31`RouteNavigationFailure` is the one payload that appears in both unions.
32`sessionwire` conversion switches (`decodeProtoClient`/`decodeProtoServer`,
33`encodeProtoClient`/`encodeProtoServer`) are exhaustive over the
34directional `ClientEnvelope`/`ServerEnvelope` `oneof` unions; unknown
35or opposite-direction payloads fail with `DecodeUnknownType` /
36`DecodeMalformed` before any mutation.
37
38## Handshake timeline
39
40One absolute 15-second budget (`protocol.HandshakeTimeout`) covers dial
41through the first committed publication, on both ends. Every
42connection opens with exactly one preamble envelope
43(`PreambleRequest` client → server, `PreambleResponse` server →
44client) carrying magic, epoch, version (exact equality mandatory),
45role, and the immutable per-connection ceilings; the preamble is
46bounded to 4 KiB before allocation and is never replayed on resume.
47`Hello.MaxOutputInFlight` stays a per-attachment claim capped by the
48negotiated ceiling.
49
50- Client (`internal/usecase/client/handshake.go`): `newHandshakeContext`
51 owns a single timer; `boundedDial` detaches the dial context so a
52 successful carriage outlives the handshake; `Close` is the only
53 interruption mechanism for blocked transport operations.
54- Daemon (`internal/usecase/daemon/handshake.go`, `daemon.go:handleConn`):
55 `newHandshakeContext` + `watchHandshakeTransport` close the exact
56 admitted connection on timeout; `failHandshakeAttachment` retires only
57 that connection and purges a route-created session only when empty. An
58 accepted connection that implements the optional
59 `ports.HandshakeDeadlineProvider` seam supplies the absolute deadline it
60 was admitted with, which the daemon adopts verbatim: queue delay is
61 consumed instead of restarting the budget, and an already-elapsed
62 deadline fails promptly. Connections without the seam keep a fresh
63 budget from accept. Completion stays where the daemon commits its
64 initial publication; the sessionwire preamble's own completion is not
65 the handshake deadline.
66
67First frame routing (`daemon.go:handleConn`): `Hello` → full attach;
68`List`, `CommandRequest`, `NavigationInventoryRequest`, and `Kill` are
69one-shot control connections answered and closed. `RemotePreviewWatch`
70streams `RemotePreview` frames until the client closes. Anything else, including any
71malformed first frame, gets a typed compatibility response and no session
72mutation:
73
74| First-frame failure kind | Response | Needs RequestID |
75|---|---|---|
76| `DecodeMessageHello`, version mismatch | `ErrorMsg{ErrVersionMismatch}` | no |
77| `DecodeMessageHello`, malformed | `ErrorMsg{ErrInternal}` | no |
78| `DecodeMessageCommand` | `CommandResult{Outcome, Code, Text}` | yes, else silent close |
79| `DecodeMessageKill` | `ErrorMsg{ErrInternal}` | no — a malformed kill has no reliable RequestID to correlate a `KillResult` to |
80| `DecodeMessageRemotePreview` | `RemotePreview{Status: Malformed}` | no |
81| `DecodeMessageNavigationInventory` | `NavigationInventoryResponse{Invalid / VersionMismatch}` | yes, else silent close |
82| other / unknown | `ErrorMsg{ErrInternal, "expected hello"}` | no |
83
84Live checks after decode: `Hello.Version`, `CommandRequest.Version`,
85`NavigationInventoryRequest.Version`, and the watch's `RemotePreviewRequest.Version`
86(independent catalogue-era schema version `1`) must equal the negotiated
87constant or the request fails
88with the version-mismatch response above. There is no multi-version
89negotiation: equality is mandatory.
90
91`Hello.color` (`ColorCapabilities{mode, source}`, field 26) is the only carrier
92of terminal color capability; `Theme` has no capability field. `mode` is a
93closed enum (`TRUE_COLOR`, `ANSI256`, `ANSI16`, `MONOCHROME`) and a missing
94message, an `UNSPECIFIED` mode, or any unknown enum value is a malformed Hello
95(`sessionwire` rejects it before any mutation). `source` `UNSPECIFIED` means
96"unknown": the daemon then ignores the claimed mode and re-detects from
97`Hello.Env`; `DECLARED` and `FORCED` claims are used verbatim. A `HEURISTIC` truecolor claim
98upgrades a weaker daemon-side detection; any other `HEURISTIC` claim falls back
99to detection.
100(`internal/domain/terminalcap.Resolve`; wire tests in
101`sessionwire/proto_convert_test.go:TestProtoHelloColorWire`.)
102
103Command requests additionally carry a 10-second result deadline
104(`daemon/command_tracker.go:CommandRequestTimeout`), tracked per
105connection and correlated by `RequestID`. Every dispatched request produces
106one closed `CommandResult` outcome: succeeded, failed, or outcome unknown.
107Timeout or reply loss after dispatch is outcome unknown and is never replayed.
108
109## Broker negotiation
110
111Broker connections are a separate conversation from the session protocol.
112`internal/adapters/brokerwire` owns it: client tags 101-114 and server tags
113201-211 are disjoint from every session tag, and each direction is its own
114closed directional `oneof` union. A broker connection opens with exactly one
115broker preamble (`PreambleRequest` client → server, `PreambleResponse` server
116→ client) using broker roles 3/4. The preamble carries the same magic
117(`wire.PreambleMagic`), epoch (`wire.ProtocolEpoch`), exact `protocol.Version`
118equality, and negotiated ceilings as the session handshake (envelope
1191 MiB..16 MiB, stream chunk 1..64 KiB), and the serialized preamble is
120bounded to 4 KiB (`wire.PreambleLimit`, exposed as
121`brokerwire.CheckPreambleSize`) before scan or allocation.
122
123Refusals use the shared `PreambleRejectionCode` taxonomy. There is no
124capability negotiation: nonzero capability bits are refused as code 7
125(`limit refused`), and code 6 (`out of order`) is reserved for the
126connection dispatcher.
127
128`internal/adapters/brokeripc` carries this conversation over `broker.sock`
129in the per-user runtime directory, with same-user peer-credential admission
130and bounded per-connection queues. Each logical stream is credit flow
131controlled in both directions: a sender spends `StreamChunkCredit` (payload
132plus 64 bytes) per data frame out of a 4 MiB window
133(`brokerwire.StreamWindowBytes`) and waits when it has too little, and the
134receiver returns the credit of frames its consumer has read in
135`StreamWindowUpdate` batches. A slow consumer therefore slows its own stream
136instead of losing it. Data or cumulative credit beyond the window is a
137flow-control violation that settles only that stream; a single grant of zero
138or above the window is a malformed frame and fails the connection. The remote helpers `_broker-mux-stdio`,
139`_broker-mux-quic-bootstrap`, and `_broker-mux-quic-proxy` bridge a remote
140daemonmux socket to stdio or to one freshly authenticated QUIC stream.
141
142Every daemonmux logical stream is credit flow controlled in both directions.
143Each side starts with the same per-stream byte window, derived from the
144negotiated ceilings (aggregate bytes / max streams, 512 KiB by default). A
145`MuxData` frame costs its payload plus 64 bytes of credit; the writer waits
146when credit runs out, and the receiver returns consumed credit with
147`MuxWindowUpdate` once half a window is drained. A slow link or a slow
148consumer therefore slows its own stream without affecting siblings. Data
149beyond the granted window, or credit beyond the window, is a peer violation
150and resets only that stream.
151
152Setup is fully bounded: the dialer's one context/deadline covers the Unix
153dial, preamble, Register send, and wait for Registered, and the server requires Register within
154the same accept-time handshake budget that bounded the preamble and admission.
155
156## Output, ACK, and flow control
157
158An `Output` state is `(Epoch, Base, New, Echo, ViewRevision, Size, Full,
159Context?, Data)`. State-bearing output (`New != 0`) requires a validated
160`ViewContext` (non-zero publication, committed route identity, valid tab
161and pane IDs); side-effect output (`New == 0`) carries no context and no
162base. `ValidateOutput` enforces the epoch/base/new chain
163(`New == Base+1` unless `Full` with `Base == 0`).
164
165- `Ack{Epoch, State}` is cumulative per epoch; epoch `0` is invalid.
166- At most `MaxOutputWindow` (8) states may be in flight per attachment
167 (`daemon.go:maxUnackedOutputStates`); the daemon negotiates the window
168 down from the transport capabilities (`Hello.MaxOutputInFlight` claim
169 capped by the transport ceiling; datagram carriage reports 1). A client
170 claims its carriage's preferred window, or `MaxOutputWindow` when the
171 carriage states none, so output pipelines instead of waiting a round trip
172 per frame.
173- Unacknowledged Output data is also bounded by `MaxOutputWindowBytes`
174 (2 MiB). State frames are charged to their own state, side effects to the
175 next state, and a cumulative ACK releases what it covers. The next state
176 frame waits when either window is full, except that a frame may always be
177 sent while no state frame is in flight. A frame carrying a large image may
178 exceed the byte window; it then holds the next frame until its ACK.
179- The client coalesces ACKs (`client.go:cumulativeAckQueue`) and keeps
180 applying + acknowledging daemon frames even while a picker lease owns
181 the terminal (see below) — admission without physical write.
182- `Echo` is the newest `InputSeq` the daemon applied at least 50 ms ago
183 (mosh's echo timeout), so the screen it ships with already holds that
184 input's echo. When no frame is due, the daemon sends it in an empty
185 side-effect `Output` (`New == 0`). Clients use it to confirm predicted
186 echo. `OutputResetRequest` restarts the chain; `UIFence{ActionID}` orders UI
187 operations and `UIReceipt` reports the processed boundary
188 (`Processed` requires epoch/state/publication; `Unavailable` carries
189 none).
190
191Compression (`sessionwire` Output converters): only full snapshots at or above
1921024 bytes are zlib candidates, and only when the compressed form plus
193header is smaller. Decoded length is validated against
194`MaxOutputDataLen` before allocation; decompression rejects
195length-excess, trailing bytes, and non-`Full` compressed states.
196
197Byte ownership: `Recv` returns freshly allocated, right-sized payload
198copies the caller may retain; `Send`/`SendAsync` copies into the
199adapter's queue. IPC reuses one grow-once read buffer internally.
200
201## Picker admission without physical write
202
203While a client picker overlay is open over a live attachment (session
204picker `PickerOffer`, or the first move `PickerSnapshot`), the client
205still applies and acknowledges every accepted daemon frame but does not
206write it. Closing the overlay sends `OutputResetRequest`, and the daemon
207answers with an authoritative full paint.
208
209The daemon sends a palette-opened picker only after a frame composed
210after the palette closed has been emitted, so the erased palette is on
211screen before output suppression starts. An ACK-blocked paint or a
212transient freeze delays the picker; a detach or transport change drops it. The daemon side drops raw
213key/mouse input for the attachment while a move picker is open; only
214typed picker messages act.
215
216## Ordering, close, and admission semantics
217
218- Each connection is one ordered reliable stream. Concurrent `Send` calls
219 are serialized by a single writer (IPC) or a mutex (SSH stdio);
220 exactly one `Recv` caller at a time.
221- `Close` is concurrent-safe, idempotent, and unblocks in-flight
222 `Send`/`Recv` with a terminal error (`io.EOF` on clean close).
223- Bounded admission, three preserved contracts (`ports` +
224 `wire.Transport`): synchronous wire attempt (`Send`), bounded async
225 admission (`SendAsync`, 8 queued frames / 32 MiB bytes,
226 `ErrBackpressure` when full), owned bounded synchronous operation.
227 Buffer ownership, cancellation, and error semantics of each
228 return are unchanged. Synchronous sends rendezvous with the writer
229 and carry no async byte-budget charge; async sends are charged on
230 admission and released exactly once on dequeue.
231
232Current state: the one-shot kill control paths have moved to the explicit
233result protocol. `Kill` carries a unique nonzero `RequestID`, and the daemon
234answers every normally decoded kill request with exactly one correlated
235`KillResult` before the control connection closes: `KillSucceeded`,
236`KillFailed` (with a bounded structured `Failures` list), or a definite
237`KillOutcomeUnknown`. The client consumes that result; a close without a
238result, a mismatched `RequestID`, or a wrong-typed reply is `outcome unknown`
239and is never replayed, so a control failure whose response cannot be delivered
240is never mistaken for success. `CommandRequest` follows the same correlated
241model through `CommandResult`: succeeded, failed, or outcome unknown. A timeout,
242close, cancellation, malformed reply, or mismatched `RequestID` after send is
243outcome unknown and is never replayed.
244
245The remaining one-shot controls (`List`, remote preview, inventory, and picker
246control) do not treat close as success, but they have no closed outcome field.
247The daemon attempts one response and then closes; a client that receives no
248valid response reports a receive error. An undeliverable response is logged
249only where the handler has a send-failure path, so these controls remain weaker
250than `KillResult` and `CommandResult`.
251
252## Limits
253
254| Item | Ceiling | Owner |
255|---|---|---|
256| Preamble envelope | 4 KiB (`wire.PreambleLimit`), exact magic/epoch/version/role | `protocol/wire/preamble.go`, `sessionwire/proto_handshake.go` |
257| Absolute envelope | 16 MiB (`wire.AbsoluteEnvelopeLimit`), enforced before allocation | `protocol/wire/preamble.go`, `streamframe` |
258| Control (non-Output, non-image) envelope | 1 MiB (`wire.ControlEnvelopeLimit`) | `protocol/wire/preamble.go` |
259| `Output.Data` | `MaxOutputDataLen` = 16776809 (16 MiB minus worst-case Protobuf overhead) | `protocol/session.go` |
260| Image envelope | 16 MiB absolute (image data rides the envelope ceiling) | `sessionwire` image converters |
261| Queued envelopes per connection | 8 messages, 32 MiB bytes (`streamframe` defaults), then backpressure; sync sends uncharged, async charged once | `adapters/streamframe` |
262| Encoding buffer | bounded per-connection framing buffers owned by `streamframe` | `adapters/streamframe` |
263| Output states in flight | 8 (`MaxOutputWindow`) preferred for QUIC/IPC/SSH | daemon, negotiated down |
264| Unacknowledged Output bytes | 2 MiB (`MaxOutputWindowBytes`); one frame always allowed when none is in flight | daemon |
265| Broker stream window | 4 MiB per stream and direction (`brokerwire.StreamWindowBytes`), returned by `StreamWindowUpdate` | `adapters/brokerwire`, enforced in `adapters/brokeripc` |
266| QUIC unauthenticated peers | 4 connections, 3 s auth deadline, one bidirectional stream each; extra/unidirectional streams rejected | `adapters/quic` |
267| QUIC bootstrap | 32-byte token + 16-byte nonce (base64), ≤4 KiB readiness (schema v1, host-independent port, SHA-256 pin, ≤15 s expiry), one bounded (512 B) auth record, atomic one-time consumption, token erasure on close | `adapters/quic` bootstrap, `app/_broker-mux-quic-bootstrap` + `_broker-mux-quic-proxy` |
268| Handshake, dial → first committed publication | 15 s, single absolute deadline propagated unchanged, never reset | both ends |
269| Command result | 10 s per request | `daemon/command_tracker.go` |
270| `RemotePreview` viewport | 256×128 cells, 1 MiB | `protocol/preview.go` |
271| Route snapshot publication | 32 entries | `protocol/routes.go` |
272| Route label | 256 bytes, UTF-8, no controls | `protocol/routes.go` |
273| SSH shutdown wait | 3 s (`sshCloseTimeout`) | `adapters/sshstdio` |
274
275Framing on IPC/SSH stdio/QUIC stream: 4-byte big-endian length, then one complete
276serialized Protobuf envelope (no type byte). Zero-length envelopes and
277lengths above the ceiling fail without allocation. The strict scanner
278rejects short reads, trailing bytes, duplicate variants, and unknown
279fields before generated unmarshal.
280
281## Authentication boundary
282
283No session bytes are accepted before authentication and version
284acceptance. Local carriage requires the preamble + `Hello`/`Welcome`
285inside the 15-second budget; a failed handshake closes the exact
286connection. Broker-owned remote carriage uses QUIC (`internal/adapters/quic`,
287TLS 1.3, ALPN `vev/1`, exact SHA-256 pin, one bidirectional stream,
288no 0-RTT); a host added with `vev host add --transport ssh` uses SSH stdio instead.
289For the QUIC route the broker starts one short-lived `_broker-mux-quic-bootstrap --production` over SSH
290which spawns a detached `_broker-mux-quic-proxy` (ephemeral listener +
291certificate, 32-byte token, ≤4 KiB readiness with host-independent
292port/pin/expiry), prints the single readiness line, and exits. The
293broker resolves the SSH target host, composes `host:port`, pins the
294certificate, and sends one bounded auth record before any preamble.
295The token is consumed atomically on first use; concurrent, replayed,
296or expired auth fails closed without private daemonmux access, and the proxy exits
297after session termination, expiry, or setup failure with token
298material erased.
299
300## Error taxonomy
301
302- Envelope level: short payload, trailing bytes, duplicate variants,
303 unknown fields, invalid wire types, oversize — all fail closed before
304 generated unmarshal, no partial values escape.
305- Typed boundary: `protocol.DecodeFailure{Category, Kind, Version,
306 RequestID?, Err}` with categories
307 `Malformed | UnknownType | WrongDirection`. The numeric `Type` field is
308 retained only for log compatibility; direction is structural (closed
309 `oneof` unions per direction). Payload bytes are never retained.
310- Transport level: `io.EOF` (close), `ErrFrameTooLarge`,
311 `ErrZeroLengthFrame`, `ErrBackpressure`, process-exit errors for SSH.
312 `LinkState` reports transport connectivity only, never semantic
313 handshake acceptance.
314
315## Benchmarks
316
317Run the protocol, framing, and typed-connection benchmarks from the repository
318root:
319
320```sh
321go test ./internal/protocol/wire -run '^$' -bench 'BenchmarkHelloEnvelope|BenchmarkInputEnvelope|BenchmarkImageEnvelope|BenchmarkOutput' -benchmem -count 5
322go test ./internal/adapters/ipc -run '^$' -bench 'BenchmarkTransport' -benchmem -count 5
323go test ./internal/adapters/sessionwire -run '^$' -bench 'BenchmarkTyped|BenchmarkOutputProductionRoundTrip' -benchmem -count 5
324```
325
326Scanner specifications are memoized by descriptor (`specCache`), so each
327message shape pays reflection once per process. Application and QUIC performance
328budgets and reproducible evidence commands live in [performance.md](performance.md).
329
330## Tooling
331
332- QUIC: `github.com/quic-go/quic-go v0.62.0` (MIT). Verified API surface:
333 TLS 1.3 (`MinVersion: tls.VersionTLS13`), ALPN-gated single bidirectional
334 stream (`AcceptStream` server / `OpenStreamSync` client,
335 `MaxIncomingStreams: 1`, `MaxIncomingUniStreams: 0`, extra streams
336 rejected), stream cancellation (`CancelRead`/`CancelWrite`), stream
337 deadlines (`SetDeadline`/`SetReadDeadline`/`SetWriteDeadline`),
338 keepalive/idle limits (`KeepAlivePeriod`, `MaxIdleTimeout`,
339 `HandshakeIdleTimeout`), listener ownership (`Listener.Accept/Close`),
340 explicit close mapping (`CloseWithError`), 0-RTT disabled by using only
341 `DialAddr`/`ListenAddr` (never `*Early`), resumption observable via
342 `ConnectionState.Used0RTT`, tracing hook for observability.
343 No QUIC types leak past `internal/adapters/quic` + `internal/app`
344 composition; no datagrams, no 0-RTT mutations.
345- Protobuf: `google.golang.org/protobuf v1.36.12` runtime +
346 `protoc-gen-go v1.36.12` pinned as a Go tool
347 (`tool google.golang.org/protobuf/cmd/protoc-gen-go` in `go.mod`);
348 generation via `github.com/bufbuild/buf/cmd/buf v1.73.0` pinned as a Go
349 tool (`tool github.com/bufbuild/buf/cmd/buf`). Sources live in
350 `internal/protocol/wire/schema/`, generated Go lands directly in
351 `internal/protocol/wire` (never edited); reproducibility command:
352 `go tool buf generate` (config `buf.gen.yaml`), verified by
353 `Makefile:protocol-check`. No gRPC.
354- Supporting dependencies are pinned in `go.mod` and `go.sum`; QUIC requires
355 the corresponding `x/crypto`, `x/net`, and `x/sys` modules.