Guide

Child.Defect

Supervise unexpected failure across independently running chart boundaries.

Child.Defect reports that an independently running child chart ended outside its modeled success and error contracts. The event belongs to the mounted child occurrence, so supervision remains explicit even when the failure began several runtime boundaries below the parent.

Supervision follows the chart tree.

A child occurrence turns unexpected failure inside an independently running chart into one source-qualified supervisory event. If that event remains unhandled, the same failure can cross the next child boundary.

1 · Supervise the child

The occurrence reports what its chart could not handle.

RenderEdition dies inside RenderJob. Because the child has no local RenderEdition.Defect listener, its Renderer occurrence completes with Renderer.Defect; Preparing handles that boundary event in place.

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

export const RenderEdition = Activity.make("RenderEdition", {
  success: Schema.Struct({ artifactId: Schema.NonEmptyString }),
});

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",
  Schema.Struct({ artifactId: Schema.NonEmptyString }),
) {}

export const RenderJobChart = Statechart.make(RenderJob);
const Renderer = RenderJobChart.as("Renderer");

class Publication extends State.Compound<Publication>()("Publication") {
  static states = States.make(() => [Preparing]);
}

class Preparing extends State.Atomic<Preparing>()("Preparing") {
  static transitions = Transitions.make(this, ({ on, spawn }) => [
    spawn(Renderer),
    on(Renderer.Done),
    on(Renderer.Defect),
  ]);
}

export const PublicationChart = Statechart.make(Publication);

2 · Escalate recursively

An unhandled child defect crosses the next boundary.

Publication deliberately leaves Renderer.Defect unhandled. Its Publisher occurrence therefore defects too, preserving the incident reference and representative defect for Workspace while it isolates the whole branch in Halted.

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

export const RenderEdition = Activity.make("RenderEdition", {
  success: Schema.Struct({ artifactId: Schema.NonEmptyString }),
});

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",
  Schema.Struct({ artifactId: Schema.NonEmptyString }),
) {}

export const RenderJobChart = Statechart.make(RenderJob);
const Renderer = RenderJobChart.as("Renderer");

class Publication extends State.Compound<Publication>()("Publication") {
  static states = States.make(() => [Preparing]);
}

class Preparing extends State.Atomic<Preparing>()("Preparing") {
  static transitions = Transitions.make(this, ({ on, spawn }) => [
    spawn(Renderer),
    on(Renderer.Done),
  ]);
}

export const PublicationChart = Statechart.make(Publication);
const Publisher = PublicationChart.as("Publisher");

class Workspace extends State.Compound<Workspace>()("Workspace") {
  static states = States.make(() => [Publishing, Halted]);
}

class Publishing extends State.Atomic<Publishing>()("Publishing") {
  static transitions = Transitions.make(this, ({ on, spawn }) => [
    spawn(Publisher),
    on(Publisher.Defect, Halted, ({ event }) => ({
      incident: event.incident,
      defect: event.defect,
    })),
  ]);
}

export class Halted extends State.Atomic<Halted>()("Halted", {
  incident: Incident.Ref,
  defect: Schema.Unknown,
}) {}

export const WorkspaceChart = Statechart.make(Workspace);

The child boundary owns the event

Renderer is an authored occurrence of RenderJobChart:

const Renderer = RenderJobChart.as("Renderer");

If that running child reaches State.Done, its occurrence produces Renderer.Done. If it reaches State.Error, it produces Renderer.Error. If execution ends unexpectedly instead, the same site produces Renderer.Defect with an incident reference and one representative defect.

The source-qualified event keeps three facts distinct: Renderer says which mounted boundary reported the failure; event.incident names the immutable operational record; event.defect provides a direct representative for authored control flow.

Internal defects become child defects when unhandled

The example's child invokes RenderEdition, but deliberately declares no RenderEdition.Defect listener:

class Rendering extends State.Atomic<Rendering>()("Rendering") {
  static transitions = Transitions.make(this, ({ invoke, on }) => [
    invoke(RenderEdition),
    on(RenderEdition.Done, Rendered, ({ event }) => event.value),
  ]);
}

When that Effect dies, RenderJob cannot publish a normal chart completion. Its mounted Renderer occurrence therefore settles with Renderer.Defect. The parent does not need to know which internal Activity failed in order to supervise the child as one component.

A local on(RenderEdition.Defect, ...) would give the child topology authority to isolate or translate that failure before it reaches the chart boundary. Omitting it is a policy choice, not an implicit conversion to modeled error.

Targetless handling retains the failed child

The first frame handles the occurrence event without leaving Preparing:

spawn(Renderer),
on(Renderer.Done),
on(Renderer.Defect),

The child is no longer running. Unlike the released Activity slot from the previous page, the terminal child outcome remains recorded beneath its active owner. That retained outcome makes the child boundary inspectable, but it does not restart the child or make the failure successful.

Reentering Preparing would retire the completed incarnation and mount a fresh child with a new incarnation identity.

Unhandled defects propagate through child boundaries

The second frame mounts the same PublicationChart as another child occurrence:

const Publisher = PublicationChart.as("Publisher");

Publication does not handle Renderer.Defect. Its Publisher occurrence therefore ends with Publisher.Defect, and Workspace becomes the next available supervisor:

spawn(Publisher),
on(Publisher.Defect, Halted, ({ event }) => ({
  incident: event.incident,
  defect: event.defect,
})),

This is recursive supervision, not ambient exception catching. Each hop is an authored child site with a precise lifetime, and each listener is selected by active topology.

Propagation preserves the incident

Crossing a child boundary does not replace the failure with a generic "child crashed" flag. Publisher.Defect carries the incident reference and representative defect produced for the nested failure. The example stores both in Halted, where the live readout confirms that the original defect remains discoverable.

The next page examines Causes and defect channels directly. For now, the important rule is structural: preserve the incident reference until a deliberate supervisory or operational policy consumes it. The complete normalized Cause remains in incident storage rather than being copied through every chart boundary.

Supervision can stop at any authored boundary

An immediate parent may isolate one child, an outer parent may retire a whole subsystem, or a root engine may surface an unhandled defect to its operational boundary. The right listener is the narrowest boundary that can make an honest recovery or escalation decision.

Do not add a local listener merely to silence validation or logging. If a layer cannot respond meaningfully, allowing the defect to cross its occurrence boundary preserves authority for the supervisor that can.

Test the recursive boundary

  1. Defect both frames and compare the retained Renderer outcome with the exited Publisher site.
  2. Add on(RenderEdition.Defect, ...) inside RenderJob and confirm that no child defect escapes.
  3. Handle Renderer.Defect inside Publication and confirm that Publisher.Defect no longer reaches Workspace.
  4. Inspect Halted and verify that it retains the incident reference and original representative defect.

Next, Causes and defect channels separate the public supervisory event from the complete incident record it names.