Guide

Activity & Child Outcomes

Deliver typed completion through exact mounted citizens in pure simulation.

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

Activity owns effect workActivityCitizen

resolve or reject one currently running Activity occurrence.

Payload is decoded through the Activity outcome contract.
Child site owns delegated behaviorChild.Site

resolveChild or rejectChild one exact mounted child.

Output or Cause is typed by the child chart; families also require a key.

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

  1. Resolve the Activity and prove the child remains running with its original generation.
  2. Start from the same initial world, resolve the child, and prove the Activity remains running.
  3. Reject a declared fallible Activity and assert the complete Cause reaches its authored listener.
  4. 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.