Every recursive model needs a base case. Every recursive runtime also needs a hard boundary for the case where the model gets that decision wrong. Those are different responsibilities.
Make the base case part of the model
Use the recursive reference's depth Query to decide whether another node belongs in the domain:
const descendants = Query.gen(function* () {
const depth = yield* Self.depth;
return depth < 3 ? [`depth-${depth + 1}`] : [];
});
At depth three, this Query returns no requested children. The family settles through its ordinary empty or completed path. No runtime failure is involved because the model did not ask for an impossible birth.
recursive: { maximumDepth: 3 } remains on the contract as a safety fence. It is not an alternate way
to express that base case, and the engine does not treat a crossed fence as an empty child family.
Query + base case + hard refusal
A base case ends the model; a fence refuses the impossible birth.
Follow one WorkNode group from its depth-zero root through normal completion, then request the one child the contract forbids.
1 · Read group-local depth
Recursive depth starts at zero for the group's first instance.
`Self.depth` counts consecutive ancestry edges inside this recursive group. An unrelated parent does not consume the group's budget, and absolute ownership depth remains a different query.
- 0rootexact instance
- 1depth-1exact instance
- 2depth-2exact instance
- 3depth-3exact instance
2 · Model the base case
The ordinary path completes before the safety fence.
Depths zero through two request one child. Depth three returns its ancestry instead. Four exact instances settle normally, and `maximumDepth` never participates in successful domain behavior.
- 0rootexact instance
- 1depth-1exact instance
- 2depth-2exact instance
- 3depth-3base case
3 · Prove the failure direction
A forbidden birth is refused, never silently omitted.
If depth three requests another child, the engine produces `RecursiveDepthExceeded` before allocating child identity or durable Birth. The exact child site receives its ordinary Defect outcome with incident evidence.
- 0rootexact instance
- 1depth-1exact instance
- 2depth-2exact instance
- 3depth-3base case
- 4No child allocatedRecursiveDepthExceeded
Distinguish group depth from ancestry depth
Self.depth is derived from engine-owned ancestry for one exact recursive group. The group's first
instance is depth zero. Consecutive child edges whose ancestry frames carry the same recursive group
id increment the value. A nonrecursive parent above that first instance does not consume the
group's depth budget.
Query.ancestryDepth answers a different question: how far is this instance from the ownership
root across every child edge? In a chart embedded below an ordinary parent, ancestry depth may be
greater than recursive depth. Use the former for ownership location and the latter for a recursive
base case.
Let query strictness carry the membership proof
Inside a member of the recursive group, yielding Self.depth is a strict read: the chart topology
proves group membership. The result is a number, not an optional guess reconstructed from repeated
chart names.
Reusable logic that may run outside the group can yield Query.option(Self.depth) instead. It
returns Option.none() when the current ancestry does not end in that group. A strict read without
membership fails with RecursiveDepthRequiresGroupMembership; it never falls back to zero.
This is the same rule as other strict and optional Queries: absence belongs in the type until the placement topology proves presence.
Refuse the birth before identity exists
When a parent at depth three requests a same-group child, the requested depth is four. The engine compares that value with the recursive contract before minting a generation, Ref, child InstanceId, durable Birth, mailbox, runner, or partial parent plan.
The refusal produces StatechartError.RecursiveDepthExceeded with the requested depth, maximum
depth, and parent InstanceId. It is recorded as an incident and delivered through the exact child
site's ordinary Defect channel. A modeled handler may supervise that failure locally; an escaped
defect follows the normal incident and Park rules.
Silent truncation would make “no children” indistinguishable from “the engine refused a child the model required.” Motive preserves that distinction as durable evidence.
Bound width and authority elsewhere
A lineage fence limits consecutive depth. It does not limit an each family's number of siblings,
concurrent Activities, model calls, tokens, open files, or external capacity. Use family membership
and weighted Resource capacity for
width and scarce work.
Depth also grants no external authority. Recursive child edges inherit or bind only the Resource requirements their declared boundary permits; attenuation may narrow that authority at every descent.
Test the model and the fence separately
- Prove the modeled base case completes without touching
maximumDepth. - Prove
Self.depthis zero at the group's first instance even beneath an unrelated parent. - Prove
Query.ancestryDepthstill reports the complete ownership path. - Request one forbidden child and verify no generation, Ref, InstanceId, Birth, or mailbox exists.
- Verify the exact child site receives
RecursiveDepthExceededand the incident retains its full Cause and parent coordinate. - Bound breadth, work, and Resource authority with their own mechanisms.
Next, Child Lifecycle & Restart follows one exact recursive child across runner loss and checkpoint recovery.