Guide

Child mail and transition .send

Address one child occurrence without leaking the parent's event stream.

Child input establishes birth facts. Mail carries typed information to a child that is already running. The parent must name both the mounted occurrence and an event that child accepts.

Child mail is typed and directional.

A mounted child does not inherit its parent's event stream. Typed mail names one child occurrence and one accepted child event, while completion returns on its own channel.

1 · Events are not automatically forwarded

The parent handles ExportDocument alone.

Renderer is mounted and waiting, but bare on(ExportDocument) consumes the command only in ExportFlow. A child's event alphabet is not an implicit copy of its parent's.

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

const BeginExport = Schema.TaggedStruct("BeginExport", {});
const ExportDocument = Schema.TaggedStruct("ExportDocument", {
  documentId: Schema.String,
});
const Render = Schema.TaggedStruct("Render", {
  documentId: Schema.String,
});

class RenderWorker extends State.Compound<RenderWorker>()("RenderWorker") {
  static states = States.make(() => [Waiting, Rendered]);
}

class Waiting extends State.Atomic<Waiting>()("Waiting") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Render, Rendered),
  ]);
}

class Rendered extends State.Done<Rendered>()("Rendered") {}

export const RenderWorkerChart = Statechart.make(RenderWorker);
const Renderer = RenderWorkerChart.as("Renderer");

export class ExportFlow extends State.Compound<ExportFlow>()("ExportFlow") {
  static states = States.make(() => [Draft, Exporting, Ready]);
}

class Draft extends State.Atomic<Draft>()("Draft") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(BeginExport, Exporting),
  ]);
}

class Exporting extends State.Atomic<Exporting>()("Exporting") {
  static transitions = Transitions.make(this, ({ on, spawn }) => [
    spawn(Renderer),
    on(ExportDocument),
    on(Renderer.Done),
  ]);
}

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

export const ExportFlowChart = Statechart.make(ExportFlow);

2 · Address one exact child occurrence

send projects a typed child event.

The transition maps the parent's ExportDocument payload into Renderer.events.Render. That explicit site reference delivers one decoded command to the mounted child.

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

const BeginExport = Schema.TaggedStruct("BeginExport", {});
const ExportDocument = Schema.TaggedStruct("ExportDocument", {
  documentId: Schema.String,
});
const Render = Schema.TaggedStruct("Render", {
  documentId: Schema.String,
});

class RenderWorker extends State.Compound<RenderWorker>()("RenderWorker") {
  static states = States.make(() => [Waiting, Rendered]);
}

class Waiting extends State.Atomic<Waiting>()("Waiting") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Render, Rendered),
  ]);
}

class Rendered extends State.Done<Rendered>()("Rendered") {}

export const RenderWorkerChart = Statechart.make(RenderWorker);
const Renderer = RenderWorkerChart.as("Renderer");

export class ExportFlow extends State.Compound<ExportFlow>()("ExportFlow") {
  static states = States.make(() => [Draft, Exporting, Ready]);
}

class Draft extends State.Atomic<Draft>()("Draft") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(BeginExport, Exporting),
  ]);
}

class Exporting extends State.Atomic<Exporting>()("Exporting") {
  static transitions = Transitions.make(this, ({ on, spawn }) => [
    spawn(Renderer),
    on(ExportDocument).send(Renderer.events.Render, ({ event }) => ({
      documentId: event.documentId,
    })),
    on(Renderer.Done),
  ]);
}

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

export const ExportFlowChart = Statechart.make(ExportFlow);

3 · Keep command and completion directional

Mail goes down; completion comes back up.

ExportDocument sends work to Renderer without moving the parent. Renderer.Done returns the child's outcome and advances ExportFlow to Ready through a separate edge.

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

const BeginExport = Schema.TaggedStruct("BeginExport", {});
const ExportDocument = Schema.TaggedStruct("ExportDocument", {
  documentId: Schema.String,
});
const Render = Schema.TaggedStruct("Render", {
  documentId: Schema.String,
});

class RenderWorker extends State.Compound<RenderWorker>()("RenderWorker") {
  static states = States.make(() => [Waiting, Rendered]);
}

class Waiting extends State.Atomic<Waiting>()("Waiting") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Render, Rendered),
  ]);
}

class Rendered extends State.Done<Rendered>()("Rendered") {}

