Guide

Activity.Defect

Supervise one invoked Effect without weakening its modeled error contract.

Every Activity has a supervisory Defect channel—even when its modeled error type is impossible. The channel belongs to one mounted occurrence and reports that its implementation ended with an unexpected failure.

Supervise the occurrence that failed.

A bound Activity Effect can die even when its domain contract declares no modeled error. Activity.Defect reports that terminal outcome to the active topology without pretending it was expected.

1 · Observe in place

A defect settles the occurrence, not its owner.

Rendering consumes Render.Defect without a target. The state remains active, but the terminal occurrence is released and does not silently start again.

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

const Render = Activity.make("Render", {
  input: Schema.Struct({ orderId: Schema.String }),
  success: Schema.Struct({ url: Schema.String }),
});

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

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

class Rendering extends State.Atomic<Rendering>()("Rendering") {
  static transitions = Transitions.make(this, ({ invoke, on }) => [
    invoke(Render, () => ({ orderId: "order-123" })),
    on(Render.Done, Ready),
    on(Render.Defect),
  ]);
}

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

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

export const BillingChart = Statechart.make(Billing);

2 · Supervise from above

An ancestor can own the response.

Billing is active whenever Invoice.Rendering and its Render occurrence are active. The ancestor listener can therefore isolate the whole invoice in Halted, leaving no Activity slot beneath the exited branch.

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

const Render = Activity.make("Render", {
  input: Schema.Struct({ orderId: Schema.String }),
  success: Schema.Struct({ url: Schema.String }),
});

export class Billing extends State.Compound<Billing>()("Billing") {
  static states = States.make(() => [Invoice, Halted]);

  static transitions = Transitions.make(this, ({ on }) => [
    on(Render.Defect, Halted),
  ]);
}

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

class Rendering extends State.Atomic<Rendering>()("Rendering") {
  static transitions = Transitions.make(this, ({ invoke, on }) => [
    invoke(Render, () => ({ orderId: "order-123" })),
    on(Render.Done, Ready),
  ]);
}

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

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

export const BillingChart = Statechart.make(Billing);

Defect belongs to the invoked Effect

Render declares input and success Schemas but no modeled error Schema:

const Render = Activity.make("Render", {
  input: Schema.Struct({ orderId: Schema.String }),
  success: Schema.Struct({ url: Schema.String }),
});

The contract says there is no expected failure value for the domain to interpret. It does not say the implementation is physically incapable of dying.

If the bound Effect ends with a Cause containing a defect, Motive records an immutable incident and the mounted occurrence produces Render.Defect. The public event carries event.incident, the reference to that record, and event.defect, one squashed representative for ordinary control flow. Motive does not invent an Error.error value that the Activity never declared.

Defect is not a catch-all

Render.Defect describes the terminal outcome of the Effect bound to Render. It does not catch every defect that could happen while the chart is running.

A defect thrown while resolving invocation input, executing a transition action, or committing an engine step belongs to that different boundary. Keeping those failures separate preserves which operation failed and which authority can respond.

A targetless listener still handles the event

The first frame listens without changing topology:

on(Render.Defect);

The Rendering state remains active, but the Activity occurrence is no longer running. Its slot is released after the terminal outcome while its owner stays active. A targetless listener does not restart the Effect, turn the defect into success, or make the occurrence available for another attempt.

Reentering Rendering would retire that slot and mount a fresh occurrence. Retry policy is different: it keeps one occurrence alive across eligible modeled failures. A defect is not eligible for that modeled retry path.

The listener may live on an active ancestor

Completion events fold through the same active ancestry as ordinary events. Billing is active while its Invoice.Rendering descendant owns the invocation, so the second frame can place supervision on that outer compound state:

class Billing extends State.Compound<Billing>()("Billing") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Render.Defect, Halted),
  ]);
}

This is useful when several working states share one isolation or escalation policy. The listener must still be structurally capable of being active with the occurrence; an unrelated sibling is not a supervisor merely because it names the same trigger.

Leaving the owner retires its topology

The ancestor listener moves Billing to Halted. The defective occurrence has settled, and exiting the Invoice branch removes the invocation site from active topology. No Render slot remains beneath the new state.

The transition can read the incident reference and representative defect while it is selected. The complete normalized Cause, ownership coordinates, and provenance remain in the immutable incident record, available through the storage-backed incident service after the Activity slot is retired. State should retain the reference when later domain control flow needs to name that incident; it does not need to copy the operational record into chart data.

Supervision remains application policy

Activity.Defect gives topology a source-qualified event; it does not prescribe a universal response. A chart can observe in place, isolate a branch, escalate an incident, or terminate a larger boundary according to its own failure model.

That response should say something true about the unexpected failure. Routing a defect into an ordinary domain recovery state only to avoid supervisory handling erases the distinction Motive has preserved.

Test the occurrence boundary

  1. Defect both frames and compare the released occurrence under an active owner with the absent site after owner exit.
  2. Send another event while the first frame remains in Rendering and confirm that the defective occurrence does not run again.
  3. Move the ancestor listener to an exclusive sibling and follow the authoring error back to the missing coactivity proof.

Next, Child.Defect carries the same incident-backed supervisory evidence across an independently running child-chart boundary.