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
- Let the second frame fire and confirm that
Waitingremains active while its slot becomesSpent. - In the third frame, choose
Complete firstand wait past the former deadline;Completedmust remain active. - Move
arm(IdleDeadline)toSessionand explain why enteringCompletedwould no longer cancel it.