Guide

Client Reads & Timeline

Keep current Snapshot, current-first changes, and retained history distinct.

One instance has current truth, future committed changes, and retained history. Those are related facts, but they are not interchangeable read models. The typed client keeps their boundaries separate so an application does not manufacture a second source of chart truth.

Choose the read by the question

What is true now?

snapshotRead one current committed point, then finish.

What is true now and next?

changesObserve the current Snapshot first, then each later commit.

What committed before?

timelinePage the retained macrostep diary by Snapshot sequence.

All three reads address the same Ticket instance below. Use Assign and Resolve in each frame and watch the current sequence move while the selected read boundary reports something different.

Choose the read that answers your question

Read current truth without confusing it with history.

Current state, future publication, and retained history answer different questions. The typed client keeps those read boundaries separate so consumers do not manufacture a second source of chart truth.

1 · Read one committed point

snapshot answers what is current now.

The typed client addresses ticket-42, starts it, and reads its current Snapshot. That value is one committed point with configuration, data, status, and sequence—not a subscription to the future.

import { State, Statechart, StatechartEngine, States, Transitions } from "@motive/motive";
import { Effect, Layer, Schema } from "effect";

const Assign = Schema.TaggedStruct("Assign", {});
const Resolve = Schema.TaggedStruct("Resolve", {});

export class Ticket extends State.Compound<Ticket>()("Ticket") {
  static states = States.make(() => [Open, Assigned, Resolved]);
}

class Open extends State.Atomic<Open>()("Open") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Assign, Assigned),
  ]);
}

class Assigned extends State.Atomic<Assigned>()("Assigned") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Resolve, Resolved),
  ]);
}

class Resolved extends State.Atomic<Resolved>()("Resolved") {}

export const TicketChart = Statechart.make(Ticket);

const TicketLive = TicketChart.toLayer().pipe(Layer.provideMerge(StatechartEngine.layerMemory));

export const readCurrent = Effect.gen(function* () {
  const ticket = (yield* TicketChart.client)(Statechart.id("ticket-42"));
  yield* ticket.start();
  return yield* ticket.snapshot;
}).pipe(Effect.provide(TicketLive));

2 · Observe current truth before future changes

changes is current-first, not event replay.

The first value from changes is the same current Snapshot even when no new event arrives. Later values publish once per committed step; the stream does not replay every retained diary row.

import { State, Statechart, StatechartEngine, States, Transitions } from "@motive/motive";
import { Effect, Layer, Schema, Stream } from "effect";

const Assign = Schema.TaggedStruct("Assign", {});
const Resolve = Schema.TaggedStruct("Resolve", {});

export class Ticket extends State.Compound<Ticket>()("Ticket") {
  static states = States.make(() => [Open, Assigned, Resolved]);
}

class Open extends State.Atomic<Open>()("Open") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Assign, Assigned),
  ]);
}

class Assigned extends State.Atomic<Assigned>()("Assigned") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Resolve, Resolved),
  ]);
}

class Resolved extends State.Atomic<Resolved>()("Resolved") {}

export const TicketChart = Statechart.make(Ticket);

const TicketLive = TicketChart.toLayer().pipe(Layer.provideMerge(StatechartEngine.layerMemory));

export const readCurrentFirst = Effect.gen(function* () {
  const ticket = (yield* TicketChart.client)(Statechart.id("ticket-42"));
  yield* ticket.start();
  return yield* ticket.changes.pipe(Stream.take(1), Stream.runCollect);
}).pipe(Effect.provide(TicketLive));

3 · Ask separately for retained history

timeline pages the macrostep diary.

After Assign and Resolve, timeline returns retained entries in ascending Snapshot sequence. Retention belongs to the engine: this memory layer keeps three rows, and losing its Scope loses all three.

import { State, Statechart, StatechartEngine, States, Transitions } from "@motive/motive";
import { Effect, Layer, Schema } from "effect";

