Guide

Child.Done

Handle typed child completion and decide where its terminal output lives.

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

  1. Complete the child in both frames and compare the retained child outcome with Published data.
  2. Change RenderResult and follow the inferred type into the parent's target input producer.
  3. Keep the targetless listener but add an action, then verify the parent still does not move.
  4. Remove the eligible Done disposition and confirm chart validation rejects the incomplete protocol.