The scope-shaped cancel: a delayed send armed by a child block is
cancelled in the <onexit> of the state that armed it (ADR-0004's
2026-08-29 amendment, "a delayed send's cancel, emitted in the arming
state's <onexit>").
Which state is "the state that armed the send"
The nearest enclosing scope state: the innermost <state> in the
parent's own emission that contains the child, whichever state that
turns out to be.
For most block types that is the parent block's own state, because
ADR-0004 decision 2 gives one block exactly one state and a child's
emission is spliced directly inside it: a core.send in a
core.sequence body is cancelled by the sequence, and one nested a
sequence deep inside a core.group is cancelled by the sequence rather
than the group, because the sequence is nearer.
A block type whose emission puts its children inside a region is the
case that makes "nearest" do work of its own, and there are two of them
in this vocabulary. An interruptible core.group puts its body in a
region, and abandoning the group takes an internal transition to the
group's own final - which exits the body region without exiting the
group, so a cancel on the group's <onexit> never fired for it. That one
is a fix as much as a move. core.parallel's lanes are
regions of its <parallel>, and a lane is a scope: leaving the lane -
which under complete: first is exactly what the losing lanes do - has
to reach the sends that lane armed. So a lane child's cancel is emitted
in the region's <onexit>, not the wrapper's, and that is ADR-0004's
complete: first amendment, P3, applied to both completion rules at
once. A delayed send in a lane of an all parallel is cancelled when its
region is exited for exactly the same reason.
Only a <state> claims. A <parallel> between the child and its nearest
state does not, which is what keeps an interrupt rail's cancel on the
enclosing group's state where the record puts it ("the enclosing group
for a rail"): a rail child is a region of the group's <parallel>, and
the nearest state above it is the group's own. The one exception is a
block whose emission is itself rooted at a <parallel> - not something
this package emits, since decision 2 makes every block's emission a
<state> - which is still offered the cancels rather than dropping them
on the floor, in the same spirit as decision 1's "a finding, never a
raise".
A send block that is the document's root has no enclosing scope and therefore no cancel. Nothing above it is a state this package emits.
How a scope learns what its children armed
Not through the child's SCXML, which ADR-0004 decision 4 keeps out of a
parent's reach, and not through a new emit/2 callback either. Through
the send id, which is a state id in disguise: ADR-0002's amendment
of the same date fixes it as <the send block's state id>__send, and
StatifierBlocks.Compiler.StateId.unstate_id/1 inverts that back to
{block id, "send"} exactly. So this pass reads each direct child's
compiled emission, keeps every <send> whose id inverts to that
child's own block id under the reserved role "send", and
emits one <cancel sendid="..."> per hit in the scope's <onexit>.
Three properties follow from reading the id rather than the emitter:
- A grandchild's send is not this scope's. Its id names the grandchild, so it fails the direct-child test here and was already cancelled by the nearer scope on its own pass. No send is cancelled twice and none is missed.
core.wait's timer is one of these sends. It mints its own delayed<send>under this same reserved role rather than a role of its own, so a wait left before its delay elapses is cancelled by its scope exactly as acore.sendis. The wait's own state bounds only what the interpreter holds: a delayed send a durable host has already scheduled outlives that state, and would fire into a run that no longer wants it.- A host block type opts in by minting the same role. Nothing here
names
StatifierBlocks.Core.Send, so a host type that arms a cancellable delayed send gets the scope cancel by usingrole_id/2the waycore.senddoes.
Only a send carrying a delay is cancelled. An undelayed <send> is on
the external queue before the enclosing state can be exited, so a
<cancel> for it would be bytes that can never match a pending timer.
Which scope a child belongs to, and how it is found
The parent's emission carries a {:child, block_id} placeholder wherever
a child's subtree will be spliced, so the parent has already said where
each child sits. The walk below reads that: one post-order pass over the
emission, in which a placeholder reports its own block id upward and each
<state> claims the armed ids reported from inside it before the rest
travel further up. Nothing here reads a slot name, and nothing names a
block type - a host type that arranges its children into regions of its
own gets per-region cancels by placing its placeholders, exactly as
core.parallel does.
Determinism
Children are visited in slot-declaration order and then document order -
the order StatifierBlocks.Compiler already hands them over - and within
one scope the cancels are emitted in that order, so ADR-0004 decision 6
holds. The <onexit> is prepended to its scope state's children, ahead
of the transitions the type wrote, which is one fixed position rather
than a position that depends on what the type emitted. A block type that
wrote an <onexit> of its own keeps it; SCXML runs both.
The pass runs on the parent's own emission before
StatifierBlocks.Compiler.Attribution stamps it, so the <onexit> and
its cancels are owned by the scope block and carry the scope state's
role, which is the block an author would recognise: the cancel is a
consequence of where the send sits in that scope, not of the send block
itself. That stays true when the scope is a region: the region is a state
the same block emitted.
Summary
Functions
Adds the scope <onexit> cancels to emission, the emission of the
block that compiled_children are the direct children of.
The role a cancellable armed send's id carries: "send".
Functions
@spec arm(StatifierBlocks.Emission.t(), [ {StatifierBlocks.Block.id(), StatifierBlocks.Emission.t()} ]) :: StatifierBlocks.Emission.t()
Adds the scope <onexit> cancels to emission, the emission of the
block that compiled_children are the direct children of.
compiled_children is the {block id, emission} list the compiler
already holds after emitting the children. Returns emission unchanged
when no direct child armed a delayed send - which is every scope in
every document that contains no core.send, so no existing chart moves
a byte.
@spec armed_role() :: String.t()
The role a cancellable armed send's id carries: "send".
StatifierBlocks.Core.Send mints its send id with
StatifierBlocks.Compiler.Context.role_id(context, armed_role()), and
this module reads it back, so the two halves of the convention name one
string in one place.