Guide

Selections & Perspectives

Project typed values only while their owning state is provably active.

Most consumers do not need an entire Snapshot. They need one typed fact—and they need to know whether the part of the model that owns that fact is active. Motive keeps those two questions in the chart instead of rebuilding them from configuration strings in every UI, agent, or service.

Perspective first, Selection second

Where is this valid?Perspective

One chart-bound proof of active topology.

TicketChart.at(Assigned)
What should it reveal?Selection<A>

One pure Query evaluated only under that proof.

assigned.select(assigneeQuery)
How should it arrive?read · observe

Acquire current truth once or follow every committed change.

Option<A>

A Perspective says where a fact may be read. A Selection says what to project there. The client decides whether to acquire one current Snapshot or observe the current-first stream. Each object has one job, so topology, projection, and delivery policy remain independently reusable.

Project current truth from a proven perspective

Let topology define when data exists.

Consumers often need one domain value only while a particular state is active. Perspectives and selections carry that geometric proof into point reads, observation, and waiting without parsing configuration strings.

1 · Name one exact active perspective

A perspective proves where a Snapshot is.

TicketChart.at(Assigned) names one exact place in this chart. isPresent refines a Snapshot immediately, while awaitPresent waits for that same geometric fact without polling configuration strings.

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

const Assign = Schema.TaggedStruct("Assign", { assignee: Schema.String });
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, ({ event }) => ({ assignee: event.assignee })),
  ]);
}

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

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

export const TicketChart = Statechart.make(Ticket);
export const assigned = TicketChart.at(Assigned);

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

export const awaitAssignment = Effect.gen(function* () {
  const ticket = (yield* TicketChart.client)(Statechart.id("ticket-42"));
  yield* ticket.start();
  yield* ticket.send.Assign({ assignee: "Mina" });
  return (yield* ticket.awaitPresent(assigned)).configuration;
}).pipe(Effect.provide(TicketLive));

2 · Project only while the owner is present

A selection carries its presence proof.

The assignee Query is valid because the Assigned perspective proves that facet active. Selection.option adds a presence check for an arbitrary Snapshot: None outside Assigned, and Some with the typed value while Assigned owns it.

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

const Assign = Schema.TaggedStruct("Assign", { assignee: Schema.String });
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, ({ event }) => ({ assignee: event.assignee })),
  ]);
}

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

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

export const TicketChart = Statechart.make(Ticket);
export const assigned = TicketChart.at(Assigned);
export const assignee = assigned.select(
  Query.gen(function* () {
    return (yield* Assigned).assignee;
  }),
);

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

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

3 · Choose acquisition without changing meaning

Read or observe a Selection; await a Perspective.

client.read evaluates the assignee Selection once; client.observe evaluates it over current-first changes. client.awaitPresent takes the Assigned Perspective and returns a refined Snapshot, so the Selection can project directly without another presence check.

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

const Assign = Schema.TaggedStruct("Assign", { assignee: Schema.String });
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, ({ event }) => ({ assignee: event.assignee })),
  ]);
}

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

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

export const TicketChart = Statechart.make(Ticket);
export const assigned = TicketChart.at(Assigned);
export const assignee = assigned.select(
  Query.gen(function* () {
    return (yield* Assigned).assignee;
  }),
);

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

export const consumeSelection = Effect.gen(function* () {
  const ticket = (yield* TicketChart.client)(Statechart.id("ticket-42"));
  yield* ticket.start();
  const current = yield* ticket.read(assignee);
  return {
    current,
    changes: ticket.observe(assignee),
    whenPresent: ticket.awaitPresent(assigned).pipe(Effect.map((snapshot) => assignee(snapshot))),
  };
}).pipe(Effect.provide(TicketLive));

Bind the smallest true perspective

TicketChart.at(Assigned) binds the exact Assigned State mounted in TicketChart. It does not search a string or accept an unrelated State class with the same tag. isPresent(snapshot) is a type guard: when it succeeds, the Snapshot is refined to Selection.SnapshotAt with Assigned proven active.

