Protocol Evolution
This page documents how the Open Sesame IPC protocol handles versioning, forward compatibility, and the addition of new event types without breaking existing daemons.
EventKind and Unknown Variant Deserialization
The protocol schema is defined by the EventKind enum in
core-types/src/events.rs. This enum is marked #[non_exhaustive] and
contains a catch-all variant:
#![allow(unused)]
fn main() {
#[derive(Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub enum EventKind {
// ... all named variants ...
#[serde(other)]
Unknown,
}
}
The #[serde(other)] attribute on the Unknown variant is the
forward-compatibility mechanism. When a daemon receives a
postcard-encoded EventKind with a variant discriminant it does not
recognize (because the sender is running a newer version of the code),
serde deserializes it as EventKind::Unknown instead of returning a
deserialization error.
This means a daemon compiled against an older version of core-types
can receive messages containing event variants that did not exist when it
was compiled. The message deserializes successfully; the daemon sees
EventKind::Unknown and can choose to ignore it, log it, or pass it
through.
The #[non_exhaustive] attribute enforces at compile time that all
match arms on EventKind must include a wildcard or Unknown branch.
This prevents new variants from causing compile errors in downstream
crates that have not been updated.
Postcard Encoding Properties
The IPC bus uses postcard (a #[no_std]-compatible, compact binary
serde format) for all serialization. Several properties of postcard’s
encoding are relevant to protocol evolution.
Externally-Tagged Enums
EventKind uses serde’s default externally-tagged representation.
Postcard encodes externally-tagged enums as a varint discriminant
followed by the variant’s fields in declaration order. The events.rs
source contains an explicit note:
Externally-tagged enum (serde default) for postcard wire compatibility. Postcard does not support
#[serde(tag = "...", content = "...")].
This means:
- Each variant is identified by its position (index) in the enum declaration.
- Adding new variants at the end of the enum produces new discriminant
values that older decoders do not recognize, triggering
#[serde(other)]deserialization toUnknown. - Reordering existing variants would change their discriminants and break all existing decoders. Variant ordering must be append-only.
Positional Field Encoding
Postcard encodes struct fields positionally (by declaration order), not
by name. The Message<T> envelope in message.rs contains a comment
making this explicit:
No
skip_serializing_if– postcard uses positional encoding, so the field must always be present in the wire format for decode compatibility.
This means:
- Every field in
Message<T>must always be serialized, even if its value isNone. Omitting anOptionfield viaskip_serializing_ifwould shift all subsequent fields by one position, causing decode failures. - New fields can only be appended to the end of the struct. The v3
fields (
origin_installation,agent_id,trust_snapshot) are explicitly commented as “v3 fields (appended for positional encoding safety).” - Removing or reordering existing fields is a breaking change.
Implications for Field Addition
When a v3 sender transmits a message with the three new trailing fields
to a v2 receiver, the v2 decoder reads only the fields it knows about
and ignores trailing bytes. Postcard’s from_bytes does not require that
all input bytes be consumed – it reads fields sequentially and stops
when the struct is fully populated. This means appending new Option
fields to Message<T> is backward-compatible as long as older decoders
were compiled without those fields.
When a v2 sender transmits a message missing the v3 trailing fields to
a v3 receiver, postcard::from_bytes encounters end-of-input when
trying to decode the missing fields. In practice, the codebase treats
wire version bumps as requiring atomic deployment of all binaries (see
the wire version section below).
Wire Version Field
The Message<T> struct contains a wire_version: u8 field, always
serialized first. The current value is 3, defined as
pub const WIRE_VERSION: u8 = 3 in message.rs.
The source code documents the wire version contract:
WIRE FORMAT CONTRACT:
v2 fields:
wire_version,msg_id,correlation_id,sender,timestamp,payload,security_level,verified_sender_nameAll v2 binaries must be deployed atomically (single compilation unit). Adding fields requires incrementing this constant and updating the decode path to handle both old and new versions during rolling upgrades.
What the Wire Version Encodes
The wire version tracks changes to the Message<T> envelope
structure – specifically, which fields are present and in what order.
It does not track changes to EventKind variants (those are handled by
#[serde(other)]).
- v2: 8 fields (
wire_versionthroughverified_sender_name) - v3: 11 fields (adds
origin_installation,agent_id,trust_snapshot)
Version Negotiation
The protocol does not perform explicit version negotiation. There is no
handshake phase where client and server agree on a wire version. Instead,
Message::new() always stamps the current WIRE_VERSION, and the source
code states that all binaries must be deployed atomically when the wire
version changes.
A receiver can inspect msg.wire_version after deserialization to
determine which generation of the protocol the sender used. The current
codebase does not implement version-conditional decode logic; all daemons
are expected to be at the same wire version. The comment about “updating
the decode path to handle both old and new versions during rolling
upgrades” describes an intended future capability, not current behavior.
How New Event Variants Are Added
Adding a new EventKind variant follows this procedure:
- Append the new variant to the end of the
EventKindenum incore-types/src/events.rs. Inserting it in the middle would change the discriminant indices of all subsequent variants. - Add a Debug arm in the
impl_event_debug!macro invocation at the bottom ofevents.rs. The macro enforces exhaustiveness – omitting a variant is a compile error. Sensitive variants (containing passwords or secret values) go in thesensitivesection with explicitREDACTEDannotations. All others go in thetransparentsection. - No wire version bump is needed for new
EventKindvariants. TheUnknowncatch-all handles unrecognized discriminants at theEventKindlevel. Wire version bumps are only needed for changes to theMessage<T>envelope structure.
Daemons compiled against the old core-types deserialize the new
variant as EventKind::Unknown. Daemons compiled against the new
core-types see the fully typed variant. Both can coexist on the same
bus.
How New Message Fields Are Added
Adding a new field to Message<T> is a more disruptive change:
- Append the new field to the end of the
Message<T>struct. Postcard’s positional encoding means insertion or reordering breaks all existing decoders. - Increment
WIRE_VERSIONto signal the structural change. - Deploy all binaries atomically. The codebase does not currently implement multi-version decode logic. All daemons must be rebuilt and redeployed together.
- Update
MessageContextif the new field should be populated by the sender (as was done fororigin_installation,agent_id, andtrust_snapshotin v3). - Do not use
skip_serializing_ifon the new field. The field must always be present on the wire for positional decode compatibility.
Practical Constraints
Variant Stability
The EventKind enum currently contains over 80 variants spanning window
management, profile lifecycle, clipboard, input, secrets RPC, launcher
RPC, agent lifecycle, authorization, federation, device posture,
multi-factor auth, and bus-level errors. Each variant’s position in the
enum declaration is its wire discriminant. Removing a variant or changing
its position is a breaking wire change.
Enum Variant Field Changes
Postcard encodes variant fields positionally, the same as struct fields. Adding a field to an existing variant, removing a field, or reordering fields within a variant is a breaking wire change. New fields for existing functionality should be introduced as new variants rather than modifications to existing ones.
Sensitivity Redaction
The Debug implementation for EventKind uses a compile-time
exhaustive macro (impl_event_debug!) that separates sensitive variants
from transparent ones. Sensitive variants (SecretGetResponse,
SecretSet, UnlockRequest, SshUnlockRequest, FactorSubmit) have
their secret-bearing fields rendered as [REDACTED; N bytes] in debug
output. Adding a new variant that carries secret material requires
placing it in the sensitive section of the macro.
Forward Compatibility Boundaries
The #[serde(other)] mechanism provides forward compatibility only for
unknown enum variants. It does not help with:
- Unknown fields within a known variant (postcard has no field-skipping mechanism for positional encoding)
- Structural changes to the
Message<T>envelope - Changes to the framing layer (length-prefix format, encryption chunking)
- Changes to the Noise handshake parameters
These categories of change require coordinated deployment of all binaries.
See Also
- IPC Bus Protocol – transport, framing, and routing details