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, 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.
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-111 and server tags
201-209 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. 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).
- 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 carries the client's last input acknowledgement inside output;
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 |
| 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); VEV_REMOTE_TRANSPORT=stdio selects 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`, `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
91Command requests additionally carry a 10-second result deadline
92(`daemon/command_tracker.go:CommandRequestTimeout`), tracked per
93connection and correlated by `RequestID`. Every dispatched request produces
94one closed `CommandResult` outcome: succeeded, failed, or outcome unknown.
95Timeout or reply loss after dispatch is outcome unknown and is never replayed.
96
97## Broker negotiation
98
99Broker connections are a separate conversation from the session protocol.
100`internal/adapters/brokerwire` owns it: client tags 101-111 and server tags
101201-209 are disjoint from every session tag, and each direction is its own
102closed directional `oneof` union. A broker connection opens with exactly one
103broker preamble (`PreambleRequest` client → server, `PreambleResponse` server
104→ client) using broker roles 3/4. The preamble carries the same magic
105(`wire.PreambleMagic`), epoch (`wire.ProtocolEpoch`), exact `protocol.Version`
106equality, and negotiated ceilings as the session handshake (envelope
1071 MiB..16 MiB, stream chunk 1..64 KiB), and the serialized preamble is
108bounded to 4 KiB (`wire.PreambleLimit`, exposed as
109`brokerwire.CheckPreambleSize`) before scan or allocation.
110
111Refusals use the shared `PreambleRejectionCode` taxonomy. There is no
112capability negotiation: nonzero capability bits are refused as code 7
113(`limit refused`), and code 6 (`out of order`) is reserved for the
114connection dispatcher.
115
116`internal/adapters/brokeripc` carries this conversation over `broker.sock`
117in the per-user runtime directory, with same-user peer-credential admission
118and bounded per-connection queues. The remote helpers `_broker-mux-stdio`,
119`_broker-mux-quic-bootstrap`, and `_broker-mux-quic-proxy` bridge a remote
120daemonmux socket to stdio or to one freshly authenticated QUIC stream.
121
122Every daemonmux logical stream is credit flow controlled in both directions.
123Each side starts with the same per-stream byte window, derived from the
124negotiated ceilings (aggregate bytes / max streams, 512 KiB by default). A
125`MuxData` frame costs its payload plus 64 bytes of credit; the writer waits
126when credit runs out, and the receiver returns consumed credit with
127`MuxWindowUpdate` once half a window is drained. A slow link or a slow
128consumer therefore slows its own stream without affecting siblings. Data
129beyond the granted window, or credit beyond the window, is a peer violation
130and resets only that stream.
131
132Setup is fully bounded: the dialer's one context/deadline covers the Unix
133dial, preamble, Register send, and wait for Registered, and the server requires Register within
134the same accept-time handshake budget that bounded the preamble and admission.
135
136## Output, ACK, and flow control
137
138An `Output` state is `(Epoch, Base, New, Echo, ViewRevision, Size, Full,
139Context?, Data)`. State-bearing output (`New != 0`) requires a validated
140`ViewContext` (non-zero publication, committed route identity, valid tab
141and pane IDs); side-effect output (`New == 0`) carries no context and no
142base. `ValidateOutput` enforces the epoch/base/new chain
143(`New == Base+1` unless `Full` with `Base == 0`).
144
145- `Ack{Epoch, State}` is cumulative per epoch; epoch `0` is invalid.
146- At most `MaxOutputWindow` (8) states may be in flight per attachment
147 (`daemon.go:maxUnackedOutputStates`); the daemon negotiates the window
148 down from the transport capabilities (`Hello.MaxOutputInFlight` claim
149 capped by the transport ceiling; datagram carriage reports 1).
150- The client coalesces ACKs (`client.go:cumulativeAckQueue`) and keeps
151 applying + acknowledging daemon frames even while a picker lease owns
152 the terminal (see below) — admission without physical write.
153- `Echo` carries the client's last input acknowledgement inside output;
154 `OutputResetRequest` restarts the chain; `UIFence{ActionID}` orders UI
155 operations and `UIReceipt` reports the processed boundary
156 (`Processed` requires epoch/state/publication; `Unavailable` carries
157 none).
158
159Compression (`sessionwire` Output converters): only full snapshots at or above
1601024 bytes are zlib candidates, and only when the compressed form plus
161header is smaller. Decoded length is validated against
162`MaxOutputDataLen` before allocation; decompression rejects
163length-excess, trailing bytes, and non-`Full` compressed states.
164
165Byte ownership: `Recv` returns freshly allocated, right-sized payload
166copies the caller may retain; `Send`/`SendAsync` copies into the
167adapter's queue. IPC reuses one grow-once read buffer internally.
168
169## Picker admission without physical write
170
171While a client picker overlay is open over a live attachment (session
172picker `PickerOffer`, or the first move `PickerSnapshot`), the client
173still applies and acknowledges every accepted daemon frame but does not
174write it. Closing the overlay sends `OutputResetRequest`, and the daemon
175answers with an authoritative full paint.
176
177The daemon sends a palette-opened picker only after a frame composed
178after the palette closed has been emitted, so the erased palette is on
179screen before output suppression starts. An ACK-blocked paint or a
180transient freeze delays the picker; a detach or transport change drops it. The daemon side drops raw
181key/mouse input for the attachment while a move picker is open; only
182typed picker messages act.
183
184## Ordering, close, and admission semantics
185
186- Each connection is one ordered reliable stream. Concurrent `Send` calls
187 are serialized by a single writer (IPC) or a mutex (SSH stdio);
188 exactly one `Recv` caller at a time.
189- `Close` is concurrent-safe, idempotent, and unblocks in-flight
190 `Send`/`Recv` with a terminal error (`io.EOF` on clean close).
191- Bounded admission, three preserved contracts (`ports` +
192 `wire.Transport`): synchronous wire attempt (`Send`), bounded async
193 admission (`SendAsync`, 8 queued frames / 32 MiB bytes,
194 `ErrBackpressure` when full), owned bounded synchronous operation.
195 Buffer ownership, cancellation, and error semantics of each
196 return are unchanged. Synchronous sends rendezvous with the writer
197 and carry no async byte-budget charge; async sends are charged on
198 admission and released exactly once on dequeue.
199
200Current state: the one-shot kill control paths have moved to the explicit
201result protocol. `Kill` carries a unique nonzero `RequestID`, and the daemon
202answers every normally decoded kill request with exactly one correlated
203`KillResult` before the control connection closes: `KillSucceeded`,
204`KillFailed` (with a bounded structured `Failures` list), or a definite
205`KillOutcomeUnknown`. The client consumes that result; a close without a
206result, a mismatched `RequestID`, or a wrong-typed reply is `outcome unknown`
207and is never replayed, so a control failure whose response cannot be delivered
208is never mistaken for success. `CommandRequest` follows the same correlated
209model through `CommandResult`: succeeded, failed, or outcome unknown. A timeout,
210close, cancellation, malformed reply, or mismatched `RequestID` after send is
211outcome unknown and is never replayed.
212
213The remaining one-shot controls (`List`, remote preview, inventory, and picker
214control) do not treat close as success, but they have no closed outcome field.
215The daemon attempts one response and then closes; a client that receives no
216valid response reports a receive error. An undeliverable response is logged
217only where the handler has a send-failure path, so these controls remain weaker
218than `KillResult` and `CommandResult`.
219
220## Limits
221
222| Item | Ceiling | Owner |
223|---|---|---|
224| Preamble envelope | 4 KiB (`wire.PreambleLimit`), exact magic/epoch/version/role | `protocol/wire/preamble.go`, `sessionwire/proto_handshake.go` |
225| Absolute envelope | 16 MiB (`wire.AbsoluteEnvelopeLimit`), enforced before allocation | `protocol/wire/preamble.go`, `streamframe` |
226| Control (non-Output, non-image) envelope | 1 MiB (`wire.ControlEnvelopeLimit`) | `protocol/wire/preamble.go` |
227| `Output.Data` | `MaxOutputDataLen` = 16776809 (16 MiB minus worst-case Protobuf overhead) | `protocol/session.go` |
228| Image envelope | 16 MiB absolute (image data rides the envelope ceiling) | `sessionwire` image converters |
229| Queued envelopes per connection | 8 messages, 32 MiB bytes (`streamframe` defaults), then backpressure; sync sends uncharged, async charged once | `adapters/streamframe` |
230| Encoding buffer | bounded per-connection framing buffers owned by `streamframe` | `adapters/streamframe` |
231| Output states in flight | 8 (`MaxOutputWindow`) preferred for QUIC/IPC/SSH | daemon, negotiated down |
232| QUIC unauthenticated peers | 4 connections, 3 s auth deadline, one bidirectional stream each; extra/unidirectional streams rejected | `adapters/quic` |
233| 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` |
234| Handshake, dial → first committed publication | 15 s, single absolute deadline propagated unchanged, never reset | both ends |
235| Command result | 10 s per request | `daemon/command_tracker.go` |
236| `RemotePreview` viewport | 256×128 cells, 1 MiB | `protocol/preview.go` |
237| Route snapshot publication | 32 entries | `protocol/routes.go` |
238| Route label | 256 bytes, UTF-8, no controls | `protocol/routes.go` |
239| SSH shutdown wait | 3 s (`sshCloseTimeout`) | `adapters/sshstdio` |
240
241Framing on IPC/SSH stdio/QUIC stream: 4-byte big-endian length, then one complete
242serialized Protobuf envelope (no type byte). Zero-length envelopes and
243lengths above the ceiling fail without allocation. The strict scanner
244rejects short reads, trailing bytes, duplicate variants, and unknown
245fields before generated unmarshal.
246
247## Authentication boundary
248
249No session bytes are accepted before authentication and version
250acceptance. Local carriage requires the preamble + `Hello`/`Welcome`
251inside the 15-second budget; a failed handshake closes the exact
252connection. Broker-owned remote carriage uses QUIC (`internal/adapters/quic`,
253TLS 1.3, ALPN `vev/1`, exact SHA-256 pin, one bidirectional stream,
254no 0-RTT); `VEV_REMOTE_TRANSPORT=stdio` selects SSH stdio instead.
255For the QUIC route the broker starts one short-lived `_broker-mux-quic-bootstrap --production` over SSH
256which spawns a detached `_broker-mux-quic-proxy` (ephemeral listener +
257certificate, 32-byte token, ≤4 KiB readiness with host-independent
258port/pin/expiry), prints the single readiness line, and exits. The
259broker resolves the SSH target host, composes `host:port`, pins the
260certificate, and sends one bounded auth record before any preamble.
261The token is consumed atomically on first use; concurrent, replayed,
262or expired auth fails closed without private daemonmux access, and the proxy exits
263after session termination, expiry, or setup failure with token
264material erased.
265
266## Error taxonomy
267
268- Envelope level: short payload, trailing bytes, duplicate variants,
269 unknown fields, invalid wire types, oversize — all fail closed before
270 generated unmarshal, no partial values escape.
271- Typed boundary: `protocol.DecodeFailure{Category, Kind, Version,
272 RequestID?, Err}` with categories
273 `Malformed | UnknownType | WrongDirection`. The numeric `Type` field is
274 retained only for log compatibility; direction is structural (closed
275 `oneof` unions per direction). Payload bytes are never retained.
276- Transport level: `io.EOF` (close), `ErrFrameTooLarge`,
277 `ErrZeroLengthFrame`, `ErrBackpressure`, process-exit errors for SSH.
278 `LinkState` reports transport connectivity only, never semantic
279 handshake acceptance.
280
281## Benchmarks
282
283Run the protocol, framing, and typed-connection benchmarks from the repository
284root:
285
286```sh
287go test ./internal/protocol/wire -run '^$' -bench 'BenchmarkHelloEnvelope|BenchmarkInputEnvelope|BenchmarkImageEnvelope|BenchmarkOutput' -benchmem -count 5
288go test ./internal/adapters/ipc -run '^$' -bench 'BenchmarkTransport' -benchmem -count 5
289go test ./internal/adapters/sessionwire -run '^$' -bench 'BenchmarkTyped|BenchmarkOutputProductionRoundTrip' -benchmem -count 5
290```
291
292Scanner specifications are memoized by descriptor (`specCache`), so each
293message shape pays reflection once per process. Application and QUIC performance
294budgets and reproducible evidence commands live in [performance.md](performance.md).
295
296## Tooling
297
298- QUIC: `github.com/quic-go/quic-go v0.62.0` (MIT). Verified API surface:
299 TLS 1.3 (`MinVersion: tls.VersionTLS13`), ALPN-gated single bidirectional
300 stream (`AcceptStream` server / `OpenStreamSync` client,
301 `MaxIncomingStreams: 1`, `MaxIncomingUniStreams: 0`, extra streams
302 rejected), stream cancellation (`CancelRead`/`CancelWrite`), stream
303 deadlines (`SetDeadline`/`SetReadDeadline`/`SetWriteDeadline`),
304 keepalive/idle limits (`KeepAlivePeriod`, `MaxIdleTimeout`,
305 `HandshakeIdleTimeout`), listener ownership (`Listener.Accept/Close`),
306 explicit close mapping (`CloseWithError`), 0-RTT disabled by using only
307 `DialAddr`/`ListenAddr` (never `*Early`), resumption observable via
308 `ConnectionState.Used0RTT`, tracing hook for observability.
309 No QUIC types leak past `internal/adapters/quic` + `internal/app`
310 composition; no datagrams, no 0-RTT mutations.
311- Protobuf: `google.golang.org/protobuf v1.36.12` runtime +
312 `protoc-gen-go v1.36.12` pinned as a Go tool
313 (`tool google.golang.org/protobuf/cmd/protoc-gen-go` in `go.mod`);
314 generation via `github.com/bufbuild/buf/cmd/buf v1.73.0` pinned as a Go
315 tool (`tool github.com/bufbuild/buf/cmd/buf`). Sources live in
316 `internal/protocol/wire/schema/`, generated Go lands directly in
317 `internal/protocol/wire` (never edited); reproducibility command:
318 `go tool buf generate` (config `buf.gen.yaml`), verified by
319 `Makefile:protocol-check`. No gRPC.
320- Supporting dependencies are pinned in `go.mod` and `go.sum`; QUIC requires
321 the corresponding `x/crypto`, `x/net`, and `x/sys` modules.