Guide

Child.Error

Handle modeled child failure without confusing it with supervisory defect.

A child chart reports an expected terminal failure through its mounted occurrence's typed Error event. Failure is part of the child protocol; the parent still decides how the application responds.

Modeled failure stays modeled.

A child terminal error crosses its occurrence boundary as typed application data and a complete Cause. The parent must disposition that channel independently from success and supervisory defects.

1 · Model failure inside the child

A terminal error becomes Child.Error.

RenderFailed owns the child chart's modeled-error Schema. Renderer.Error carries both the first typed failure projection and its complete Cause; a targetless listener handles it while Preparing keeps the failed child outcome recorded.

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

export const MissingAsset = Schema.TaggedStruct("MissingAsset", {
  asset: Schema.NonEmptyString,
});
const RenderResult = Schema.Struct({ artifactId: Schema.NonEmptyString });

export const RenderEdition = Activity.make("RenderEdition", {
  success: RenderResult,
  error: MissingAsset,
});

class RenderJob extends State.Compound<RenderJob>()("RenderJob") {
  static states = States.make(() => [Rendering, Rendered, RenderFailed]);
}

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

class Rendered extends State.Done<Rendered>()("Rendered", RenderResult) {}
class RenderFailed extends State.Error<RenderFailed>()("RenderFailed", MissingAsset) {}

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

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

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

class NeedsRevision extends State.Atomic<NeedsRevision>()("NeedsRevision") {}

export const PublicationChart = Statechart.make(Publication);

2 · Choose the parent response

Expected failure remains application data.

The same Renderer.Error event now enters NeedsRevision. The parent retains event.error for domain recovery and event.cause for complete failure evidence, while leaving Preparing retires the failed child slot.

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

export const MissingAsset = Schema.TaggedStruct("MissingAsset", {
  asset: Schema.NonEmptyString,
});
const RenderResult = Schema.Struct({ artifactId: Schema.NonEmptyString });
const RetainedFailure = {
  error: MissingAsset,
  cause: Schema.Cause(MissingAsset, Schema.Never),
};

export const RenderEdition = Activity.make("RenderEdition", {
  success: RenderResult,
  error: MissingAsset,
});

class RenderJob extends State.Compound<RenderJob>()("RenderJob") {
  static states = States.make(() => [Rendering, Rendered, RenderFailed]);
}

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

class Rendered extends State.Done<Rendered>()("Rendered", RenderResult) {}
class RenderFailed extends State.Error<RenderFailed>()("RenderFailed", MissingAsset) {}

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

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

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

export class NeedsRevision extends State.Atomic<NeedsRevision>()(
  "NeedsRevision",
  RetainedFailure,
) {}

export const PublicationChart = Statechart.make(Publication);

The child owns its modeled failure contract

A terminal State.Error declares the chart-level error Schema:

const MissingAsset = Schema.TaggedStruct("MissingAsset", {
  asset: Schema.NonEmptyString,
});

class RenderFailed extends State.Error<RenderFailed>()("RenderFailed", MissingAsset) {}

Reaching RenderFailed ends the child with a decoded domain failure. The error is expected enough to name in the model: a missing image may require a revised publication rather than crash recovery.

Internal failure becomes terminal output

The child translates an Activity failure into its chart-level contract:

on(RenderEdition.Error, RenderFailed, ({ event }) => event.error);

That translation is deliberate. Activities are implementation boundaries inside the child; RenderFailed states what failure means to consumers of the whole child chart. A complex child may try several operations before choosing its final modeled error.

The occurrence exposes Error

The parent's mounted occurrence names the boundary event:

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

on(Renderer.Error);

event.error is the first typed MissingAsset projection used for matching and ordinary domain logic. event.cause retains the complete modeled Cause, including every failure reason preserved by the child terminal snapshot.

The Cause is the encoding authority; the convenient error projection is derived from it. Neither field is an exception escaping the child, and clients cannot forge the reserved error.child.* event at the send boundary.

Targetless means handled, not recovered

The first frame writes down both possible child outcomes:

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

When rendering fails, Preparing stays active and the failed child outcome remains recorded below it. The error is dispositioned, but no domain recovery state owns a copy.

This can be correct when an action, emission, or sibling region owns the consequence. It is not an implicit retry and it does not turn modeled failure into success.

Parent state can retain error and Cause

The second frame makes recovery data explicit:

on(Renderer.Error, NeedsRevision, ({ event }) => ({
  error: event.error,
  cause: event.cause,
}));

NeedsRevision owns both fields. The tagged error drives product behavior; the complete Cause remains available for diagnostics, audit evidence, or a later policy decision without flattening it to a message string.

Leaving Preparing retires the failed child slot. Retention is therefore a parent modeling choice, not a reason to keep a terminal child mounted forever.

Failure and recovery publish atomically

The engine folds child settlement through parent transition selection in one macrostep. The first frame publishes Preparing with a failed child. The second publishes NeedsRevision with decoded parent data and no mounted Renderer.

Observers never see the child failure committed while the selected parent recovery is still pending.

Error is not Defect

Child.Error contains failures declared by a terminal State.Error. Child.Defect carries a supervisory Cause when the child dies unexpectedly, violates a runtime boundary, or otherwise cannot express the outcome as modeled application data.

Recovery from missing content belongs here. Policy for broken child execution belongs to supervision. Keeping the channels distinct lets each owner make an honest decision.

Test the modeled-failure boundary

  1. Fail both children and compare the retained child Cause with NeedsRevision data.
  2. Add another tagged failure and use the typed projection to select distinct parent responses.
  3. Remove Renderer.Error and confirm validation rejects the incomplete child protocol.
  4. Replace the modeled failure with a defect and verify that Child.Error no longer handles it.