const Assign = Schema.TaggedStruct("Assign", {});
const Resolve = Schema.TaggedStruct("Resolve", {});

export class Ticket extends State.Compound<Ticket>()("Ticket") {
  static states = States.make(() => [Open, Assigned, Resolved]);
}

class Open extends State.Atomic<Open>()("Open") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Assign, Assigned),
  ]);
}

class Assigned extends State.Atomic<Assigned>()("Assigned") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Resolve, Resolved),
  ]);
}

class Resolved extends State.Atomic<Resolved>()("Resolved") {}

export const TicketChart = Statechart.make(Ticket);

const TicketLive = TicketChart.toLayer().pipe(
  Layer.provideMerge(StatechartEngine.layerMemoryWith({ timelineRetention: 3 })),
);

export const readRetainedDiary = Effect.gen(function* () {
  const ticket = (yield* TicketChart.client)(Statechart.id("ticket-42"));
  yield* ticket.start();
  yield* ticket.send.Assign();
  yield* ticket.send.Resolve();
  return yield* ticket.timeline({ fromSeq: 1, limit: 3 });
}).pipe(Effect.provide(TicketLive));

snapshot is one current point

The first frame acquires a typed client for one chart and RootInstanceId, starts that instance, and reads snapshot. The result carries the current configuration, state-owned data, status, and sequence at the read boundary. Another commit may happen immediately afterward; a Snapshot does not reserve the future.

A client is an address, not proof that an instance exists. Reading does not birth one. snapshot and changes fail with InstanceNotStarted for an unborn instance, so call start or establish birth through the owning command path before treating the client as readable.

changes begins with current truth

The second frame consumes the first value of changes. The stream publishes the current Snapshot first, then one value for each later committed step. A component can render immediately without racing a separate initial snapshot request against its subscription.

Observation never starts an instance. changes is also not event replay: it does not emit refused attempts and does not replay every retained macrostep when a new observer arrives. It carries current truth forward from the observation boundary.

Use the higher-level observe(selection) and awaitPresent(perspective) forms when a consumer needs a state-owned projection rather than every complete Snapshot. They preserve the same current-first publication boundary while keeping absence explicit.

timeline is a separate retained diary

The final frame sends Assign and Resolve, then pages timeline. Entries are ordered by ascending Snapshot sequence and contain the retained committed event, resulting Snapshot, provenance, and microstep trace. fromSeq is inclusive; limit defaults to 100 and is capped at 1,000. A full page only says another page may exist, so continue from next when it is present.

Retention belongs to the engine. This example keeps the newest three entries in an in-memory ring, so closing the Layer Scope loses the whole diary. A durable timeline reads storage, but it still cannot reconstruct rows that the configured retention policy already removed. A request older than the retained floor begins at the earliest retained entry instead of failing.

Current head and retained history are distinct authorities: pruning an old diary row does not erase the current Snapshot, while seeing a historical row does not make that configuration active again.

Coordinate with sequence, not timing

Every Snapshot sequence names one dense committed position. Carry that coordinate when combining reads: a current Snapshot at sequence 42 and a timeline page beginning at 40 can be compared precisely, even if another commit arrives between the two requests.

Do not infer consistency from which Promise happened to resolve first. If one decision must be made from one committed point, derive it from a single Snapshot or Selection. If a consumer must remain current, stay on the current-first stream and treat each later Snapshot as a new authority.

Client reads are instance-scoped

The typed client already fixes the chart declaration and instance identity. It is the right boundary for application code acting on a known instance. Fleet-wide operational reads, storage-owned provenance, and queries that must not enter an instance mailbox belong to Observability.

Change the world

  1. Subscribe to changes after Assign and predict its first value.
  2. Set timeline retention to two, take three steps, and identify the retained floor.
  3. Compare sequence numbers when coordinating a Snapshot read with a later timeline page.

Next, Selections & Perspectives projects typed values from current geometry while preserving honest absence.