Guide

Child inputs and lifecycle

Construct decoded child birth data and let parent topology own its lifetime.

Child input carries immutable birth facts from an active parent into a newly mounted child. The child chart owns the contract; the parent constructs one value at the spawn site.

Birth data crosses the boundary once.

The child chart owns its input Schema and initialization. A parent mount constructs one decoded value for each new incarnation, then parent topology governs how long that child may live.

1 · Construct the child at birth

spawn supplies the child chart's input.

Exporting captures document identity and revision from BeginExport. Its spawn producer constructs the ExportJobChart input; the child Schema decodes that value and init seeds Uploading before the new instance becomes observable.

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

const Revision = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(1));
const ExportInput = Schema.Struct({
  documentId: Schema.NonEmptyString,
  revision: Revision,
});

const BeginExport = Schema.TaggedStruct("BeginExport", ExportInput.fields);

export const UploadDocument = Activity.make("UploadDocument", {
  input: ExportInput,
  success: Schema.Void,
});

class ExportJob extends State.Compound<ExportJob>()("ExportJob") {
  static states = States.make(() => [Uploading, Exported]);
}

class Uploading extends State.Atomic<Uploading>()("Uploading", ExportInput) {
  static transitions = Transitions.make(this, ({ invoke, on }) => [
    invoke(UploadDocument, ({ state }) => ({
      documentId: state.documentId,
      revision: state.revision,
    })),
    on(UploadDocument.Done, Exported),
  ]);
}

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

export const ExportJobChart = Statechart.make(ExportJob, {
  input: ExportInput,
  init: ({ input }) => [new Uploading(input)],
});

const ExportWorker = ExportJobChart.as("ExportWorker");

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

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

class Exporting extends State.Atomic<Exporting>()("Exporting", ExportInput) {
  static transitions = Transitions.make(this, ({ on, spawn }) => [
    spawn(ExportWorker, ({ state }) => ({
      documentId: state.documentId,
      revision: state.revision,
    })),
    on(ExportWorker.Done),
  ]);
}

export const DocumentChart = Statechart.make(Document);

2 · Let the owner govern the lifetime

Leaving the mount state retires the child.

CancelExport returns the parent to Draft while UploadDocument is still running. Exiting Exporting retires ExportWorker and interrupts its work, so the former child cannot complete into a later parent configuration.

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

const Revision = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(1));
const ExportInput = Schema.Struct({
  documentId: Schema.NonEmptyString,
  revision: Revision,
});

const BeginExport = Schema.TaggedStruct("BeginExport", ExportInput.fields);
const CancelExport = Schema.TaggedStruct("CancelExport", {});

export const UploadDocument = Activity.make("UploadDocument", {
  input: ExportInput,
  success: Schema.Void,
});

class ExportJob extends State.Compound<ExportJob>()("ExportJob") {
  static states = States.make(() => [Uploading, Exported]);
}

class Uploading extends State.Atomic<Uploading>()("Uploading", ExportInput) {
  static transitions = Transitions.make(this, ({ invoke, on }) => [
    invoke(UploadDocument, ({ state }) => ({
      documentId: state.documentId,
      revision: state.revision,
    })),
    on(UploadDocument.Done, Exported),
  ]);
}

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

export const ExportJobChart = Statechart.make(ExportJob, {
  input: ExportInput,
  init: ({ input }) => [new Uploading(input)],
});

const ExportWorker = ExportJobChart.as("ExportWorker");

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

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

class Exporting extends State.Atomic<Exporting>()("Exporting", ExportInput) {
  static transitions = Transitions.make(this, ({ on, spawn }) => [
    spawn(ExportWorker, ({ state }) => ({
      documentId: state.documentId,
      revision: state.revision,
    })),
    on(ExportWorker.Done),
    on(CancelExport, Draft),
  ]);
}

export const DocumentChart = Statechart.make(Document);

Input belongs to the child chart

Declare the input contract when assembling the child definition:

const ExportInput = Schema.Struct({
  documentId: Schema.NonEmptyString,
  revision: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(1)),
});

const ExportJobChart = Statechart.make(ExportJob, {
  input: ExportInput,
  init: ({ input }) => [new Uploading(input)],
});

