Guide

Timers and arm

Mount time on its owning state and let topology govern its lifetime.

A timeout is not merely an event that arrives later. It is a running piece of time with an owner, a birth, and an end.

Time belongs to the state that waits for it.

A deadline needs an owner. arm mounts a Timer on the state that needs it, making creation and cancellation consequences of topology instead of host-side bookkeeping.

1 · Time arrives as another command

A caller has to remember when Waiting expires.

The chart understands Expire but owns no clock. A host must schedule and send that event, then somehow cancel its work if Waiting exits first.

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

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

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, ({ on }) => [
    on(Expire, Expired),
  ]);
}

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

export const SessionChart = Statechart.make(Session);

2 · Mount time on its owner

arm gives the deadline a lifetime.

IdleDeadline is a relative Timer citizen. Entering Waiting resolves and arms its default occurrence; when it fires, the targetless Done disposition spends that incarnation without changing state.

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

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

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

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

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(IdleDeadline),
    on(IdleDeadline.Done),
  ]);
}

export const SessionChart = Statechart.make(Session);

3 · Exit ends the incarnation

Leaving Waiting cancels its deadline.

Complete exits Waiting before IdleDeadline fires. The timer incarnation is released with its owner, so the old deadline cannot later pull Completed into Expired.

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

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

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

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

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(IdleDeadline),
    on(IdleDeadline.Done),
    on(Complete, Completed),
  ]);
}

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

export const SessionChart = Statechart.make(Session);

An event does not own a clock

The first frame gives Waiting an ordinary Expire transition. The chart understands what the event means, but it says nothing about who measures the delay.

A host would need to schedule work, remember which root instance it belongs to, send Expire at the right time, and cancel that work if the session leaves Waiting first. The temporal lifecycle would exist beside the model instead of inside it.

Declare a Timer citizen

Timer.relative names a duration measured from the moment an occurrence is armed:

const IdleDeadline = Timer.relative("IdleDeadline");

This declares timer identity, not a running timeout. Like an Activity definition, the value can be reused wherever the model needs to refer to this kind of temporal citizen.

arm mounts the default occurrence

Waiting mounts one occurrence with arm:

static transitions = Transitions.make(this, ({ arm, on }) => [
  arm(IdleDeadline),
  on(IdleDeadline.Done),
]);

Entering Waiting creates the occurrence and asks its runtime binding for the duration. The targetless Done listener is an explicit disposition required by the current model; the next Time pages will give timer completion and input their own treatment.

Bind duration at the runtime boundary

The declarative chart names the deadline and its owner. toLayer supplies the Effect that resolves how long this deployment waits:

SessionChart.toLayer({
  timers: {
    [IdleDeadline.tag]: () => Effect.succeed("1600 millis"),
  },
});

The binding may read services or configuration, but it does not choose where the Timer is mounted or what its completion means. Those remain visible in topology.

Firing spends an incarnation

When the duration elapses, the engine spends that exact timer incarnation before routing its completion. A spent occurrence cannot fire twice.

In the second frame the targetless disposition leaves Waiting active, so the readout changes from Armed to Spent without a state transition. Remaining in the owner does not silently re-arm the same occurrence.

Exit is cancellation

The final frame adds Complete. If it arrives first, the chart exits Waiting and enters Completed; the armed deadline is released in the same committed decision.

The old incarnation cannot fire later and pull the completed session somewhere else. There is no callback handle to clear and no host-side race to reconcile: the topology that mounts the Timer also decides when it stops existing.

Entry creates a fresh incarnation

Entering Waiting again would resolve the binding again and arm a new incarnation. Identity of the Timer definition remains stable while each mounted lifetime is distinct.

That distinction lets engines persist, recover, or simulate time without changing chart meaning. Restart and explicit cancellation controls exist for behavior that stays within the owner, but ordinary entry and exit establish the fundamental lifecycle.

Test the lifecycle

  1. Let the second frame fire and confirm that Waiting remains active while its slot becomes Spent.
  2. In the third frame, choose Complete first and wait past the former deadline; Completed must remain active.
  3. Move arm(IdleDeadline) to Session and explain why entering Completed would no longer cancel it.