Skip to content

Actions

This content is for the 0.1 version. Switch to the latest version for up-to-date documentation.

Everything that changes state is a StateAction. There is one sealed type with 85 variants, and every one of them arrives wrapped in an envelope that says which channel it belongs to and where it sits in the sequence.

final class ActionEnvelope {
final String channel; // which channel this mutates
final StateAction action; // what changed
final int? serverSeq; // position in the connection-global sequence
final ActionOrigin? origin; // who caused it
final String? rejectionReason;
}

serverSeq is global to the connection, not per-channel. That is what makes a gap detectable at all, and it is why gap detection lives on the mirror rather than on an individual store.

StateAction is sealed, so a switch over it is exhaustive and the compiler tells you when upstream adds a variant you have not handled:

switch (action) {
case StateActionChatDelta(:final value):
print('${value.turnId}: ${value.delta}');
case StateActionSessionReady(:final value):
print('session ready: ${value.type}');
case StateActionUnknown(:final raw):
// A newer host sent something this client predates.
break;
default:
break;
}

Each variant wraps a payload class carrying that action’s fields. The variant is the discriminant; value is the data.

StateActionUnknown is not an error path. The specification requires clients to silently ignore actions they do not understand, so an unrecognized type decodes into it, keeps the raw JSON verbatim, and re-encodes unchanged.

case StateActionUnknown(:final raw):
// raw is the original map. toJson() returns it byte-for-byte.

Two consequences worth relying on:

  • Reducers treat it as a no-op, returning the state they were given identically — which suppresses rebuilds for free. See Reducers.
  • A newer host round-trips through an older client without losing fields. Nothing is dropped on the way through.

Every generated union behaves this way, not just StateAction.

The discriminants are also available as an enum, recovered from the schema’s oneOf:

if (envelope.action case StateActionChatDelta(:final value)) {
assert(value.type == ActionType.chatDelta);
}

ActionType.fromWire('chat/delta') decodes one, and throws on anything it does not recognize — deliberately, because at that point you are not decoding an action, you are decoding a string that was supposed to be one.

Two directions, and the difference matters for reconciliation:

Origin Path
The host Arrives on client.actions, applied to confirmed and optimistic
This client dispatch applies to optimistic immediately, then reconciles

A dispatched action comes back from the host as an echo carrying the same clientSeq, which is how the client recognizes its own work and settles it. Dispatching actions covers the round trip.

The round-trip fixtures name a type as a string and expect it decoded. Dart has no runtime reflection in AOT, so the generator emits a lookup table:

final decoder = protocolDecoders['ChatState']!;
final state = decoder(json) as ChatState;

No other client needs this — Go, Swift, and Kotlin get the same effect from generics or reflection. It exists because Dart does not.