ExportJobChart is now the authority for what every new export instance requires. A required field cannot be omitted at a mount, and an inputless child cannot be given an invented input producer.

The Schema owns construction, decoding, validation, and any transformations. Runtime code receives the decoded child domain value rather than a loose object that must be checked again.

spawn constructs one birth value

The parent supplies input at the occurrence site:

spawn(ExportWorker, ({ state }) => ({
  documentId: state.documentId,
  revision: state.revision,
}));

The producer runs from the active parent state. Here, BeginExport first establishes Exporting with the selected document and revision; entering Exporting then constructs the child input from that durable parent data.

The producer accepts the Schema's constructor input, so defaults and transformations remain available without weakening the decoded child type.

Decode before the parent commits

Child input is resolved and decoded before the parent macrostep commits its new snapshot. If the producer returns an invalid revision or an undeclared field, child arming is refused as a Schema issue and the step publishes no partially mounted child.

This atomic boundary matters. Observers cannot see Exporting committed with a malformed or half-created ExportWorker beside it.

Input construction is chart logic, not post-commit Activity enactment. A defect in the producer or decoder receives the engine's pre-commit author-defect disposition.

init turns input into child state

The decoded value is passed to the child chart's init function:

init: ({ input }) => [new Uploading(input)];

init seeds the data-bearing states in the child's initial configuration. The example seeds Uploading, whose Activity input producer then reads the same documentId and revision from child state.

Input and state data are different seats. Input exists at birth; init decides which initial state data should retain it. Later child transitions read and update state data, not a hidden ambient constructor argument.

Input is captured once per incarnation

The spawn producer runs when the occurrence is mounted. Changes to the parent afterward do not rewrite the running child's birth input.

If the owner is exited and entered again, spawn constructs a fresh value and init seeds a fresh child incarnation. The new child may receive a different revision without mutating or reviving the retired instance.

Use child mail for facts that arrive after birth. Input establishes the initial contract; messages belong to the running protocol.

The parent state owns the lifetime

ExportWorker is mounted by Exporting, so their live lifetimes are nested:

class Exporting extends State.Atomic<Exporting>()("Exporting", ExportInput) {
  static transitions = Transitions.make(this, ({ on, spawn }) => [
    spawn(ExportWorker, ({ state }) => ({
      documentId: state.documentId,
      revision: state.revision,
    })),
    on(ExportWorker.Done),
    on(CancelExport, Draft),
  ]);
}

The child is not a detached task. Its occurrence exists beneath one active parent state and is retired when that ownership geometry ends.

Owner exit cancels still-running work

In the second frame, CancelExport leaves Exporting while UploadDocument is running. The engine retires the child incarnation and interrupts the child-owned Activity as part of parent exit.

Waiting beyond the old completion time cannot produce ExportWorker.Done in Draft. A late result from the retired incarnation has no authority over the parent's new configuration.

This is why lifecycle ownership belongs in topology: cancellation follows state exit even when the application has no bespoke cleanup branch.

Resolve a child through its parent

When application code needs the currently mounted child, use the parent client's typed occurrence lookup rather than reconstructing a child address:

const child = yield * parent.child(ExportWorker);
const snapshot = yield * child.snapshot;

child captures the exact published occupant of that occurrence and returns the child chart's ordinary component client for snapshots, sends, reads, observations, and further descendant lookup. It deliberately has no independent start or stop authority; birth, replacement, and interruption remain owned by the parent topology.

Use childOption when absence is expected. For keyed .batch and .each occurrences, provide the member key to child, or use children to obtain the current key-to-client map. Passing a previous parent Snapshot intentionally resolves the occupant recorded by that Snapshot, which keeps incarnation identity explicit across replacement.

Test birth and retirement

  1. Start the first frame and compare Exporting data with the decoded input observed inside the child Activity binding.
  2. Give revision a zero value and verify that Schema refusal publishes no mounted child.
  3. Start the second frame, exit to Draft, and wait beyond the former upload duration.
  4. Re-enter Exporting with another revision and verify that a new child incarnation captures the new value exactly once.