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
- Fail both children and compare the retained child Cause with
NeedsRevisiondata. - Add another tagged failure and use the typed projection to select distinct parent responses.
- Remove
Renderer.Errorand confirm validation rejects the incomplete child protocol. - Replace the modeled failure with a defect and verify that
Child.Errorno longer handles it.