Guide

.restart and .cancel

Replace or remove a mounted Timer incarnation without leaving its owner.

Arming gives a Timer occurrence a lifecycle. Control actions can replace or remove that incarnation while its owning state remains active.

Control the clock without changing the topology.

restart replaces a mounted Timer incarnation; cancel removes it. Both are transition actions, so control remains explicit and publishes atomically without requiring the owner state to exit.

1 · Replace the incarnation

restart begins the mounted clock again.

RestartDeadline does not leave Waiting. Its action cancels the current Deadline incarnation, resolves the same binding again, and arms a fresh incarnation at the existing mount.

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

const BeginWaiting = Schema.TaggedStruct("BeginWaiting", {});
const RestartDeadline = Schema.TaggedStruct("RestartDeadline", {});

export const Deadline = Timer.relative("Deadline");

export class Session extends State.Compound<Session>()("Session") {
  static states = States.make(() => [Idle, Waiting, Expired]);
}

class Idle extends State.Atomic<Idle>()("Idle") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(BeginWaiting, Waiting),
  ]);
}

class Waiting extends State.Atomic<Waiting>()("Waiting") {
  static transitions = Transitions.make(this, ({ arm, on }) => [
    arm(Deadline),
    on(Deadline.Done, Expired),
    on(RestartDeadline).restart(Deadline),
  ]);
}

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

export const SessionChart = Statechart.make(Session);

2 · Remove the incarnation

cancel ends the clock without exiting its owner.

CancelDeadline removes the live Deadline slot while Waiting remains active. The former deadline cannot later produce Done; entry or an explicit restart is required to create another incarnation.

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

const BeginWaiting = Schema.TaggedStruct("BeginWaiting", {});
const RestartDeadline = Schema.TaggedStruct("RestartDeadline", {});
const CancelDeadline = Schema.TaggedStruct("CancelDeadline", {});

export const Deadline = Timer.relative("Deadline");

export class Session extends State.Compound<Session>()("Session") {
  static states = States.make(() => [Idle, Waiting, Expired]);
}

class Idle extends State.Atomic<Idle>()("Idle") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(BeginWaiting, Waiting),
  ]);
}

class Waiting extends State.Atomic<Waiting>()("Waiting") {
  static transitions = Transitions.make(this, ({ arm, on }) => [
    arm(Deadline),
    on(Deadline.Done, Expired),
    on(RestartDeadline).restart(Deadline),
    on(CancelDeadline).cancel(Deadline),
  ]);
}

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

export const SessionChart = Statechart.make(Session);

restart replaces the incarnation

Attach restart to an ordinary transition:

on(RestartDeadline).restart(Deadline);

Handling RestartDeadline cancels the current incarnation and arms a fresh one at the same authored mount. Waiting stays active, but the deadline begins again from the control macrostep.

The runtime resolves the Timer binding again because restart creates a new incarnation. Any input producer is evaluated again too, so the replacement can capture current state facts.

cancel removes the incarnation

cancel addresses the same occurrence without creating a replacement:

on(CancelDeadline).cancel(Deadline);

The occurrence slot disappears and the old deadline can no longer fire. This is not a transition to another state: Waiting remains the active configuration.

Controls are transition actions

restart and cancel are fluent actions on on, always, and other action-bearing transition forms. Guards and transition selection decide whether the control runs; the action then joins the same atomic macrostep as every other selected consequence.

That makes control observable as one stable result. A client never observes an intermediate snapshot where the old incarnation has been canceled but the restarted incarnation has not yet been armed.

Address the occurrence not the class in general

The Timer value addresses its default occurrence:

on(RestartDeadline).restart(Deadline);

When one class has named sites, address the exact site instead:

on(RestartReminder).restart(Reminder);
on(CancelExpiration).cancel(Expiration);

Control follows occurrence identity. Restarting Reminder cannot replace Expiration, even when both sites share one source Timer and binding.

The mount must be in scope

A control can only address a Timer mounted by its source state or an ancestor. This keeps the operation geometrically meaningful: if the transition can run, the authored occurrence site is in scope.

The chart refuses a dead control whose source has no ancestor-or-self arm site. That failure is an authoring diagnostic, not a runtime no-op.

Entry and exit remain structural controls

Explicit control does not replace topology-owned lifecycle:

  • entering the mount state arms a fresh incarnation;
  • re-entering it cancels the old incarnation and arms a new one;
  • exiting it cancels every still-live occurrence owned by that state.

Use restart and cancel when an event or eventless decision must control time without changing the active topology.

Test the control boundary

  1. Restart the first frame after part of its duration and verify that the original deadline passes while Waiting remains active.
  2. Verify that the binding resolves once for initial arming and again for restart.
  3. Cancel the second frame, wait beyond its former deadline, and verify that Deadline.Done never enters Expired.
  4. Move a control to a state outside the mount's ancestor-or-self scope and read the chart assembly diagnostic.