Skip to content

Wire Protocol

Gabor Galazzo edited this page Jul 25, 2026 · 1 revision

Wire Protocol (v2)

Evento v2 replaced v1's JSON-over-socket protocol with a binary CBOR protocol over Netty (4.2.x). Wire compatibility with v1 is intentionally broken — see Migrating from v1.


1. Frame layout

Every message on the TCP socket is:

[4 bytes length BE]         ← LengthFieldPrepender / LengthFieldBasedFrameDecoder
[envelope header]           ← CBOR-serialised Envelope record
[CBOR payload]

2. The Netty pipeline

Identical on both sides of the connection:

EnvelopeFrameDecoder     (LengthFieldBasedFrameDecoder, maxFrame 16 MB)
EnvelopeFrameEncoder     (LengthFieldPrepender 4)
ChunkReassembler         ← inbound: reassembles CHUNK frames by stream UUID
ChunkingEncoder          ← outbound: FULL frame, or 1..N CHUNK frames
CborMessageDecoder       ← produces Frame(message, rawBytes)
CborMessageEncoder       ← encodes Message; passes ByteBuf through for sendRaw (zero-copy)
IdleStateHandler         ← triggers heartbeat on write idle
HeartbeatHandler         ← sends Ping; closes the channel on reader idle
BackpressureHandler      ← transitions state → DEGRADED at the high-water mark
MessageInboundHandler    ← dispatches Frame on the virtual-thread executor

The Netty EventLoop is never blocked: CBOR decoding and handler dispatch both hand off to the business executor.


3. Chunking — there is no message size limit

ChunkingEncoder wraps each outbound ByteBuf as either:

  • a FULL frame — 0x00 + CBOR data, when it fits in one chunk; or
  • CHUNK frames — 0x01 + 16-byte stream UUID + 1-byte isLast flag + data.

ChunkReassembler buffers chunks per stream UUID and only fires downstream on the last chunk.

maxFrameLength bounds per-chunk memory, not message size. A message larger than the frame limit is transparently split. The server's test suite includes an 80 MB large-payload round trip.


4. The sealed Message hierarchy

In evento-transport-api, package com.evento.transport.message:

Message (sealed interface)
  ├── Hello        (bundleId, instanceId, bundleVersion, authToken, capabilities)
  ├── Welcome      (serverVersion, acceptedCapabilities)
  ├── Reject       (reason — code constants: AUTH_FAILED, VERSION_MISMATCH, …)
  ├── Ping         (seq, ts)
  ├── Pong         (seq, ts)
  ├── Request      (correlationId, payloadType: String, payload: byte[])
  ├── Response     (correlationId, payload: byte[], failure: ResponseError)
  └── Notification (payloadType: String, payload: byte[])

Adding a wire type is exactly two steps: extend the permits clause, and register a byte tag in MessageTypeRegistry. The compiler then fails every non-exhaustive switch in the codebase until the new type is handled — which is the point.

Note what Request / Response / Notification carry: a string payload type and an opaque byte array. The server never deserializes the business payload; see design decision 1 in Architecture Overview.


5. Protocol notifications

Constants in ProtocolNotifications, sent as Notification.payloadType:

Payload type Meaning
evento:bundle-registration Lean registration — bundle version + handler payload types only
evento:enable The bundle is ready to receive messages
evento:disable The bundle should stop receiving messages
evento:bundle-discovery Rich metadata (handlers + payloadInfo schemas), sent after enable

6. Handshake and registration

Bundle                                                   Server
  │── Hello(bundleId, instanceId, version, authToken) ──►│
  │                                              TokenValidator.validate()
  │◄── Welcome(serverVersion) ────────────────────────── │  (or Reject)
  │── Notification(evento:bundle-registration, lean) ───►│  → BundleRegistered
  │── Notification(evento:enable) ──────────────────────►│  → BundleEnabled
  │── Notification(evento:bundle-discovery, rich) ──────►│  → BundleDiscovered
  │                                     CommandBrokerHandler registers LocalRequestHandlers

Why registration and discovery are split. A bundle with thousands of handlers would exceed the 16 MB frame limit if all discovery metadata rode along with registration. So registration stays lean (payload types only) and the rich metadata — handler schemas, payloadInfo, source locations — follows post-enable as its own notification.


7. Zero-copy forwarding

The broker relays Request / Response between bundles using Transport.sendRaw(byte[]) with the raw bytes retained on the inbound Frame. No CBOR re-encoding on the broker hop.

This is a pinned contract, not an optimisation detail: BusLifecycle.forwardedRawCount and forwardedReencodedCount are exposed as the meter evento.server.forwarded{path=raw|reencoded}, and tests assert that every Netty-to-Netty forward takes the raw path.


8. Version tolerance

Wire DTOs evolve by addition, and every codec tolerates unknown properties. All four mappers — JacksonCborCodec, JacksonCborPayloadCodec, AdminPayloadCodec, ObjectMapperUtils — disable FAIL_ON_UNKNOWN_PROPERTIES, so a peer one version ahead does not get its payload rejected. Records normalise null to a default in their @JsonCreator, which covers the opposite skew. CodecVersionToleranceTest pins both directions.

History — why this matters. The transport codecs used to be strict. A derived isAsync() getter on RegisteredHandler therefore made Jackson emit an async property that the broker rejected, which killed the entire BundleDiscoveryInfo. The failure was nearly invisible: the bundle registered, enabled and consumed normally while all of its handler metadata silently vanished from the dashboard, with only event=listener_error … decode failed for BundleDiscoveryInfo in the log.

Strictness was never the security control here. The deserialization defence is MessageTypeRegistry and the PolymorphicTypeValidator, which bound which types may be instantiated; skipping an unrecognised property on an already-whitelisted type instantiates nothing. See Security Model.

When adding a field to a wire DTO: keep both properties in place, and give a new record component a null-normalising @JsonCreator.


See also

Clone this wiki locally