When an armed Timer reaches its deadline, the engine creates a typed completion event. The Timer owns that event's identity; the chart owns what happens next.
The clock reports completion; the model interprets it.
Firing spends one armed Timer incarnation and creates its payload-less Done event. The event then follows ordinary transition selection: the chart decides whether to stay, move, or perform actions.
1 · Dispose without moving
A targetless Done handler spends the Timer.
IdleDeadline fires and its occurrence becomes spent before the payload-less Done event is selected. Waiting handles that event explicitly without choosing a target, so configuration stays put.
import { State, Statechart, States, Timer, Transitions } from "@motive/motive";
import { Schema } from "effect";
const BeginWaiting = Schema.TaggedStruct("BeginWaiting", {});
export const IdleDeadline = Timer.relative("IdleDeadline");
export 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);
2 · Give completion a destination
Timer.Done triggers an ordinary transition.
The same engine-owned completion now targets Expired. Firing still spends the occurrence first; the authored edge then exits Waiting and commits Expired as the application-visible consequence.
import { State, Statechart, States, Timer, Transitions } from "@motive/motive";
import { Schema } from "effect";
const BeginWaiting = Schema.TaggedStruct("BeginWaiting", {});
export const IdleDeadline = Timer.relative("IdleDeadline");
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(IdleDeadline),
on(IdleDeadline.Done, Expired),
]);
}
class Expired extends State.Atomic<Expired>()("Expired") {}
export const SessionChart = Statechart.make(Session);
Firing creates Timer.Done
IdleDeadline.Done is derived from the Timer definition:
const IdleDeadline = Timer.relative("IdleDeadline");
on(IdleDeadline.Done);
Authors refer to the Timer value rather than spelling a raw event tag. That keeps the listener tied to the citizen that can actually produce it and lets assembly detect missing or impossible dispositions.
Done is engine-owned
A client cannot send IdleDeadline.Done. The lowered done.timer.* namespace is reserved for the
engine occurrence that was armed and reached its deadline.
This prevents a caller from forging time. Tests can advance a simulator or virtual clock, but they still exercise the Timer mechanism rather than bypassing it with an ordinary public command.
Completion has no payload
Timer.Done carries identity, not a result value. Facts that determine when the deadline fires
belong in Timer input; facts the application must retain after firing belong in state data or an
action selected by the completion edge.
There is therefore no event.value or event.error to copy. The meaningful fact is that this exact
armed incarnation reached its deadline.
Firing spends before selection
The engine spends the timer incarnation before routing Done through active topology. Even if the
selected handler stays in Waiting, the old occurrence cannot fire again.
The first frame writes that decision as targetless handling:
on(IdleDeadline.Done);
The readout changes from Armed to Spent, while the configuration remains Waiting.
Done uses ordinary transition semantics
The second frame gives the same completion a destination:
on(IdleDeadline.Done, Expired);
The edge may use guards, updates, raises, emits, or target input just like an ordinary on edge.
Here it exits Waiting, cancels anything else owned there, and enters Expired in one committed
macrostep.
Every mount needs a disposition
Each mounted Timer occurrence needs a provably co-active Done listener. A targetless listener is
an explicit choice to consume completion without moving; omitting the listener is not.
Guarded branches still need total coverage. If every eligible guard refuses at runtime, the completion remains unconsumed and the already-fired occurrence remains spent.
One firing, one publication
Readers do not observe a separate “timer fired” snapshot followed by an Expired snapshot. The
spent occurrence and the transition consequences publish as one stable result of the completion
macrostep.
That atomic boundary matters for durable engines: recovery can establish whether the occurrence was spent and which chart result committed without replaying an ambiguous host callback.
Test completion semantics
- Let both frames fire and compare
Waiting · SpentwithExpired · Spent. - Add a targetless fallback after a guarded transition and verify that every completion has an authored disposition.
- Try sending a raw
done.timer.IdleDeadlineevent through the client and confirm the reserved completion boundary refuses it.