Guide

Repeated Placements and Occurrence Identity

Reuse one Component twice while qualifying every state and mounted citizen by its placement path.

Repeated placements are separate authored locations built from the same definition. They share the state tree you wrote, but not state, facets, mounted work, or runtime progress.

The distinction becomes visible as the chart qualifies each reusable local name:

Only the first row is shared. Every row beneath it names a different part of the assembled model.

One definition, two authored locations

Place the same Component twice, then put both placements under a Parallel host:

const Indicator = Component.make(() => {
  class Indicator extends State.Compound<Indicator>()("Indicator") {
    static states = States.make(() => [Waiting, Complete]);
  }

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

  return Indicator;
});

class Left extends Indicator.as<Left>()("Left") {}
class Right extends Indicator.as<Right>()("Right") {}

class Dashboard extends State.Parallel<Dashboard>()("Dashboard") {
  static states = States.make(() => [Left, Right]);
}

Left and Right are ordinary placement classes. Each lowers the captured Indicator tree at a different path; there is no hidden Indicator segment and no shared running singleton behind the two classes. The placement tags also contribute to chart hashing, so renaming a placement changes model identity rather than display copy.

Sibling placement tags must be unique. The same local names remain legal inside different placements because the surrounding path makes them distinct.

Qualification is structural

Inside the definition, Waiting arms one Timer site named Pulse. Placement derives typed references for each authored location:

Left.Waiting;
Right.Waiting;

Left.Waiting.timers.Pulse;
Right.Waiting.timers.Pulse;

The state nodes can be used as exact transition targets and Query subjects. The Timer references can be used by tools such as migration to inspect or dispose the left and right occurrences independently. Authors keep the readable local name Pulse; Component supplies the qualification.

Three names are doing different jobs:

PulseTimer
The Timer definition whose duration is bound by the Simulator or runtime Layer.
Pulse

The local named site authored once beneath Waiting.

Dashboard.Left.Waiting.Pulse
The complete mounted coordinate for one placement's currently armed Timer.

Now follow that identity from one placement to two, then address only one occurrence:

Reuse definitions; qualify every occurrence

Two placements share behavior, never runtime identity.

Repeated placements share one component definition while keeping every active node and mounted citizen distinct. Placement paths make local names reusable without letting one occurrence complete another.

1 · Carry citizens through one placement

A placement qualifies the component's mounted work.

Indicator owns a local Pulse Timer beneath Waiting. Placing the component as Player preserves that authored citizen while qualifying it by the placement path, so its durable identity is no longer merely the local name Pulse.

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

const PulseTimer = Timer.relative("PulseTimer");
const Pulse = PulseTimer.as("Pulse");

const Indicator = Component.make(() => {
  class Indicator extends State.Compound<Indicator>()("Indicator") {
    static states = States.make(() => [Waiting, Complete]);
  }

  class Waiting extends State.Atomic<Waiting>()("Waiting") {
    static transitions = Transitions.make(this, ({ arm, on }) => [
      arm(Pulse),
      on(Pulse.Done, Complete),
    ]);
  }

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

  return Indicator;
});

export class Panel extends State.Compound<Panel>()("Panel") {
  static states = States.make(() => [Player]);
}

class Player extends Indicator.as<Player>()("Player") {}

export const IndicatorChart = Statechart.make(Panel);

2 · Reuse the definition twice

Left and Right share a template, not an occurrence.

Two placements of Indicator are legal in parallel because every state node and mounted citizen is qualified by its placement. Dashboard.Left.Waiting.Pulse and Dashboard.Right.Waiting.Pulse can be armed together without colliding even though their template source is identical.

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

const PulseTimer = Timer.relative("PulseTimer");
const Pulse = PulseTimer.as("Pulse");

const Indicator = Component.make(() => {
  class Indicator extends State.Compound<Indicator>()("Indicator") {
    static states = States.make(() => [Waiting, Complete]);
  }

  class Waiting extends State.Atomic<Waiting>()("Waiting") {
    static transitions = Transitions.make(this, ({ arm, on }) => [
      arm(Pulse),
      on(Pulse.Done, Complete),
    ]);
  }

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

  return Indicator;
});

export class Dashboard extends State.Parallel<Dashboard>()("Dashboard") {
  static states = States.make(() => [Left, Right]);
}

class Left extends Indicator.as<Left>()("Left") {}
class Right extends Indicator.as<Right>()("Right") {}

export const DashboardChart = Statechart.make(Dashboard);

3 · Address one exact citizen

Firing Left leaves the sibling placement untouched.

Simulator.TimerCitizen names the complete mounted coordinate, not just Pulse. Firing Dashboard.Left.Waiting.Pulse moves only Left to Complete; Right remains Waiting with its own independently armed incarnation.

import {
  Component,
  Simulator,
  State,
  Statechart,
  States,
  Timer,
  Transitions,
} from "@motive/motive";
import { Effect, Result } from "effect";

const PulseTimer = Timer.relative("PulseTimer");
const Pulse = PulseTimer.as("Pulse");

const Indicator = Component.make(() => {
  class Indicator extends State.Compound<Indicator>()("Indicator") {
    static states = States.make(() => [Waiting, Complete]);
  }

  class Waiting extends State.Atomic<Waiting>()("Waiting") {
    static transitions = Transitions.make(this, ({ arm, on }) => [
      arm(Pulse),
      on(Pulse.Done, Complete),
    ]);
  }

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

  return Indicator;
});

export class Dashboard extends State.Parallel<Dashboard>()("Dashboard") {
  static states = States.make(() => [Left, Right]);
}

export class Left extends Indicator.as<Left>()("Left") {}
export class Right extends Indicator.as<Right>()("Right") {}

export const DashboardChart = Statechart.make(Dashboard);

export const initial = Effect.runSync(
  Simulator.make(DashboardChart, { timers: { PulseTimer: "5 seconds" } }),
);

export const leftDone = Result.getOrThrow(
  initial.fireTimer(Simulator.TimerCitizen.make("Dashboard.Left.Waiting.Pulse")),
);

Equal deadlines are still separate lifecycles

The second frame advances virtual time by five seconds. Both placements reach Complete because both independent Timer occurrences are due—not because one completion is broadcast across the shared definition. The Simulator delivers a completion to each exact coordinate.

The final frame makes the separation observable. Firing Dashboard.Left.Waiting.Pulse moves only Left to Complete; Right remains in Waiting with Dashboard.Right.Waiting.Pulse still armed.

Exact addresses do not guess

Simulator.TimerCitizen.make("Dashboard.Left.Waiting.Pulse") names one currently armed Timer occurrence. A bare Pulse or nonexistent placement path does not fall back to a local-name match. The Simulator records the attempted delivery as Stale with reason NotArmed, leaves the configuration unchanged, and preserves both real occurrences.

That failure direction matters: stale external facts may become harmless evidence, but they may never settle whichever repeated placement happens to look compatible.

Test repeated placement as an identity boundary

  1. Assemble one placement and verify the definition name does not add a hidden path segment.
  2. Assemble two placements and verify their state paths, Timer occurrences, and chart hash are placement-qualified.
  3. Advance both equal deadlines and verify two independent completions occur.
  4. Fire only the left coordinate and verify the right Timer remains armed.
  5. Fire a bare or nonexistent coordinate and verify a Stale / NotArmed trace with no state change.
  6. Select Left.Waiting.timers.Pulse and Right.Waiting.timers.Pulse independently through a migration or inspection boundary.

Next, Recursive Composition carries these same qualified identities through child-chart boundaries instead of stopping at one assembled chart.