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
- Handle
ExportDocumentwithout.sendand verify the child remainsWaiting. - Add the addressed send and verify only the child consumes
Render. - Change the child event Schema and follow the type mismatch into the send producer.
- Leave
Renderer.Donetargetless and identify which lifecycle has completed. - Exit
Exportingbefore sending and verify the retired occurrence receives nothing.