A child chart reports successful terminal output through the mounted occurrence's typed Done
event. The child defines what completion means; the parent decides what that result should change.
Completion crosses the child boundary.
A terminal child state produces one typed outcome for its mounted occurrence. The parent must handle that outcome, then chooses whether the value stays with the completed child or moves into parent-owned state.
1 · Expose the child's result
A terminal state becomes Child.Done.
Rendered owns the child chart's terminal output Schema. When Renderer reaches it, the parent receives one typed Done event. A targetless listener handles that event while Preparing and the completed child occurrence remain in place.
import { Activity, State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";
export const RenderResult = Schema.Struct({
artifactId: Schema.NonEmptyString,
pageCount: Schema.Int.check(Schema.isGreaterThan(0)),
});
export const RenderEdition = Activity.make("RenderEdition", {
success: RenderResult,
});
class RenderJob extends State.Compound<RenderJob>()("RenderJob") {
static states = States.make(() => [Rendering, Rendered]);
}
class Rendering extends State.Atomic<Rendering>()("Rendering") {
static transitions = Transitions.make(this, ({ invoke, on }) => [
invoke(RenderEdition),
on(RenderEdition.Done, Rendered, ({ event }) => event.value),
]);
}
class Rendered extends State.Done<Rendered>()("Rendered", RenderResult) {}
export const RenderJobChart = Statechart.make(RenderJob);
const Renderer = RenderJobChart.as("Renderer");
export class Publication extends State.Compound<Publication>()("Publication") {
static states = States.make(() => [Preparing, Published]);
}
class Preparing extends State.Atomic<Preparing>()("Preparing") {
static transitions = Transitions.make(this, ({ on, spawn }) => [
spawn(Renderer),
on(Renderer.Done),
]);
}
class Published extends State.Atomic<Published>()("Published", RenderResult) {}
export const PublicationChart = Statechart.make(Publication);
2 · Give the result a longer-lived owner
The parent decides what survives the child.
The same Renderer.Done event now targets Published. Its input producer copies event.value into parent state; leaving Preparing retires the completed child slot, while Published keeps the artifact identity and page count.
import { Activity, State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";
export const RenderResult = Schema.Struct({
artifactId: Schema.NonEmptyString,
pageCount: Schema.Int.check(Schema.isGreaterThan(0)),
});
export const RenderEdition = Activity.make("RenderEdition", {
success: RenderResult,
});
class RenderJob extends State.Compound<RenderJob>()("RenderJob") {
static states = States.make(() => [Rendering, Rendered]);
}
class Rendering extends State.Atomic<Rendering>()("Rendering") {
static transitions = Transitions.make(this, ({ invoke, on }) => [
invoke(RenderEdition),
on(RenderEdition.Done, Rendered, ({ event }) => event.value),
]);
}
class Rendered extends State.Done<Rendered>()("Rendered", RenderResult) {}
export const RenderJobChart = Statechart.make(RenderJob);
const Renderer = RenderJobChart.as("Renderer");
export class Publication extends State.Compound<Publication>()("Publication") {
static states = States.make(() => [Preparing, Published]);
}
class Preparing extends State.Atomic<Preparing>()("Preparing") {
static transitions = Transitions.make(this, ({ on, spawn }) => [
spawn(Renderer),
on(Renderer.Done, Published, ({ event }) => event.value),
]);
}
export class Published extends State.Atomic<Published>()("Published", RenderResult) {}
export const PublicationChart = Statechart.make(Publication);
The terminal state owns the output contract
The child chart ends in a data-bearing State.Done:
const RenderResult = Schema.Struct({
artifactId: Schema.NonEmptyString,
pageCount: Schema.Int.check(Schema.isGreaterThan(0)),
});
class Rendered extends State.Done<Rendered>()("Rendered", RenderResult) {}
Rendered is both child topology and the Schema boundary for successful terminal output. Reaching
it ends the child chart with one decoded { artifactId, pageCount } value.
This is not the same declaration as the parent's completion listener. State.Done defines the
child result; Child.Done carries that result across one mounted parent-child boundary.
Produce the terminal value inside the child
The child can assemble its output through ordinary transitions:
on(RenderEdition.Done, Rendered, ({ event }) => event.value);
Here an Activity finishes rendering, and the child chooses that successful value as the input to
Rendered. A more involved child could validate several steps, run parallel regions, receive
mail, or update intermediate data before producing the same terminal contract.
The parent does not need to know that internal path. Its protocol sees only the chart-level result.
The occurrence exposes Done
Deriving a singular occurrence gives the parent its completion descriptor:
const Renderer = RenderJobChart.as("Renderer");
on(Renderer.Done);
Renderer.Done is tied to this occurrence site, not merely to a chart name. When the mounted
child reaches Rendered, the engine creates the event and places the decoded terminal result on
event.value.
It is a reserved runtime event. Clients cannot impersonate a child by sending a hand-written
done.child.* tag through the public event boundary.
A targetless listener is an explicit disposition
In the first frame, Preparing handles completion without moving:
spawn(Renderer),
on(Renderer.Done),
The parent remains Preparing, and the completed occurrence remains recorded beneath that active
owner. The event is handled; the result simply has no new state owner beyond the completed child
slot.
This is useful when completion itself is enough—for example, when sibling topology, a raised event, or an emission carries the next consequence. Targetless does not mean accidental or unobserved.
Retain output in parent state
The second frame gives the result a parent-owned lifetime:
on(Renderer.Done, Published, ({ event }) => event.value);
The input producer is typed from Rendered's Schema. It constructs Published directly from the
child event, so changing either contract exposes the mismatch at the transition rather than after
deployment.
Entering Published exits Preparing, which retires the child occurrence site. The child no
longer needs to remain mounted merely to keep its output: Published is now the explicit owner of
the artifact identity and page count.
Completion and parent movement commit together
Child settlement is folded through parent transition selection as one macrostep. Observers do not
see an intermediate parent snapshot where the child is done but the selected Published
transition has not yet happened.
The first frame therefore publishes a stable Preparing snapshot with a completed child. The
second publishes a stable Published snapshot with parent data and no mounted Renderer.
Those are different authored dispositions of the same typed outcome.
Every possible outcome needs a disposition
If a mounted child can complete successfully, an eligible owner must say what to do with
Renderer.Done. Moving, updating, raising, emitting, and a targetless handler are all explicit
choices.
This totality rule prevents a successful child from silently reaching an outcome the parent never modeled. It does not require success to advance the parent; it requires the parent chart to decide.
Child.Error and Child.Defect are separate channels. Successful terminal output does not erase
modeled failure or supervisory failure, and those pages give each channel its own disposition.
Test the completion boundary
- Complete the child in both frames and compare the retained child outcome with
Publisheddata. - Change
RenderResultand follow the inferred type into the parent's target input producer. - Keep the targetless listener but add an action, then verify the parent still does not move.
- Remove the eligible
Donedisposition and confirm chart validation rejects the incomplete protocol.