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