This note defines one reading of a compiled chart: the block-level flow
graph, a graph with one node per block and one edge per way control can
pass from one block to another, lifted from the transitions the compiler
emits. It says what an edge is, how interrupts and resume loops appear, how
convergence after a branch is drawn, and what done.state firing on any
outcome means for an edge.
It is a note, not a record. It decides nothing the compiler does not already
do, and nothing in this package computes the graph: it is the vocabulary a
chart-view proposal cites for what its edges mean. Every claim about the
compiler's output below was read at commit 01af1f5, and the cites table at
the end carries that SHA for each function named.
What it is not
Three things in and around this package are also drawn as nodes and edges. The block-level flow graph is none of them.
- Not
StatifierBlocks.Graph. That module is the publish-time check between a parent document and the child documents it names. Its nodes are documents and its edges are the references acore.subchartor acore.mapmakes to another document;check/2andconsumers_broken/2walk it one pair at a time. The block-level flow graph never leaves one document: its nodes are that document's blocks. - Not the editor's connector layer.
StatifierBlocks.Connectors.edges/2draws the edges that adjacency and nesting in the view-model tree imply, from measured rectangles. It reads no compiled chart. The block-level flow graph is read from the chart, so it shows what the compiled transitions do rather than what the document's layout suggests. - Not a state-index renderer. Another package ships a renderer that
draws a chart's states indexed by state id. Drawn that way, one block is
several nodes: the compiler gives most blocks generated role states
(
__pick,__waiting,__armed,__run,__body,__body_done,__history) and one<final>per outcome (__o_<name>). The block-level flow graph folds every one of those into the block that produced it, so an author sees their own blocks and the paths between them, not the scaffolding the compiler built to realise them.
Nodes: one per block
Every state id the compiler emits belongs to exactly one block. The
provenance map says which: StatifierBlocks.Provenance.owner_of_state/2
answers a state id with its owning block id and its role.
Lifting a transition finds the block at each end. The target end is the
block that owns the target state. The source end is the block the
transition leaves: for a transition on a block's own done.state or
done.outcome event, the block that event names
(StatifierBlocks.Compiler.StateId.undone_event/1 inverts the generated
name, and answers :error for a role state's completion); for any other
transition, the block that owns the state it sits on. A transition whose
two ends are the same block is internal to that block and draws no edge; it
can still label the block (an await's received and timed_out finals are
both inside the await's node). Interrupts take one more step, described
under "Edges" below.
A container block (a sequence, a group, a branch) is a node with an
entry and an exit. Its entry is where its compound state starts: its
first child, or a branch's __pick state. Its exit is its own outcome
final, the <final> whose entry raises done.state.<its state id> for its
parent; a group with handlers adds its body region's __body_done final,
which leads only to that outcome final.
Edges: what the compiler emits between blocks
Four kinds of compiled transition lift to an edge.
- A sequence edge. A parent sequences its children with transitions on
its own state, one per adjacent pair, each on the finished child's
done.state.<state id>and targeting the next child's state; the last child's transition targets the parent's exit (Emit.chain/2). Each is attributed to the child it leaves, which is also the end the lifted edge starts from. - A branch edge.
core.branchenters an atomic__pickstate holding one eventless transition per arm, guarded by the arm's condition, and a last unguarded one forotherwise(Branch.emit/2). Each lifts to an edge from the branch's entry to the arm's first child, labelled with the condition. An empty arm's transition targets the branch's own exit, so it lifts to an edge straight from the branch's entry to its exit. - An interrupt edge. A handler in a group's
interruptsslot sits in a region of the group's__run<parallel>, beside the body. Its transition on its event raises one of two events salted with the group's state id, and the group's own state carries the transition for each: abandon targets the group's exit, resume targets the group's__runor its__history(Emit.interruptible/2). The raise and the transition together lift to one edge from the handler to where the group goes. - An exit edge. The transition that carries a finished child to its
parent's exit (the last case of 1, and a group's
done.state.<body id>transition to its own exit) lifts to an edge from that child to the parent's exit.
The coupling of two blocks by an event name alone is not an edge. A
core.send that schedules an event and a handler that listens for the same
name share a string, not a transition; the compiled chart has nothing that
connects them, and neither does the graph.
done.state fires on any outcome
A block with more than one outcome compiles each one to its own outcome
final. Every one of them raises the same done.state.<state id> when entered,
because SCXML raises that event whenever a compound state's configuration
enters a <final> child. The child summary a parent is handed says so in its
own words: its done_event is the "finished, do not care how" signal
(StatifierBlocks.Compiler.Context, the child_summary type).
Every sequence and exit edge is on that event. So a lifted edge carries
every outcome its source block can finish with. A core.await with a
timeout finishes as received or timed_out (Await.outcomes/1); in a
sequence, both leave by the one edge to the next sibling. Drawing
timed_out as a second exit would draw a fork the chart does not contain.
Labelling the edge with the outcomes it carries stays true to the chart;
splitting it does not.
The same holds for a group's abandon. The group's transition on its abandon
event targets the same exit final that a finished body reaches
(Emit.interruptible/2), so
from outside, a group that completed and a group that was abandoned leave by
the same edge.
Where outcomes do route to different places, the routing is inside the
block that declares them. core.invoke's failure transition targets its
on_error child inside the invoke's own state, and both of its outcome
finals then raise the invoke's one done.state (Invoke.emit/2). The
per-outcome completion events, done.outcome.<state id>.<name>
(StateId.outcome_event/2), are what an interrupt handler can listen for
when it is wired onto a sibling's outcome (StatifierBlocks.Core.OnEvent,
"Candidates for event"); such a handler draws an interrupt edge whose
trigger is that outcome, not a second sequence edge.
Convergence after a branch
Every arm of a core.branch chains its children to the branch's own
exit (Branch.emit/2 passes the one exit final to Emit.chain/2 for each
arm), and an empty arm's pick transition targets that exit directly. The
branch then leaves its parent by the one sequence edge on its own
done.state.
So convergence is drawn at the branch's exit: every arm's last block has an
exit edge into it, and one edge leaves it. There is no separate join node,
and none is needed; the branch's exit is where the chart converges. A
wired undecided slot is one more guarded pick transition and one more arm
into the same exit.
Interrupts and resume loops
A handler is not a step in the body. It is a region beside the body, active
for as long as the group's __run <parallel> is, so its edge starts from
the handler node and can fire from any point in the body; it does not hang
off any one body step.
Where an interrupt edge ends is the handler's outcome config
(StatifierBlocks.Core.OnEvent, "The outcome values"):
- abandon ends at the group's exit, the same exit a finished body reaches.
- resume is a loop back into the group. On
core.groupit targets the group's__run<parallel>, so the body starts again from its first step. Oncore.resumable_groupit targets a<history>in the body region, of the type the group'shistoryconfig names:shallowre-enters at the top-level step the group was last in,deepat the exact position. The history's default transition targets the body's first step. A resume loop into a resumable group therefore ends at "the step it was in", which is not one fixed block; the lifted edge ends at the body, not at a block the chart does not name.
Each group's pair of interrupt events is salted with that group's state id
(Emit.interrupt_events/1), and the raises inside a group's rail are
rewritten to that salt (StatifierBlocks.Compiler.Interrupts). An interrupt
edge therefore always ends at its own group, never at an enclosing one.
A handler with a finish_as name also completes under that name, which an
enclosing declaring composite can route on (StatifierBlocks.Core.OnEvent,
the finish_as section). This note does not work composites through.
Worked example: patron registration
The fixture test/fixtures/documents/patron_registration.json is a sequence
holding one group. The group's body schedules a 24-hour deadline and then
awaits the patron's email verification; two handlers abandon the group when
the registration is abandoned or the deadline arrives.
blk_PROOT core.sequence
body:
blk_PGRP core.group
body:
blk_PDLN core.send event registration.deadline, delay 24h
blk_PVER core.await event email.verified
interrupts:
blk_PABN core.on_event event registration.abandoned, outcome abandon
blk_PEXP core.on_event event registration.deadline, outcome abandonThe transitions that cross a block boundary, as compiled:
<!-- on s_blk_PROOT -->
<transition event="done.state.s_blk_PGRP" target="s_blk_PROOT__o_done" type="internal"/>
<!-- on s_blk_PGRP -->
<transition event="done.state.s_blk_PGRP__body" target="s_blk_PGRP__o_done" type="internal"/>
<transition event="statifier_blocks.interrupt.abandon.s_blk_PGRP" target="s_blk_PGRP__o_done" type="internal"/>
<transition event="statifier_blocks.interrupt.resume.s_blk_PGRP" target="s_blk_PGRP__run" type="internal"/>
<!-- on s_blk_PGRP__body -->
<transition event="done.state.s_blk_PDLN" target="s_blk_PVER" type="internal"/>
<transition event="done.state.s_blk_PVER" target="s_blk_PGRP__body_done" type="internal"/>
<!-- on s_blk_PABN__armed and s_blk_PEXP__armed -->
<transition event="registration.abandoned" target="s_blk_PABN__o_done">
<raise event="statifier_blocks.interrupt.abandon.s_blk_PGRP"/></transition>
<transition event="registration.deadline" target="s_blk_PEXP__o_done">
<raise event="statifier_blocks.interrupt.abandon.s_blk_PGRP"/></transition>Lifted:
blk_PROOT entry -> blk_PGRP
blk_PGRP -> blk_PROOT exit exit, on done.state (the group's one outcome, done)
blk_PGRP entry -> blk_PDLN
blk_PDLN -> blk_PVER sequence, on done.state (done)
blk_PVER -> blk_PGRP exit exit, on done.state (received)
blk_PABN -> blk_PGRP exit interrupt, abandon, on registration.abandoned
blk_PEXP -> blk_PGRP exit interrupt, abandon, on registration.deadlineFour things the example shows:
- The group's resume transition is present in the chart, but no handler raises resume, so no resume loop is drawn. An edge is drawn only where a transition can be taken from a block.
blk_PVERhas no timeout, and the compiled chart holds only itsreceivedfinal, so its exit edge carriesreceivedalone.- Three edges end at the group's exit: a verified patron, an abandoned
registration and an expired one. The group then leaves by one edge,
because
done.state.s_blk_PGRPfires for all three. blk_PDLNschedulesregistration.deadlineandblk_PEXPlistens for it. No transition joins them, so there is no edge between them; the delayed send is cancelled in the body region's<onexit>when the body is left (StatifierBlocks.Compiler.Cancels).
Worked example: a library loan
This document is illustrative. It is written out here and is not a fixture
in this repository; it compiles with the core.* palette at 01af1f5 with
no findings and no warnings. A patron who owes fines is sent a notice and
the loan waits for the fines to be paid; the book is then checked out, and
the loan waits up to 21 days for the return. A renewal resumes the wait; a
report that the book is lost abandons it. Either way, the loan is closed.
{
"schema_version": 1,
"id": "bdoc_01JLIBRARYLOAN",
"revision": 1,
"metadata": {"name": "Library loan"},
"accepts": ["fines.paid", "loan.renewed", "loan.returned", "loan.reported_lost"],
"datamodel": [{"id": "patron", "description": "The borrowing patron."}],
"root": {
"id": "blk_LROOT", "type": "core.sequence", "type_version": 1,
"slots": {"body": [
{"id": "blk_LCHK", "type": "core.branch", "type_version": 1,
"config": {"arms": [{"slot": "arm_owes", "cond": "patron.fines_owed > 0"}]},
"slots": {
"arm_owes": [
{"id": "blk_LFIN", "type": "core.send", "type_version": 1,
"config": {"event": "loan.fines_notice"}},
{"id": "blk_LPAY", "type": "core.await", "type_version": 1,
"config": {"event": "fines.paid"}}
],
"otherwise": [],
"undecided": []
}},
{"id": "blk_LOUT", "type": "core.send", "type_version": 1,
"config": {"event": "loan.checked_out"}},
{"id": "blk_LLEN", "type": "core.resumable_group", "type_version": 1,
"config": {"history": "shallow"},
"slots": {
"body": [
{"id": "blk_LRET", "type": "core.await", "type_version": 1,
"config": {"event": "loan.returned", "timeout": "21d"}}
],
"interrupts": [
{"id": "blk_LREN", "type": "core.on_event", "type_version": 1,
"config": {"event": "loan.renewed", "outcome": "resume"}},
{"id": "blk_LLOS", "type": "core.on_event", "type_version": 1,
"config": {"event": "loan.reported_lost", "outcome": "abandon"}}
]
}},
{"id": "blk_LEND", "type": "core.send", "type_version": 1,
"config": {"event": "loan.closed"}}
]}
}
}The transitions that cross a block boundary, as compiled:
<!-- on s_blk_LROOT -->
<transition event="done.state.s_blk_LCHK" target="s_blk_LOUT" type="internal"/>
<transition event="done.state.s_blk_LOUT" target="s_blk_LLEN" type="internal"/>
<transition event="done.state.s_blk_LLEN" target="s_blk_LEND" type="internal"/>
<transition event="done.state.s_blk_LEND" target="s_blk_LROOT__o_done" type="internal"/>
<!-- on s_blk_LCHK__pick, then on s_blk_LCHK -->
<transition cond="patron.fines_owed > 0" target="s_blk_LFIN"/>
<transition target="s_blk_LCHK__o_done"/>
<transition event="done.state.s_blk_LFIN" target="s_blk_LPAY" type="internal"/>
<transition event="done.state.s_blk_LPAY" target="s_blk_LCHK__o_done" type="internal"/>
<!-- on s_blk_LLEN -->
<transition event="done.state.s_blk_LLEN__body" target="s_blk_LLEN__o_done" type="internal"/>
<transition event="statifier_blocks.interrupt.abandon.s_blk_LLEN" target="s_blk_LLEN__o_done" type="internal"/>
<transition event="statifier_blocks.interrupt.resume.s_blk_LLEN" target="s_blk_LLEN__history" type="internal"/>
<!-- on s_blk_LLEN__body, and its history -->
<transition event="done.state.s_blk_LRET" target="s_blk_LLEN__body_done" type="internal"/>
<history id="s_blk_LLEN__history" type="shallow"><transition target="s_blk_LRET"/></history>
<!-- on s_blk_LREN__armed and s_blk_LLOS__armed -->
<transition event="loan.renewed" target="s_blk_LREN__o_done">
<raise event="statifier_blocks.interrupt.resume.s_blk_LLEN"/></transition>
<transition event="loan.reported_lost" target="s_blk_LLOS__o_done">
<raise event="statifier_blocks.interrupt.abandon.s_blk_LLEN"/></transition>Inside blk_LRET, s_blk_LRET__waiting carries one transition to its
received final and one, on the deadline its entry schedules, to its
timed_out final. Both ends are blk_LRET, so neither is an edge.
Lifted:
blk_LROOT entry -> blk_LCHK
blk_LCHK -> blk_LOUT sequence, on done.state (done)
blk_LOUT -> blk_LLEN sequence, on done.state (done)
blk_LLEN -> blk_LEND sequence, on done.state (done)
blk_LEND -> blk_LROOT exit exit, on done.state (done)
blk_LCHK entry -> blk_LFIN branch, when patron.fines_owed > 0
blk_LCHK entry -> blk_LCHK exit branch, otherwise (empty arm)
blk_LFIN -> blk_LPAY sequence, on done.state (done)
blk_LPAY -> blk_LCHK exit exit, on done.state (received)
blk_LLEN entry -> blk_LRET
blk_LRET -> blk_LLEN exit exit, on done.state (received, timed_out)
blk_LREN -> blk_LLEN body interrupt, resume, on loan.renewed, at shallow history
blk_LLOS -> blk_LLEN exit interrupt, abandon, on loan.reported_lostWhat it shows:
- Convergence. The fines arm and the empty
otherwisearm both end atblk_LCHK's exit, and one edge leaves the branch forblk_LOUT. - Any outcome.
blk_LRETfinishes asreceivedortimed_out, and both leave by its one exit edge. An overdue book is not routed anywhere different in this document:blk_LENDfollows either way. A view that drewtimed_outas its own path would show a behaviour this document does not have. - The resume loop.
blk_LRENloops back intoblk_LLEN's body at its shallow history. Here the body has one top-level step, so the loop lands onblk_LRETin practice; in a longer body it lands on whichever top-level step was active, which is why the edge ends at the body rather than at a block. - Abandon shares the exit. A lost book leaves
blk_LLENby the same exit as a returned one, soloan.closedis sent in both cases.
What this note does not decide
It fixes no view: no node shape, no layout, no colour, and no rule for how
an entry, an exit or a history target is drawn. It says nothing about
core.parallel, core.foreach, core.map, core.subchart or a composite
beyond the general rules above; each of those has emitted shapes of its own
that a proposal covering it works through the same way, against the compiled
chart. It adds nothing to the compiler, and a flow-graph emitter would be a
proposal of its own.
Cites
Every cite was read at 01af1f5.
| Anchor | File | SHA |
|---|---|---|
StatifierBlocks.Graph, check/2, consumers_broken/2 | lib/statifier_blocks/graph.ex | 01af1f5 |
StatifierBlocks.Connectors.edges/2 | lib/statifier_blocks/connectors.ex | 01af1f5 |
StatifierBlocks.Provenance.owner_of_state/2 | lib/statifier_blocks/provenance.ex | 01af1f5 |
StatifierBlocks.Core.Emit.chain/2, interruptible/2, interrupt_events/1, final/1 | lib/statifier_blocks/core/emit.ex | 01af1f5 |
StatifierBlocks.Compiler.Context, the child_summary type | lib/statifier_blocks/compiler/context.ex | 01af1f5 |
StatifierBlocks.Compiler.StateId.done_event/1, outcome_event/2, undone_event/1 | lib/statifier_blocks/compiler/state_id.ex | 01af1f5 |
StatifierBlocks.Compiler.Interrupts | lib/statifier_blocks/compiler/interrupts.ex | 01af1f5 |
StatifierBlocks.Compiler.Cancels | lib/statifier_blocks/compiler/cancels.ex | 01af1f5 |
StatifierBlocks.Core.Branch.emit/2 | lib/statifier_blocks/core/branch.ex | 01af1f5 |
StatifierBlocks.Core.Await.outcomes/1, emit/2 | lib/statifier_blocks/core/await.ex | 01af1f5 |
StatifierBlocks.Core.Invoke.emit/2 | lib/statifier_blocks/core/invoke.ex | 01af1f5 |
StatifierBlocks.Core.OnEvent, "The outcome values", finish_as, "Candidates for event" | lib/statifier_blocks/core/on_event.ex | 01af1f5 |
StatifierBlocks.Core.ResumableGroup.emit/2 | lib/statifier_blocks/core/resumable_group.ex | 01af1f5 |
| The patron registration fixture | test/fixtures/documents/patron_registration.json | 01af1f5 |
Note (2026-09-26): where the graph is computed
This is a dated note on the sentence in the introduction that nothing in
this package computes the graph, not a change to anything the note
defines. ADR-0016, at proposed, decides that StatifierBlocks.Describe
computes it: StatifierBlocks.Describe.outline/3 answers one node per
block and the sequence, branch, interrupt and exit edges of this note
(plus an entry edge from a container's entry to its first child), for
core.sequence, core.group, core.resumable_group and core.branch,
and StatifierBlocks.Describe.render/2 writes them as one line each.
It reads the document's structure and never compiles, so it does not lift
edges from a compiled chart the way this note does by hand, and the
sentence that a flow-graph emitter would be a proposal of its own still
stands. Its edges carry the outcomes a block's type declares rather than
the finals the compiler emits. In the two worked examples above those
differ only at an await with no timeout, blk_PVER and blk_LPAY, where
the lifted listings say received and the describe says received, timed_out (ADR-0016, decision 1).
Until the module ships in a published version, the introduction's sentence stays true of main. Every other claim here is unchanged.
Note (2026-09-27): the describe's timer edge
This is a dated note on the sentence in "Edges: what the compiler emits
between blocks" that the coupling of two blocks by an event name alone is
not an edge, not a change to it. That sentence stays true of the compiled
chart and of the graph this note lifts. StatifierBlocks.Describe, which
reads the document's structure rather than lifting a chart, draws one
describe-level exception (ADR-0016's amendment of 2026-09-27 and ADR-0017
decision 3, both at proposed): a :timer edge from a delayed core.send
to every core.on_event and core.await that names its event, labelled
with the delay. It is not a transition. In the patron registration worked
example it joins blk_PDLN to blk_PEXP; the library loan has no delayed
send and gains none.