export const RenderWorkerChart = Statechart.make(RenderWorker);
const Renderer = RenderWorkerChart.as("Renderer");

export class ExportFlow extends State.Compound<ExportFlow>()("ExportFlow") {
  static states = States.make(() => [Draft, Exporting, Ready]);
}

class Draft extends State.Atomic<Draft>()("Draft") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(BeginExport, Exporting),
  ]);
}

class Exporting extends State.Atomic<Exporting>()("Exporting") {
  static transitions = Transitions.make(this, ({ on, spawn }) => [
    spawn(Renderer),
    on(ExportDocument).send(Renderer.events.Render, ({ event }) => ({
      documentId: event.documentId,
    })),
    on(Renderer.Done, Ready),
  ]);
}

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

export const ExportFlowChart = Statechart.make(ExportFlow);

Mounting does not forward events

The first frame mounts Renderer, then handles ExportDocument only in the parent:

spawn(Renderer),
on(ExportDocument),

Exporting observes the event, but the child remains Waiting. A child chart does not inherit its parent's event stream, and a parent event with a similar shape does not become child mail by coincidence.

This isolation keeps each chart's accepted event vocabulary honest. Adding a child cannot silently change who receives every existing parent command.

The child chart declares its mailbox

RenderWorkerChart includes a typed Render event in its own topology:

const Render = Schema.TaggedStruct("Render", {
  documentId: Schema.String,
});

class Waiting extends State.Atomic<Waiting>()("Waiting") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Render, Rendered),
  ]);
}

Deriving the singular occurrence projects that accepted event through the site:

const Renderer = RenderWorkerChart.as("Renderer");
Renderer.events.Render;

The projected event reference carries two facts together: the Render Schema and the exact child site that can receive it.

send addresses one child occurrence

The second frame appends a transition action:

on(ExportDocument).send(Renderer.events.Render, ({ event }) => ({
  documentId: event.documentId,
}));

.send is part of the fluent transition grammar. It is selected because Exporting handled ExportDocument; the producer constructs the child's event from the already-decoded parent event.

There is no string mailbox, service lookup, or ambient actor registry. The occurrence reference answers “which child?” and the projected Schema answers “which message?” at the authoring site.

The producer preserves Schema ownership

The producer accepts the constructor input of the child's event Schema. Defaults and transforms remain available, while required fields and refinements are checked at the real boundary.

Renaming documentId, changing its type, or addressing an event not accepted by this child exposes the mismatch where the send is authored. The runtime does not reconstruct a weaker loose object and hope the recipient understands it.

send is a committed consequence

The parent macrostep records an addressed child-send intent as a transition consequence. The child delivery is not secretly folded into the same parent configuration change, and child topology does not execute inside the parent interpreter.

That boundary matters for durable engines: the parent decision can be committed and the addressed delivery enacted without pretending two independently running charts share one snapshot.

Mail is for facts after birth

Input and mail have different lifetimes:

  • spawn(Renderer, input) constructs one value for a new child incarnation.
  • .send(Renderer.events.Render, message) addresses an incarnation that is already mounted.

Do not use repeated reentry merely to update a child, and do not hide immutable birth requirements inside the first message. The model should say whether a fact is required before the child exists or arrives during its running protocol.

Completion travels back separately

Sending Render advances the child to its terminal Rendered state. That produces Renderer.Done; it does not automatically move the parent.

The final frame gives the return channel its own transition:

on(Renderer.Done, Ready);

Commands travel down through explicitly addressed mail. Typed outcomes travel back through the occurrence's completion channels. The child cannot choose parent topology, and the parent cannot bypass the child's event protocol.

No recipient means no delivery authority

The send is addressed to the live occurrence mounted by Exporting. Leaving that owner retires the child and ends its mailbox authority. A stale host reference cannot revive the old incarnation or deliver into a later one with the same authored occurrence name.

For keyed child families, addressing also requires the authored member key. Multiplicity adds an identity coordinate; it does not weaken the event contract.

Test the mail boundary

  1. Handle ExportDocument without .send and verify the child remains Waiting.
  2. Add the addressed send and verify only the child consumes Render.
  3. Change the child event Schema and follow the type mismatch into the send producer.
  4. Leave Renderer.Done targetless and identify which lifecycle has completed.
  5. Exit Exporting before sending and verify the retired occurrence receives nothing.