A Simulator records authored work, but it does not run that work. The test chooses which declared external outcome arrives and delivers it through the same mounted identity and decoded completion channel the chart expects.
Choose the outcome by owner
ActivityCitizenresolve or reject one currently running Activity occurrence.
Child.SiteresolveChild or rejectChild one exact mounted child.
Do not inject the generated Done or Error event by hand. Give Simulator the external outcome;
it validates, identifies, and delivers the completion through ordinary chart selection.
Deliver outcomes through exact mounted citizens
Control completion without bypassing chart semantics.
A Simulator does not run live handlers or child engines. Tests deliver typed outcomes through the same mounted citizens and generated completion channels that production bindings use.
1 · Observe mounted work without handlers
The Simulator keeps both citizens pending.
The Activity lane invokes FetchRecord while the child lane spawns WorkerSite. Simulator records both authored lifecycles but starts neither a handler nor a child engine; their outcomes remain under test control.
import { Activity, Simulator, State, Statechart, States, Transitions } from "@motive/motive";
import { Effect, Schema } from "effect";
class Worker extends State.Compound<Worker>()("Worker") {
static states = States.make(() => [WorkerWaiting, WorkerFinished]);
}
class WorkerWaiting extends State.Atomic<WorkerWaiting>()("WorkerWaiting") {}
class WorkerFinished extends State.Done<WorkerFinished>()(
"WorkerFinished",
Schema.Struct({ receipt: Schema.String }),
) {}
const WorkerChart = Statechart.make(Worker);
const FetchRecord = Activity.make("FetchRecord", {
success: Schema.Struct({ record: Schema.String }),
});
export const WorkerSite = WorkerChart.as("WorkerSite");
export class Coordinator extends State.Parallel<Coordinator>()("Coordinator") {
static states = States.make(() => [ActivityLane, ChildLane]);
}
class ActivityLane extends State.Compound<ActivityLane>()("ActivityLane") {
static states = States.make(() => [Fetching, ActivityReady]);
}
class Fetching extends State.Atomic<Fetching>()("Fetching") {
static transitions = Transitions.make(this, ({ invoke, on }) => [
invoke(FetchRecord),
on(FetchRecord.Done, ActivityReady, ({ event }) => ({ record: event.value.record })),
]);
}
class ActivityReady extends State.Atomic<ActivityReady>()("ActivityReady", {
record: Schema.String,
}) {}
class ChildLane extends State.Compound<ChildLane>()("ChildLane") {
static states = States.make(() => [Delegating, ChildReady]);
}
class Delegating extends State.Atomic<Delegating>()("Delegating") {
static transitions = Transitions.make(this, ({ on, spawn }) => [
spawn(WorkerSite),
on(WorkerSite.Done, ChildReady, ({ event }) => ({ receipt: event.value.receipt })),
]);
}
class ChildReady extends State.Atomic<ChildReady>()("ChildReady", {
receipt: Schema.String,
}) {}
export const CoordinatorChart = Statechart.make(Coordinator);
export const initial = Effect.runSync(Simulator.make(CoordinatorChart, {}));
2 · Resolve one Activity definition
ActivityCitizen delivers the declared success value.
Resolving FetchRecord decodes its success payload and routes the generated Done event through the normal transition. The Activity lane advances with A-42 while the child remains pending.
import { Activity, Simulator, State, Statechart, States, Transitions } from "@motive/motive";
import { Effect, Result, Schema } from "effect";
class Worker extends State.Compound<Worker>()("Worker") {
static states = States.make(() => [WorkerWaiting, WorkerFinished]);
}
class WorkerWaiting extends State.Atomic<WorkerWaiting>()("WorkerWaiting") {}
class WorkerFinished extends State.Done<WorkerFinished>()(
"WorkerFinished",
Schema.Struct({ receipt: Schema.String }),
) {}
const WorkerChart = Statechart.make(Worker);
const FetchRecord = Activity.make("FetchRecord", {
success: Schema.Struct({ record: Schema.String }),
});
export const WorkerSite = WorkerChart.as("WorkerSite");
export class Coordinator extends State.Parallel<Coordinator>()("Coordinator") {
static states = States.make(() => [ActivityLane, ChildLane]);
}
class ActivityLane extends State.Compound<ActivityLane>()("ActivityLane") {
static states = States.make(() => [Fetching, ActivityReady]);
}
class Fetching extends State.Atomic<Fetching>()("Fetching") {
static transitions = Transitions.make(this, ({ invoke, on }) => [
invoke(FetchRecord),
on(FetchRecord.Done, ActivityReady, ({ event }) => ({ record: event.value.record })),
]);
}
class ActivityReady extends State.Atomic<ActivityReady>()("ActivityReady", {
record: Schema.String,
}) {}
class ChildLane extends State.Compound<ChildLane>()("ChildLane") {
static states = States.make(() => [Delegating, ChildReady]);
}
class Delegating extends State.Atomic<Delegating>()("Delegating") {
static transitions = Transitions.make(this, ({ on, spawn }) => [
spawn(WorkerSite),
on(WorkerSite.Done, ChildReady, ({ event }) => ({ receipt: event.value.receipt })),
]);
}
class ChildReady extends State.Atomic<ChildReady>()("ChildReady", {
receipt: Schema.String,
}) {}
export const CoordinatorChart = Statechart.make(Coordinator);
export const initial = Effect.runSync(Simulator.make(CoordinatorChart, {}));
export const activityReady = Result.getOrThrow(
initial.resolve(Simulator.ActivityCitizen.make("FetchRecord"), {
record: "A-42",
}),
);
3 · Resolve one exact child site
The child site itself carries occurrence identity.
resolveChild accepts WorkerSite and its chart output, so the exact mounted occurrence—not a string alias—owns delivery. The child lane advances with R-42 while FetchRecord remains pending.
import { Activity, Simulator, State, Statechart, States, Transitions } from "@motive/motive";
import { Effect, Result, Schema } from "effect";
class Worker extends State.Compound<Worker>()("Worker") {
static states = States.make(() => [WorkerWaiting, WorkerFinished]);
}
class WorkerWaiting extends State.Atomic<WorkerWaiting>()("WorkerWaiting") {}
class WorkerFinished extends State.Done<WorkerFinished>()(
"WorkerFinished",
Schema.Struct({ receipt: Schema.String }),
) {}
const WorkerChart = Statechart.make(Worker);
const FetchRecord = Activity.make("FetchRecord", {
success: Schema.Struct({ record: Schema.String }),
});
export const WorkerSite = WorkerChart.as("WorkerSite");
export class Coordinator extends State.Parallel<Coordinator>()("Coordinator") {
static states = States.make(() => [ActivityLane, ChildLane]);
}
class ActivityLane extends State.Compound<ActivityLane>()("ActivityLane") {
static states = States.make(() => [Fetching, ActivityReady]);
}
class Fetching extends State.Atomic<Fetching>()("Fetching") {
static transitions = Transitions.make(this, ({ invoke, on }) => [
invoke(FetchRecord),
on(FetchRecord.Done, ActivityReady, ({ event }) => ({ record: event.value.record })),
]);
}
class ActivityReady extends State.Atomic<ActivityReady>()("ActivityReady", {
record: Schema.String,
}) {}
class ChildLane extends State.Compound<ChildLane>()("ChildLane") {
static states = States.make(() => [Delegating, ChildReady]);
}
class Delegating extends State.Atomic<Delegating>()("Delegating") {
static transitions = Transitions.make(this, ({ on, spawn }) => [
spawn(WorkerSite),
on(WorkerSite.Done, ChildReady, ({ event }) => ({ receipt: event.value.receipt })),
]);
}
class ChildReady extends State.Atomic<ChildReady>()("ChildReady", {
receipt: Schema.String,
}) {}
export const CoordinatorChart = Statechart.make(Coordinator);
export const initial = Effect.runSync(Simulator.make(CoordinatorChart, {}));
export const childReady = Result.getOrThrow(initial.resolveChild(WorkerSite, { receipt: "R-42" }));
Mounted does not mean executed
The initial Snapshot contains a running FetchRecord Activity and a running WorkerSite child.
Their inputs, arming or generation identity, attempts, and ownership paths are chart facts. No
Activity handler, Effect fiber, child engine, mailbox, or external service has started.
This is not a mock transition. The Simulator has folded the same invoke and spawn declarations
an engine would enact. The test now controls the external half of each boundary: which success or
failure becomes available, and in what order.
The two parallel lanes make that independence visible. Resolving one occurrence advances only the lane that listens to its completion; the other remains running.
Deliver an Activity outcome by citizen
Simulator.ActivityCitizen.make("FetchRecord") names a currently running Activity occurrence.
resolve(citizen, { record: "A-42" }) decodes the supplied value through the declared success
Schema, constructs the corresponding Activity completion, and folds the generated Done event
through normal transition selection.
reject(citizen, cause) takes a complete Cause rather than an arbitrary thrown value. The fold
then follows the Activity's authored failure channel. The test controls the outcome; it does not
bypass the contract or manufacture the listener event directly.
When repeated placements make the local Activity occurrence ambiguous, pass
{ in: Simulator.StateCitizen.make(path) } to scope the injection to one mounted State path.
Deliver a child outcome by site
resolveChild(WorkerSite, { receipt: "R-42" }) starts with the authored child-site value. That
value carries the child chart and occurrence identity needed to decode its output and deliver the
site's generated Done event.
rejectChild accepts a Cause whose failure type comes from the child chart. A .batch or .each
family additionally requires the exact member key; a singleton .as site does not. Repeated
Component placements can use the same in: StateCitizen scope as Activity injection.
Keep the site value. Replacing it with a display label or reconstructing occurrence identity from a string throws away the structure that makes completion safe.
Preserve stale and ambiguous outcomes
An outcome can arrive after its occurrence has ended or been superseded. Simulator returns another
world with a structural Stale trace entry instead of delivering the value to a different
incarnation. A missing current occurrence is NotArmed; kernel reconciliation can report
Superseded when the addressed lifecycle identity is no longer current.
If a short citizen name matches several live occurrences, injection fails with
Simulator.AmbiguousInjection and lists the matching slots. If the supplied output does not decode,
the Result fails with the Schema error. Neither case permits first-match delivery.
These are distinct test outcomes: stale delivery is a successful semantic non-step, ambiguity is an addressing failure, and invalid output is a contract failure.
Outcome injection stops before enactment
Resolving an Activity does not prove its Effect implementation could produce that value. Resolving a child does not prove the child engine ran, recovered, or committed its own history. Simulator proves how the parent chart reacts if that typed external fact arrives.
Use focused handler and child-runtime tests for production enactment. Use Simulator to explore completion order, retries, stale deliveries, parallel independence, and the transitions selected by each declared outcome.
Change the world
- Resolve the Activity and prove the child remains running with its original generation.
- Start from the same initial world, resolve the child, and prove the Activity remains running.
- Reject a declared fallible Activity and assert the complete
Causereaches its authored listener. - Resolve an old Activity arming after restart and assert
Stale / Superseded, not accidental delivery.
Next, Observability leaves controlled rehearsal and begins with immutable evidence from real committed execution.