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.
PlannerSelfExecutorSelf2 · 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.
PlannerSelfExecutorSelf3 · 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.
PlannerSelf.make(Planner)ExecutorSelf.make(Executor)Author cross-links through the exact member handles
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
- Assert every returned chart carries the same recursive group id, members, and maximum depth.
- Build the same contract with a different object-key order and verify the group id is unchanged.
- Run one indirect cycle and verify the ancestry chart names and group ids at every frame.
- Omit or swap a returned member and verify authoring fails before registration or execution.
- 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.