Use the narrowest perspective that makes the projection true:

  • chart.select(query) starts at the chart root for facts valid in every configuration.
  • chart.at(state) binds one mounted State or one exact Component placement node.
  • chart.when(guard) binds a snapshot-safe structural Guard built from active, inactive, done, and their boolean combinations. The guard must prove at least one active State so the perspective carries a useful read boundary.

The perspective also exposes presence, a Selection of the refined Snapshot itself. That is the same presence proof used by awaitPresent.

Let the Query read only what the proof owns

assigned.select(query) compiles one synchronous Query against the Assigned perspective. A strict read of the Assigned facet is legal because the perspective proves its owner active. A strict read from an unrelated branch is rejected by the chart's geometry checks.

Snapshot Selections cannot read the current step event. Events exist while the fold is processing a step; a published Snapshot does not retain one as ambient context. Put event-dependent logic in the transition, then select the state that the transition committed.

Selection evaluation is pure and framework-neutral. It reads the decoded Snapshot through the chart's topology and returns a value; it does not acquire storage, enter a mailbox, run an Effect, or subscribe to changes.

Use Option when presence is not proven

A Selection has the pure proven shape SnapshotAt<P> → A: its input must carry the perspective proof under which the Query was compiled. For an arbitrary Snapshot, Selection.option(selection, snapshot) adds the missing presence check. Option.none() then has one precise meaning: the Selection's perspective is not present, so its Query was not evaluated. In this example, Selection.option(assignee, snapshot) is None in Open, Some("Mina") in Assigned, and None again in Resolved.

That outer Option is not a nullable result invented by the Query. If A is itself optional, the shape remains Option<Option<A>>:

  • outer None — the owning perspective is absent;
  • Some(None) — the perspective is present and the selected field is empty;
  • Some(Some(value)) — both the perspective and selected value are present.

Do not flatten those cases unless the consuming product deliberately treats them as the same.

Evaluate purely or acquire through a client

When code already holds an unrefined Snapshot, use Selection.option(selection, snapshot). It returns Option<A> without any runtime service. After perspective.isPresent has refined the Snapshot, call the Selection directly. It returns A without the outer Option because presence is already proven.

A typed client adds acquisition and observation:

  • client.read(selection) acquires one current durable Snapshot, then evaluates the Selection.
  • client.observe(selection) evaluates it over the current-first committed Snapshot stream.

The optional evaluation still describes topology. The Effect or Stream error channel still describes failure to acquire or follow the Snapshot. Keeping those channels separate prevents “not currently Assigned” from being confused with “the instance could not be read.”

Wait for presence, not a selected value

client.awaitPresent(assigned) watches the same current-first stream until the perspective is present and returns a presence-refined Snapshot. It does not return the assignee Selection's value. That separation lets the caller evaluate several proof-owned Selections against the same exact Snapshot.

If the chart terminates before the perspective ever becomes present, the Effect fails with Selection.EndedBeforePresence, carrying chart name, instance id, and perspective path. It does not wait forever and it does not turn terminal absence into a successful empty value.

Keep rendering policy at the edge

The same Selection can serve a server decision, a Solid store, an agent prompt, or a command-line view because it contains domain projection rather than presentation policy. A component may render outer None as “Not assigned yet,” hide the panel, or retain the previous visual value—but that is the component's choice.

Keep the Selection honest. It should answer what the model currently proves, not what one consumer wants absence to look like.

Change the world

  1. Assign the ticket and watch the Assigned perspective and assignee Selection become present together.
  2. Resolve it and explain why the Selection becomes absent while the retained timeline still remembers the assignment.
  3. Add an optional dueAt facet and preserve None, Some(None), and Some(Some(dueAt)).
  4. Replace at(Assigned) with a when perspective that proves a shared owner across two active configurations.

Next, Simulation & Trace moves from observing the current instance to rehearsing explicit alternative inputs as immutable values.