A Timer binding decides how runtime time is represented. A Timer input decides which Schema-validated facts the chart gives that binding when an occurrence is armed.
Arming captures the facts that determine a deadline.
A Timer input is the immutable value captured when an occurrence is armed. The chart constructs it from active data; the runtime binding receives the decoded contract and turns it into time.
1 · Begin with a fixed binding
The runtime owns the only duration.
IdleDeadline has no input Schema, so arm carries no value across the model/runtime boundary. Its binding can resolve a duration, but the chart cannot vary that duration from current data.
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 · Construct typed input at arming
arm captures the duration from active state.
IdleDeadline declares milliseconds. Waiting owns the chosen value, arm constructs the timer input from that facet, and the runtime binding receives the same decoded object exactly when the occurrence mounts.
import { State, Statechart, States, Timer, Transitions } from "@motive/motive";
import { Schema } from "effect";
const Milliseconds = Schema.Finite.check(Schema.isGreaterThan(0));
const BeginWaiting = Schema.TaggedStruct("BeginWaiting", {
milliseconds: Milliseconds,
});
export const IdleDeadline = Timer.relative("IdleDeadline", {
input: Schema.Struct({ milliseconds: Milliseconds }),
});
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, ({ event }) => ({
milliseconds: event.milliseconds,
})),
]);
}
export class Waiting extends State.Atomic<Waiting>()("Waiting", {
milliseconds: Milliseconds,
}) {
static transitions = Transitions.make(this, ({ arm, on }) => [
arm(IdleDeadline, ({ state }) => ({
milliseconds: state.milliseconds,
})),
on(IdleDeadline.Done),
]);
}
export const SessionChart = Statechart.make(Session);
Input belongs to the Timer definition
The baseline IdleDeadline has no input Schema. Its binding may return a fixed duration, but
Waiting has no typed way to supply a different value:
const IdleDeadline = Timer.relative("IdleDeadline");
That is useful when configuration owns the entire deadline. When chart data should choose among durations, the contract needs to say what crosses the boundary.
Declare the arming contract
The second frame gives IdleDeadline one Schema-owned field:
const Milliseconds = Schema.Finite.check(Schema.isGreaterThan(0));
const IdleDeadline = Timer.relative("IdleDeadline", {
input: Schema.Struct({ milliseconds: Milliseconds }),
});
The Schema owns decoding, validation, and the inferred input type. The binding can no longer quietly expect a string, omit the field, or accept a non-positive or non-finite number.
Construct input from active data
Waiting owns the chosen delay. Its mount constructs timer input through the same checked producer
language used elsewhere in the chart:
arm(IdleDeadline, ({ state }) => ({
milliseconds: state.milliseconds,
}));
This producer runs when Waiting is entered and the occurrence is armed. It does not run when the
Timer is merely declared, and the binding does not reach back into mutable chart state later.
The binding receives decoded input
toLayer closes the other side of the contract:
timers: {
IdleDeadline: ({ input }) =>
Effect.succeed(Duration.millis(input.milliseconds)),
}
Because the Timer declares input, the handler argument includes a typed input field. The runtime
implementation translates that domain value into Effect's Duration; it does not parse an
unchecked object or discover which state happened to mount it.
Input is captured once per incarnation
The readout compares Waiting data with the value observed by the binding. They agree because one
arming operation constructs, validates, and delivers one immutable input value.
Changing Waiting.milliseconds after arming would not mutate the current deadline. Re-entry or an
explicit restart creates a new incarnation and runs the input producer and binding again.
State owns policy; runtime owns mechanism
The chart can choose 1200 or 2400 milliseconds from events, state, queries, or services allowed
at the authoring boundary. The binding decides how that value becomes runtime time in this
deployment.
This separation keeps the reason for a deadline in the model while leaving clocks, persistence, and scheduling machinery under the engine and its Effect environment.
Test the contract
- Start the second frame with each duration and compare
arm inputwithbinding input. - Tighten
Millisecondsto a maximum allowed delay and follow the constraint into both the event and producer. - Remove the producer from
arm(IdleDeadline, ...)and read the type error: a Timer with required input cannot be mounted without constructing it.