Guide

Required and Optional Defect Dispositions

Close every modeled terminal channel while keeping defect supervision deliberate.

Motive requires a chart to close every terminal channel declared by its model. Unexpected defects remain available to supervision without becoming another mandatory domain branch.

Modeled outcomes are total; supervision is a choice.

Motive proves that every declared outcome has somewhere to go. Defects remain visible too, but the topology chooses whether and where to supervise them.

1 · Close the declared contract

Known outcomes require a total disposition.

Publish declares success and modeled error, so Publishing handles both Done and Error. The targetless listeners are explicit dispositions: either outcome releases the occurrence without forcing a state change.

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

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

export const Publish = Activity.make("Publish", {
  input: Schema.Struct({ releaseId: Schema.String }),
  success: Schema.Struct({ url: Schema.String }),
  error: PublishUnavailable,
});

export class Release extends State.Compound<Release>()("Release") {
  static states = States.make(() => [Publishing]);
}

class Publishing extends State.Atomic<Publishing>()("Publishing") {
  static transitions = Transitions.make(this, ({ invoke, on }) => [
    invoke(Publish, () => ({ releaseId: "release-42" })),
    on(Publish.Done),
    on(Publish.Error),
  ]);
}

export const ReleaseChart = Statechart.make(Release);

2 · Choose a supervisory boundary

Defect handling is authored when the model owns it.

Publish.Defect was always available, but it was not required for assembly. Adding this listener gives Release an explicit policy: an unexpected failure now moves the chart into Halted instead of escaping to the next supervisor.

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

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

export const Publish = Activity.make("Publish", {
  input: Schema.Struct({ releaseId: Schema.String }),
  success: Schema.Struct({ url: Schema.String }),
  error: PublishUnavailable,
});

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

class Publishing extends State.Atomic<Publishing>()("Publishing") {
  static transitions = Transitions.make(this, ({ invoke, on }) => [
    invoke(Publish, () => ({ releaseId: "release-42" })),
    on(Publish.Done),
    on(Publish.Error),
    on(Publish.Defect, Halted),
  ]);
}

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

export const ReleaseChart = Statechart.make(Release);

The declaration defines what is required

An Activity that declares success and modeled error can finish through Done or Error. Both channels therefore need a disposition at a state that can be active with the invocation:

invoke(Publish, () => ({ releaseId: "release-42" })),
on(Publish.Done),
on(Publish.Error),

Remove either listener and TypeScript reports ActivityChannelMustHaveTotalDisposition at Statechart.make. The encoded graph validator names the same missing structural coverage CompletionDispositionNeedsTotal. Both point back to the invocation site and uncovered channel; the remedy is topology, not a cast or runtime fallback.

A disposition does not have to move the chart

The first frame handles Publish.Error without a target. Publishing remains active, while the settled Activity occurrence is released and does not silently restart.

A required disposition may transition, update data, raise an event, or intentionally consume the outcome in place. What matters is that it is total, selectable, and provably co-active with the producer. One guarded branch alone does not cover a required channel; use a complete match or an orElse branch when the response depends on the payload.

Defect is available without being required

Publish.Defect exists even if an Activity declares no modeled error Schema. It carries an incident reference and representative defect for unexpected failure, but it is not added to the chart's required completion set.

The second frame authors that optional policy explicitly:

on(Publish.Defect, Halted),

Optional does not mean structurally unchecked. Once authored, the listener still must be able to coexist with its producer and participate in real selection. A defect listener placed in an exclusive sibling or hidden completely by a nearer listener is rejected as impossible or shadowed.

An omitted listener does not swallow the incident

Without a local Defect disposition, Motive preserves the incident evidence and offers it to the next supervisory boundary. The immutable record still owns the complete Cause. An Activity defect may become Child.Defect as it crosses a child-chart boundary without minting a weaker replacement. If no owner remains above an ownerless root, the engine parks that root against the incident for operational inspection.

This is why defect handling can remain optional without becoming best-effort error handling. The model chooses where recovery is meaningful; the runtime still refuses to lose an unexpected failure.

Aggregate only the subtree you supervise

When one owner has policy for several descendants, State.defect(scope) derives one supervisory boundary from that exact topology:

on(State.defect(Publishing), Halted, ({ event }) => ({
  incident: event.incident,
  defect: event.defect,
}));

The boundary receives only a descendant Defect that escaped every nearer exact listener. A local on(Publish.Defect, ...) still wins for Publish; the aggregate is not a competing catch-all and does not intercept modeled Error outcomes. Assembly resolves the symbolic state or Component placement to its public descendant defect citizens and rejects an empty or ambiguous scope.

This is useful when recovery belongs to a whole lane, region, or placed feature. It keeps the boundary structural: moving work outside Publishing removes it from the supervisor without editing a list of event names.

Put the policy where recovery belongs

A local targetless listener can record or acknowledge a defective occurrence while retaining its state. An ancestor can isolate a larger branch. A parent chart can replace a defective child. An operational boundary can park the root for human or automated recovery.

Choose the narrowest owner that has enough context to act. Catching every defect locally is not more complete if the local state cannot make a sound recovery decision.

Keep the two contracts separate

A Defect listener does not satisfy a missing modeled Error disposition, and adding a broad error Schema does not turn an unexpected failure into domain knowledge. Required modeled channels describe what the Activity promises may happen; optional supervision describes who can respond when that promise is broken.

Test the disposition boundary

  1. Remove on(Publish.Error) and confirm chart assembly names the uncovered required channel.
  2. Replace it with one guarded listener and confirm partial selection still fails totality.
  3. Remove on(Publish.Defect, Halted) and confirm the chart remains valid.
  4. Let the provider die without that listener and inspect the same incident reference at the next supervisory boundary, then retrieve its complete Cause from incident storage.

Next, named occurrences introduce .as: the first step from one default mount toward explicit multiplicity and occurrence identity.