Guide

Timer inputs

Construct Schema-owned input when a Timer occurrence is armed.

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

  1. Start the second frame with each duration and compare arm input with binding input.
  2. Tighten Milliseconds to a maximum allowed delay and follow the constraint into both the event and producer.
  3. Remove the producer from arm(IdleDeadline, ...) and read the type error: a Timer with required input cannot be mounted without constructing it.