Guide

Mutually Recursive Groups

Declare every member early, author exact cross-links, and close one canonical recursive group atomically.

Mutual recursion is not a pair of charts patched together after construction. It is one finite definition boundary that names every member before any member is implemented, then admits the complete cycle atomically.

Declare the complete group before the roots

Statechart.contract begins with one explicit recursive policy and a record of member contracts. The record keys are the chart names. Each value declares that member's input, output, modeled error, events, emits, Resource slots, and directional Resource requirements.

const mutual = Statechart.contract(
  {
    recursive: { maximumDepth: 2 },
    members: {
      Executor: {
        input: MutualInput,
        output: Instance.Ancestry,
        error: Schema.Never,
      },
      Planner: {
        input: MutualInput,
        output: Instance.Ancestry,
        error: Schema.Never,
      },
    },
  },
  ({ Executor: ExecutorSelf, Planner: PlannerSelf }) => {
    // Implement and close both members here.
  },
);

This contract is early on purpose. Planner can refer to ExecutorSelf, and Executor can refer to PlannerSelf, even though neither root class existed when the contract was declared. There is no mutable placeholder and no period in which a partially wired group is a usable Statechart.

Early contracts + cross-links + atomic closure

Close the cycle as one definition.

Build a Planner and Executor that may create one another without weakening either chart's contract or introducing a mutable assembly phase.

1 · Declare the group

Both contracts exist before either implementation does.

The member keys name the complete group. Each contract fixes its input, output, modeled error, events, Resource requirements, and the one maximum depth shared by the cycle.

Recursive group · maximumDepth 21 of 3
Contract members2 exact keys
Definition cyclenot authored yet
Closurepending
ContractGroupone atomic boundary
member contractPlannerinput MutualInput · output AncestryPlannerSelf
member contractExecutorinput MutualInput · output AncestryExecutorSelf
members: { Executor, Planner }
Names and Schemas precede roots.

2 · Author through early handles

Each member can target the other before either root is closed.

The callback receives exact unresolved Self handles. Planner authors an Executor child site; Executor authors a Planner child site. The cycle is explicit without mutable declarations or late patching.

Recursive group · maximumDepth 22 of 3
Contract members2 exact keys
Definition cyclePlanner ↔ Executor
Closurepending
ContractGroupone atomic boundary
member contractPlannerinput MutualInput · output AncestryPlannerSelf
member contractExecutorinput MutualInput · output AncestryExecutorSelf
Planner → Executor → Planner
Cross-links target typed early handles.

3 · Close atomically

The exact record closes one canonical recursive group.

Each root closes through its own Self.make call and returns under the matching contract key. Motive admits both members together or refuses the whole authoring operation.

Recursive group · maximumDepth 23 of 3
Contract members2 exact keys
Definition cyclePlanner ↔ Executor
Closureatomic · admitted
recursive-group-…one canonical identity
member contractPlannerinput MutualInput · output AncestryPlannerSelf.make(Planner)
member contractExecutorinput MutualInput · output AncestryExecutorSelf.make(Executor)
return { Executor, Planner }
Both members admitted under the same group identity.

Every callback property is an unresolved child target with the declared member's exact contract. Use its ordinary .as, .batch, or .each authoring surface wherever another member may create that chart:

const PlannerChildren = ExecutorSelf.each("PlannerExecutor");
const ExecutorChildren = PlannerSelf.each("ExecutorPlanner");

The names describe the owning site; the handle determines the target chart. PlannerChildren therefore belongs to the Planner implementation and creates Executor instances. The reverse site belongs to Executor and creates Planner instances.

Those child sites still use ordinary child grammar—input Queries, outcome transitions, sends, retries, and keyed membership do not acquire a special mutual-recursion variant. The early handle only solves definition ordering while preserving the target's real type.

Close every member through its own Self

After authoring the roots, close each one with the matching handle and return exactly the contract record:

return {
  Executor: ExecutorSelf.make(Executor, {
    init: ({ input }) => [new ExecutorRunning(input)],
  }),
  Planner: PlannerSelf.make(Planner, {
    init: ({ input }) => [new PlannerRunning(input)],
  }),
};

The keys, returned chart names, and closing handles must agree. A member cannot be omitted, renamed, substituted with another member's closure, or returned alongside an undeclared extra. Each member must close exactly once through its own Self.make(...).

Share group identity, not instance identity

Successful closure gives both assembled charts the same canonical recursive-group declaration: the group id, sorted member names, and maximum depth. Contract order does not affect that identity. Changing any member changes the group definition and therefore invalidates the group's identity for every member.

The charts do not become one runtime instance. Register mutual.Executor and mutual.Planner as ordinary charts. A run such as Planner → Executor → Planner produces three separately identified instances, three Snapshots, and three mailboxes. Their ancestry frames carry the same recursive group id, which is why group depth can count only edges that belong to this cycle.

Refuse partial closure atomically

The authoring boundary rejects an empty group, a callback that does not return an object, an inexact set of keys, or a member not returned from its own closure. The ordinary contract-closure checks also prove that each closure matches its declared input, output, error, alphabet, and Resource requirements.

Statechart.contract(
  {
    recursive: { maximumDepth: 1 },
    members: {
      A: { output: Schema.Never, error: Schema.Never },
      B: { output: Schema.Never, error: Schema.Never },
    },
  },
  () => ({}),
);
// ContractGroupBuildMustReturnExactMembers

Nothing is admitted from this operation. There is no valid A, unfinished B, mutable group registry, or runtime cleanup problem to repair.

Test the definition boundary and the running cycle

  1. Assert every returned chart carries the same recursive group id, members, and maximum depth.
  2. Build the same contract with a different object-key order and verify the group id is unchanged.
  3. Run one indirect cycle and verify the ancestry chart names and group ids at every frame.
  4. Omit or swap a returned member and verify authoring fails before registration or execution.
  5. Change one member contract and verify the whole group's canonical identity changes.

Next, recursive Resource propagation will make authority across these child edges as explicit as their identity. Until then, Resource attenuation defines the monotone delegation law every recursive edge must preserve.