Guide

Named Timer occurrences

Give fixed Timer mounts independent identities while reusing one contract.

One kind of deadline may appear more than once in the same chart. Named occurrences preserve one Timer contract while giving every mounted clock its own address and completion event.

One Timer contract can govern several clocks.

Timer.as(id) derives a fixed occurrence from one reusable Timer class. Each site owns an independent input, slot, and Done trigger while every site shares the class Schema and runtime binding.

1 · Mount the default occurrence

A Timer value names one occurrence by default.

Waiting arms Deadline directly. Its implicit occurrence shares the class name, resolves the binding once, and enters Expired through Deadline.Done when that single incarnation fires.

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

const BeginWaiting = Schema.TaggedStruct("BeginWaiting", {});
const Milliseconds = Schema.Finite.check(Schema.isGreaterThan(0));

export const Deadline = Timer.relative("Deadline", {
  input: Schema.Struct({ milliseconds: Milliseconds }),
});

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(Deadline, () => ({ milliseconds: 1400 })),
    on(Deadline.Done, Expired),
  ]);
}

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

export const SessionChart = Statechart.make(Session);

2 · Name two fixed occurrences

Timer.as gives each mount its own identity.

Reminder and Expiration reuse Deadline's Schema and binding but own independent inputs, slots, and Done triggers. Reminder can become spent while Expiration remains armed, then Expiration alone enters Expired.

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

const BeginWaiting = Schema.TaggedStruct("BeginWaiting", {});
const Milliseconds = Schema.Finite.check(Schema.isGreaterThan(0));

export const Deadline = Timer.relative("Deadline", {
  input: Schema.Struct({ milliseconds: Milliseconds }),
});

export const Reminder = Deadline.as("Reminder");
export const Expiration = Deadline.as("Expiration");

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(Reminder, () => ({ milliseconds: 800 })),
    arm(Expiration, () => ({ milliseconds: 1900 })),
    on(Reminder.Done),
    on(Expiration.Done, Expired),
  ]);
}

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

export const SessionChart = Statechart.make(Session);

The Timer value is the default occurrence

Arming a Timer value directly mounts its default occurrence:

arm(Deadline, () => ({ milliseconds: 1400 }));
on(Deadline.Done, Expired);

The default occurrence uses the Timer class tag as its identity. That is the compact spelling when one chart needs only one mounted Deadline.

one creates a fixed named site

When the same Timer class appears more than once, derive explicit sites before assembling the chart:

const Reminder = Deadline.as("Reminder");
const Expiration = Deadline.as("Expiration");

Each value is an authored occurrence site. Its name is chart-wide identity, not runtime data, so it must be stable and unique and cannot contain the dot reserved for qualified paths.

Sites reuse the source contract

Reminder and Expiration are not new Timer classes. Both retain Deadline's relative kind and positive-millisecond input Schema:

arm(Reminder, () => ({ milliseconds: 800 }));
arm(Expiration, () => ({ milliseconds: 1900 }));

The two producers construct different values for different mounted lifetimes while the source contract remains canonical.

Each occurrence owns Done

Named sites derive occurrence-scoped completion triggers:

on(Reminder.Done);
on(Expiration.Done, Expired);

The first deadline is deliberately targetless. When it fires, Reminder becomes spent while Expiration remains armed and Waiting remains active. The later event can only come from Expiration, and that edge enters Expired.

One class has one binding

toLayer still binds the source tag exactly once:

timers: {
  Deadline: ({ input }) =>
    Effect.succeed(Duration.millis(input.milliseconds)),
}

The engine invokes that binding once per mounted occurrence. Its occurrence address and decoded input distinguish the two calls without creating a second implementation authority.

Lifetimes remain independent

Spending Reminder does not spend, restart, or cancel Expiration. Their incarnations share an owner state but not a slot.

If Waiting exits before either deadline, both still-live occurrences are canceled with their owner. Re-entering Waiting creates one fresh incarnation at each named site.

Fixed sites are topology

Use Timer.as when the set of occurrences is known while authoring the chart: reminder and expiration, soft and hard deadline, primary and backup wake-up.

Dynamic keyed membership is a different modeling problem. It belongs to Timer families rather than manufacturing occurrence names from runtime strings.

Test occurrence identity

  1. Begin the second frame and watch Reminder become spent while Expiration remains armed.
  2. Swap the two duration inputs and verify that completion behavior follows the named site, not declaration order.
  3. Try giving both sites the same name and read the assembly refusal as an identity collision.