Skip to content

Reconciliation

Typing should feel instant, and the server should stay the source of truth. Write-ahead reconciliation is how you get both.

Each subscribed channel keeps:

  • confirmed — derived purely from server-sequenced actions.
  • pending — your actions, applied locally, not yet echoed back.
  • optimistic — confirmed with pending replayed on top.

Only the last is ever rendered, and it is derived, never stored:

flowchart LR
  C[(confirmed)] --> R{replay pending}
  P[pending queue] --> R
  R --> O[(optimistic)]
  O --> UI[what you render]
final handle = runtime.dispatch(channel, action);

The action lands in pending and optimistic recomputes immediately — the UI updates on that line, before anything reaches the socket. Then the notification goes out.

When the server echoes it back, one of four things happens.

It confirms. The entry pops off pending and applies to confirmed. Visually nothing moves: the recomputed optimistic state is identical to what was already on screen, so no rebuild is emitted.

It is rejected. confirmed is left alone — a rejected action never applied server-side, so dropping it from pending is the revert. handle.settled resolves to DispatchRejected with the host’s reason.

A foreign action arrives first. Another client’s action applies to confirmed, and your pending work rebases on top of it. There is no operational transform here; the reducers are last-write-wins, so recomputing is the rebase.

The server skips it. If the host echoes a later action of yours, the skipped ones are never coming — one frame is one message over an ordered socket, so this is not transient reordering. They are dropped and surfaced rather than left pending forever, and their handles settle as DispatchDiscarded.

final handle = runtime.dispatch(channel, ChatMessageSentAction(text: text));
controller.clear(); // optimistic: the bubble is already up
switch (await handle.settled) {
case DispatchConfirmed():
break;
case DispatchRejected(:final reason):
controller.text = text; // restore the draft; the bubble vanished
showSnackBar('Not sent: $reason');
case DispatchDiscarded():
controller.text = text;
}

Rejection is a value, not an exception. A host declining an action is an ordinary product outcome, and awaiting one should not require try/catch.

clientSeq is per-connection, so the server has no memory of your pending actions after a drop. The spec says clear them, and clearing is correct.

But silently vaporising a message someone typed is a product decision, not a protocol one, so the engine clears and reports:

runtime.mirror.pendingDrains.listen((drain) {
// Restore a draft, offer "resend", or just tell them.
for (final action in drain.actions) { /* … */ }
});

Every discarded handle also settles as DispatchDiscarded, so nothing awaits forever.

With no upstream corpus, correctness rests on the property reconciliation exists to guarantee — convergence:

With the pending queue drained, confirmed equals the naive fold of the server’s own accepted order, with no trace of client history.

The suite generates 2,000 random but legal interleavings of local dispatch, own echo, foreign action, rejection, duplicate, and out-of-order echo, and asserts that invariant after every one. It is not equivalent to a cross-language corpus, and the parity checklist says so plainly.

serverSeq is global to the connection, not per-channel — reconnect resumes from a single number covering every subscription.

So a client subscribed to several channels legitimately sees 10 then 47 on any one of them, and a per-channel contiguity check would false-positive on every healthy connection. Gap detection therefore lives on the mirror, where the sequence really is contiguous, and ships as observe: it counts and reports without acting.

Whether contiguity holds at the connection level for a client subscribed to a subset of channels is not stated anywhere in the spec, and no other client checks at all. The diagnostic exists so a real host can settle the question before anything acts on it.