Guide

Child emissions

Receive typed, nonterminal facts from one mounted child occurrence.

Completion says how a child ended. An emission reports a typed fact while the child is still running. The parent listens through the mounted occurrence, preserving who spoke as part of the event boundary.

A child can speak without finishing.

Completion reports how a child ended. Emissions report typed facts while it is still running. The parent subscribes through the mounted occurrence, so the event retains both its Schema and its source.

1 · Emit without completing

The child reports progress and keeps running.

RenderWorker handles Render with a targetless transition and emits RenderProgress. The external emit binding observes the typed event, while the child stays mounted and the parent leaves its own progress unchanged.

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

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

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

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

export const RenderProgress = Schema.TaggedStruct("RenderProgress", {
  documentId: Schema.String,
  percent: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 })),
});

class RenderWorker extends State.Atomic<RenderWorker>()("RenderWorker") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Render).emit(RenderProgress, ({ event }) => ({
      documentId: event.documentId,
      percent: 40,
    })),
  ]);
}

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

export class ExportJob extends State.Compound<ExportJob>()("ExportJob", {
  progress: Schema.Int.pipe(
    Schema.check(Schema.isBetween({ minimum: 0, maximum: 100 })),
    Schema.withConstructorDefault(Effect.succeed(0)),
  ),
}) {
  static states = States.make(() => [Draft, Exporting]);
}

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,
    })),
  ]);
}

export const ExportJobChart = Statechart.make(ExportJob);

2 · Subscribe through the occurrence

The parent listens to this child, not every child.

Renderer.emits.RenderProgress projects the child's emitted Schema through its mounted site. Exporting handles that site-scoped event and retains percent in ExportJob without completing or replacing either chart.

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

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

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

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

export const RenderProgress = Schema.TaggedStruct("RenderProgress", {
  documentId: Schema.String,
  percent: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 })),
});

class RenderWorker extends State.Atomic<RenderWorker>()("RenderWorker") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Render).emit(RenderProgress, ({ event }) => ({
      documentId: event.documentId,
      percent: 40,
    })),
  ]);
}

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

export class ExportJob extends State.Compound<ExportJob>()("ExportJob", {
  progress: Schema.Int.pipe(
    Schema.check(Schema.isBetween({ minimum: 0, maximum: 100 })),
    Schema.withConstructorDefault(Effect.succeed(0)),
  ),
}) {
  static states = States.make(() => [Draft, Exporting]);
}

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.emits.RenderProgress).update(ExportJob, ({ event }) => ({
      progress: event.percent,
    })),
  ]);
}

export const ExportJobChart = Statechart.make(ExportJob);

Emissions belong to the child contract

RenderWorkerChart declares RenderProgress by emitting it from a transition:

on(Render).emit(RenderProgress, ({ event }) => ({
  documentId: event.documentId,
  percent: 40,
}));

That declaration adds RenderProgress to the chart's outbound event alphabet. Its fields, refinements, transforms, and encoded form remain owned by the Schema; the runtime does not replace the event with an untyped callback payload.

The child chooses which domain fact to publish. It does not choose who must react.

Emit without completing

The Render transition has no target. After emitting progress, RenderWorker remains active and can accept more mail:

on(Render).emit(RenderProgress, ...)

An emission is not Child.Done, Child.Error, or Child.Defect. Those channels describe terminal or supervisory outcomes. Emissions describe intermediate facts without changing the child's lifecycle by themselves.

Use them for facts such as progress, checkpoints, discoveries, partial results, or requests that make sense before the child has finished.

The runtime binding observes the chart emission

The child chart's runtime implementation supplies the external consequence:

RenderWorkerChart.toLayer({
  emits: {
    RenderProgress: ({ event }) => recordProgress(event.documentId, event.percent),
  },
});

This binding belongs to RenderWorkerChart, not to its parent. The same child chart can run by itself, under another parent, or many times; its emitted event contract remains the same.

The first frame lets this binding observe guide-42 · 40% while the parent keeps progress at 0%. A chart emission does not mutate parent state by ambient reach.

The occurrence projects the outbound contract

Deriving a child occurrence projects both accepted and emitted events through that site:

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

The projected surface makes the direction explicit. .send(Renderer.events.Render, ...) addresses an event the child accepts. on(Renderer.emits.RenderProgress, ...) subscribes to an event the child emits. Motive rejects a reference whose direction does not fit the operation.

The listener retains source identity

The final frame listens through Renderer, not through the bare RenderProgress Schema:

on(Renderer.emits.RenderProgress).update(ExportJob, ({ event }) => ({
  progress: event.percent,
}));

That is a source-qualified event. Another placement of RenderWorkerChart may emit the same Schema, but it is not this listener's source. The authored occurrence is part of the subscription rather than a string inspected inside a general handler.

This is the important difference between a chart's outbound alphabet and a parent's local subscription: one states what may be emitted; the other states which mounted source this topology handles.

Parent handling is an independent choice

The parent may update data, transition elsewhere, raise a local event, emit another fact, or remain targetless when it receives a child emission. In the final frame it updates ExportJob.progress while both Exporting and RenderWorker remain active.

External emit handling and parent handling are distinct consumers of the same declared fact. A runtime integration can record progress even when this parent does not subscribe; a parent can retain the progress it needs without becoming the implementation of the child's emit binding.

Emissions cross a commit boundary

The child's macrostep records RenderProgress as a committed consequence. The engine enacts the emit binding and routes a site-qualified child-emission delivery to the parent subscription.

The parent's response is its own macrostep. Do not model the two independently running charts as sharing one synchronous call stack or one mutable snapshot. Durable engines can recover and enact the recorded consequence while preserving the authored source and event codec.

The subscription lives with the occurrence

Leaving Exporting retires Renderer and its subscription authority. A later incarnation with the same authored occurrence name is a new running child; a delayed emission from the retired incarnation cannot masquerade as current progress.

For child families, the projected event also carries the member key. Multiplicity adds the exact emitting member to the address; it does not collapse every child into one global stream.

Test the emission boundary

  1. Emit RenderProgress and verify the child remains running.
  2. Remove the parent listener and verify the chart-level emit binding still observes the event.
  3. Restore on(Renderer.emits.RenderProgress) and verify only that occurrence updates the parent.
  4. Change the emitted Schema and follow the type mismatch through its producer, runtime binding, and parent listener.
  5. Retire the occurrence and verify a stale incarnation cannot publish as the current child.