Guide

Causes and Defect Channels

Preserve every failure reason and classify the complete Cause honestly.

Every failed Effect ends with a Cause: a structured value that preserves all the reasons the computation failed. Motive uses the whole Cause to classify an outcome. Modeled Error events expose it directly; supervisory Defect events record it in immutable incident storage and carry the incident reference plus one representative defect.

A Cause is more than one error.

A Cause preserves every reason an Effect failed. Motive classifies that complete value at an Activity or child boundary, then exposes the channel that matches its strongest reason.

1 · Project modeled failure

Error gives one typed reason a domain name.

Review fails with a single typed Fail reason. Review.Error exposes that reason as event.error while retaining the complete one-reason Cause as event.cause.

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

export const ReviewUnavailable = Schema.TaggedStruct("ReviewUnavailable", {
  service: Schema.NonEmptyString,
});

const Review = Activity.make("Review", {
  success: Schema.Void,
  error: ReviewUnavailable,
});

class ReviewFlow extends State.Compound<ReviewFlow>()("ReviewFlow") {
  static states = States.make(() => [Checking, Approved, NeedsReview]);
}

class Checking extends State.Atomic<Checking>()("Checking") {
  static transitions = Transitions.make(this, ({ invoke, on }) => [
    invoke(Review),
    on(Review.Done, Approved),
    on(Review.Error, NeedsReview, ({ event }) => ({
      error: event.error,
      cause: event.cause,
    })),
  ]);
}

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

export class NeedsReview extends State.Atomic<NeedsReview>()("NeedsReview", {
  error: ReviewUnavailable,
  cause: Schema.Cause(ReviewUnavailable, Schema.Never),
}) {}

export const ReviewFlowChart = Statechart.make(ReviewFlow);

2 · Classify the whole Cause

One defect keeps a mixed Cause supervisory.

The same Activity now ends with both Fail and Die reasons. Because the complete Cause contains a defect, Review.Defect—not Review.Error—is selected; Halted retains the incident reference and representative defect while incident storage retains both reasons.

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

export const ReviewUnavailable = Schema.TaggedStruct("ReviewUnavailable", {
  service: Schema.NonEmptyString,
});

const Review = Activity.make("Review", {
  success: Schema.Void,
  error: ReviewUnavailable,
});

class ReviewFlow extends State.Compound<ReviewFlow>()("ReviewFlow") {
  static states = States.make(() => [Checking, Approved, NeedsReview, Halted]);
}

class Checking extends State.Atomic<Checking>()("Checking") {
  static transitions = Transitions.make(this, ({ invoke, on }) => [
    invoke(Review),
    on(Review.Done, Approved),
    on(Review.Error, NeedsReview, ({ event }) => ({
      error: event.error,
      cause: event.cause,
    })),
    on(Review.Defect, Halted, ({ event }) => ({
      incident: event.incident,
      defect: event.defect,
    })),
  ]);
}

class Approved extends State.Atomic<Approved>()("Approved") {}
class NeedsReview extends State.Atomic<NeedsReview>()("NeedsReview", {
  error: ReviewUnavailable,
  cause: Schema.Cause(ReviewUnavailable, Schema.Never),
}) {}

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

export const ReviewFlowChart = Statechart.make(ReviewFlow);

Cause preserves failure structure

In the installed Effect version, a Cause contains a reasons collection. Each reason is one of three kinds:

  • Fail<E> carries a typed error from the Effect's expected error channel.
  • Die carries an unexpected defect.
  • Interrupt records which fiber interrupted the computation.

A Cause may contain more than one reason. Concurrency, cleanup, and composed failure can therefore retain facts that a single Error value cannot represent.

Error is a projection of a modeled Cause

The first frame settles Review with one typed ReviewUnavailable failure. With no defect in the Cause, Motive selects Review.Error.

That event exposes two related values:

on(Review.Error, NeedsReview, ({ event }) => ({
  error: event.error,
  cause: event.cause,
}));

event.error is the convenient typed projection for ordinary domain decisions. event.cause is the complete modeled Cause. The projection does not replace its authority.

Defect classification examines the whole Cause

The second frame ends with a Cause containing both a typed Fail and an unexpected Die. Motive checks the complete Cause, finds the defect, and selects Review.Defect:

on(Review.Defect, Halted, ({ event }) => ({
  incident: event.incident,
  defect: event.defect,
}));

The typed failure reason is not discarded: it remains in the incident record's normalized Cause. But the public event is not weakened to Review.Error merely because a modeled reason is also present. Any retained defect keeps the terminal outcome under supervisory authority.

The channel is a classification, not a second failure

Review.Error and Review.Defect do not wrap the Cause in new exceptions. They describe how the mounted occurrence classified its terminal Cause and which authored listener may respond. Error keeps the typed projection and Cause close to domain control flow; Defect keeps the complete Cause under incident authority and gives the chart only its reference and representative.

This is why a defect can cross child boundaries without accumulating strings such as "child failed" and "parent failed." The occurrence source changes at each boundary; the incident remains the authority for the structured Cause.

Interruption belongs to lifecycle

An Interrupt reason records cancellation of the running Effect. A pure interruption is not promoted into Activity.Defect: leaving the owning state, stopping an instance, and retiring a child are lifecycle operations, not evidence that the implementation died unexpectedly.

A Cause may retain interruption alongside other reasons. Classification still responds to the whole value: a contained Die remains a defect, and a typed Fail remains available for modeled inspection when no defect is present.

Inspect without flattening

Effect provides structural operations such as Cause.hasDies, Cause.hasFails, Cause.findDefect, and Cause.findErrorOption. Use them directly with a modeled Error Cause, or after retrieving a supervisory incident record at the operational policy boundary that needs to decide, format, or retain a specific fact. Use event.defect when authored control flow needs only the representative unexpected value.

Avoid reducing a Cause to String(cause) at the first listener. Human-readable formatting is a projection for logs or UI; it is not a replacement for typed reasons, annotations, interruption identity, or the original defect value.

Test the classification boundary

  1. Settle both frames and compare the one-reason Error Cause with the Defect event's incident reference and representative.
  2. Remove the Die reason from the second frame and confirm that Review.Error becomes eligible.
  3. Add a second typed Fail reason and verify that the complete Cause retains both even though event.error remains one convenient projection.
  4. Retrieve the incident record, encode and decode its Cause, and confirm both reason kinds survive the durable boundary.

Next, retry policy decides which modeled failures may attempt the same occurrence again—and what happens when that policy is exhausted.