Skip to content

Envelope format

When an actor has an eventAdapter() (or stateAdapter()), every persisted payload is wrapped in a versioned envelope before write:

{
"_v": 2, // version
"_t": "deposited", // type tag
"_e": { "kind": "deposited", "amount": 100, "currency": "USD" }
}

On read, the framework unwraps this and routes through the adapter’s upcast(payload, version). The adapter sees the version + the payload; it returns the current-shape event.

FieldTypePurpose
_vnumberThe version this payload was written under.
_tstringThe type tag — typically the event/state name. Optional but useful for tooling.
_eobjectThe actual payload.

Underscores so they don’t collide with user fields. The framework considers any object with these three keys an envelope.

class Account extends PersistentActor<...> {
override eventAdapter() {
return new SomeAdapter();
}
}

Setting an adapter makes the framework wrap events on write + unwrap on read. Without an adapter, events are written raw — no envelope.

This is strict on read: once you set an adapter, reading a non-envelope event throws MigrationError. You can’t mix enveloped + raw events for the same pid without WrapLegacy (see below).

For first-time deployments (fresh journal), you can either start with adapters from the beginning (every event has _v: 1) or defer adapter introduction until you actually need migration. Most teams add adapters only when the first breaking change arrives.

interface EventAdapter<E> {
upcast(stored: unknown, version: number): E;
}

stored is the _e payload — without the envelope. version is the _v value. The adapter’s job:

  • Inspect version.
  • If it’s the current version, cast stored to E (the current shape) and return.
  • If it’s older, transform stored to the current shape, then return.

The framework’s built-in adapters (DefaultAdapter, MigratingAdapter) encapsulate this pattern. Custom adapters implement the same.

When you want to bump the version:

class Account extends PersistentActor<...> {
override eventAdapter() {
return new MigratingAdapter<EventV2>({
currentVersion: 2,
chain: [
{ from: 1, to: 2, fn: v1ToV2 },
],
});
}
}

The adapter declares the current version. New events written by onCommand now get _v: 2 envelopes. Old _v: 1 events get upcast through the chain.

This works regardless of what version a given event was written under — the migration chain handles each.

If you’re adding an adapter to an existing journal that has already-persisted raw events:

class Account extends PersistentActor<...> {
override eventAdapter() {
return wrapLegacy<EventV2>({
legacyVersion: 1,
baseAdapter: new MigratingAdapter<EventV2>({
currentVersion: 2,
chain: [{ from: 1, to: 2, fn: v1ToV2 }],
}),
});
}
}

wrapLegacy treats any non-envelope event as version 1 and feeds it through the rest of the chain. See WrapLegacy for details.

The envelope is a regular JSON object — serialized by whatever serializer your journal uses (JSON for the SQLite + in-memory journals, customizable for Cassandra).

This means:

  • JSON-safe types only in _e — no functions, no Symbols, no BigInts without a custom serializer.
  • Deeply-nested objects work — the envelope is one level deep; _e itself can be arbitrarily complex.
  • Binary-friendly via CBOR — if you configure CBOR serialization, the envelope serializes as CBOR (more compact, binary-friendly).