Guide

Activity.Error

Consume typed expected failure and model the application's response.

An Activity reports an expected failure through its typed Error event. The implementation says what went wrong; the chart decides what that failure means for the application.

Expected failure returns through the model.

An Activity's error Schema creates a typed Error event. The implementation reports the failure; the chart decides how the application responds and whether its next state retains the reason.

1 · Model expected failure

Error is an event the chart interprets.

ReviewOrder fails with a decoded ReviewUnavailable value. The listener enters NeedsReview, but the destination retains none of the failure payload yet.

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

const ReviewUnavailable = Schema.TaggedStruct("ReviewUnavailable", {
  reason: Schema.String,
});

const ReviewOrder = Activity.make("ReviewOrder", {
  input: Schema.Struct({ orderId: Schema.String }),
  success: Schema.Struct({ approvalCode: Schema.String }),
  error: ReviewUnavailable,
});

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

class Checking extends State.Atomic<Checking>()("Checking", {
  orderId: Schema.String.pipe(
    Schema.withConstructorDefault(Effect.succeed("order-123")),
  ),
}) {
  static transitions = Transitions.make(this, ({ invoke, on }) => [
    invoke(ReviewOrder, ({ state }) => ({ orderId: state.orderId })),
    on(ReviewOrder.Done),
    on(ReviewOrder.Error, NeedsReview),
  ]);
}

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

export const ReviewChart = Statechart.make(Review);

2 · Carry the failure forward

Construct the destination from event.error.

NeedsReview now owns a reason. Its transition input reads ReviewOrder.Error.error, so the typed implementation failure becomes durable state only because the chart explicitly retains it.

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

const ReviewUnavailable = Schema.TaggedStruct("ReviewUnavailable", {
  reason: Schema.String,
});

const ReviewOrder = Activity.make("ReviewOrder", {
  input: Schema.Struct({ orderId: Schema.String }),
  success: Schema.Struct({ approvalCode: Schema.String }),
  error: ReviewUnavailable,
});

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

class Checking extends State.Atomic<Checking>()("Checking", {
  orderId: Schema.String.pipe(
    Schema.withConstructorDefault(Effect.succeed("order-123")),
  ),
}) {
  static transitions = Transitions.make(this, ({ invoke, on }) => [
    invoke(ReviewOrder, ({ state }) => ({ orderId: state.orderId })),
    on(ReviewOrder.Done),
    on(ReviewOrder.Error, NeedsReview, ({ event }) => ({
      reason: event.error.reason,
    })),
  ]);
}

export class NeedsReview extends State.Atomic<NeedsReview>()("NeedsReview", {
  reason: Schema.String,
}) {}

export const ReviewChart = Statechart.make(Review);

Error is part of the contract

ReviewOrder declares a Schema for failures the application expects to model:

error: Schema.TaggedStruct("ReviewUnavailable", {
  reason: Schema.String,
});

That declaration creates ReviewOrder.Error. When the bound Effect fails, Motive encodes and decodes its error through the Schema before the completion event reaches transition selection.

Failure does not choose the recovery

Both frames fail with the same value:

{ _tag: "ReviewUnavailable", reason: "risk service unavailable" }

The Effect implementation does not enter NeedsReview, retry itself, or turn the chart into a failed instance. Checking owns the domain response:

on(ReviewOrder.Error, NeedsReview);

The first frame therefore reaches NeedsReview while deliberately retaining none of the returned error. An implementation failure and the application's response to it remain separate decisions.

Error is a modeled channel

ReviewOrder.Error represents the Activity's declared error type. It is not an exception leaking through the chart, and it is not the same thing as Activity.Defect, which belongs to supervision of unexpected failures.

The distinction matters: unavailable dependencies, declined payments, and rejected inputs can be ordinary facts in a domain even when their implementations use Effect's error channel.

The payload lives on event.error

The decoded failure is available as event.error inside guards, target input producers, and actions selected by the Error edge. Its type comes directly from the Activity's error Schema.

Here, event.error is narrowed to ReviewUnavailable, so reason is known to be a string and the _tag remains available for branching or observation.

Retention needs a state owner

The second frame gives NeedsReview a reason field and constructs the destination from the event:

on(ReviewOrder.Error, NeedsReview, ({ event }) => ({
  reason: event.error.reason,
}));

The readout separates Error.error from NeedsReview data. The reason survives after Checking exits only in the frame whose topology names an owner for it.

Failure settles the occurrence

A terminal error settles this mounted ReviewOrder occurrence and releases its Activity slot. The Error event is folded through active topology in the same committed chart boundary as the transition into NeedsReview.

Re-entering Checking would mount a new occurrence. Retry policy, when authored, keeps one occurrence alive across attempts and exposes Error only after that policy is exhausted.

Account for every modeled outcome

Because ReviewOrder declares both success and error channels, its active invocation needs an eligible disposition for both Done and Error. A targetless listener is an explicit disposition; silence is not.

This makes the possible outcomes inspectable from the model before an implementation runs, while leaving the chart free to transition, retain data, raise consequences, or deliberately stay put.

Test the failure boundary

  1. Fail ReviewOrder in both frames and compare Error.error with retained NeedsReview data.
  2. Change the error Schema and follow the resulting type error into the target input producer.
  3. Replace the transition with targetless on(ReviewOrder.Error) and confirm the instance remains running after the occurrence settles.