The block-level flow graph

Copy Markdown View Source

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 a core.subchart or a core.map makes to another document; check/2 and consumers_broken/2 walk 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/2 draws 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.

  1. 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.
  2. A branch edge. core.branch enters an atomic __pick state holding one eventless transition per arm, guarded by the arm's condition, and a last unguarded one for otherwise (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.
  3. An interrupt edge. A handler in a group's interrupts slot 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 __run or its __history (Emit.interruptible/2). The raise and the transition together lift to one edge from the handler to where the group goes.
  4. 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.group it targets the group's __run <parallel>, so the body starts again from its first step. On core.resumable_group it targets a <history> in the body region, of the type the group's history config names: shallow re-enters at the top-level step the group was last in, deep at 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 abandon

The 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.deadline

Four 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_PVER has no timeout, and the compiled chart holds only its received final, so its exit edge carries received alone.
  • 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_PGRP fires for all three.
  • blk_PDLN schedules registration.deadline and blk_PEXP listens 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 &gt; 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_lost

What it shows:

  • Convergence. The fines arm and the empty otherwise arm both end at blk_LCHK's exit, and one edge leaves the branch for blk_LOUT.
  • Any outcome. blk_LRET finishes as received or timed_out, and both leave by its one exit edge. An overdue book is not routed anywhere different in this document: blk_LEND follows either way. A view that drew timed_out as its own path would show a behaviour this document does not have.
  • The resume loop. blk_LREN loops back into blk_LLEN's body at its shallow history. Here the body has one top-level step, so the loop lands on blk_LRET in 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_LLEN by the same exit as a returned one, so loan.closed is 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.

AnchorFileSHA
StatifierBlocks.Graph, check/2, consumers_broken/2lib/statifier_blocks/graph.ex01af1f5
StatifierBlocks.Connectors.edges/2lib/statifier_blocks/connectors.ex01af1f5
StatifierBlocks.Provenance.owner_of_state/2lib/statifier_blocks/provenance.ex01af1f5
StatifierBlocks.Core.Emit.chain/2, interruptible/2, interrupt_events/1, final/1lib/statifier_blocks/core/emit.ex01af1f5
StatifierBlocks.Compiler.Context, the child_summary typelib/statifier_blocks/compiler/context.ex01af1f5
StatifierBlocks.Compiler.StateId.done_event/1, outcome_event/2, undone_event/1lib/statifier_blocks/compiler/state_id.ex01af1f5
StatifierBlocks.Compiler.Interruptslib/statifier_blocks/compiler/interrupts.ex01af1f5
StatifierBlocks.Compiler.Cancelslib/statifier_blocks/compiler/cancels.ex01af1f5
StatifierBlocks.Core.Branch.emit/2lib/statifier_blocks/core/branch.ex01af1f5
StatifierBlocks.Core.Await.outcomes/1, emit/2lib/statifier_blocks/core/await.ex01af1f5
StatifierBlocks.Core.Invoke.emit/2lib/statifier_blocks/core/invoke.ex01af1f5
StatifierBlocks.Core.OnEvent, "The outcome values", finish_as, "Candidates for event"lib/statifier_blocks/core/on_event.ex01af1f5
StatifierBlocks.Core.ResumableGroup.emit/2lib/statifier_blocks/core/resumable_group.ex01af1f5
The patron registration fixturetest/fixtures/documents/patron_registration.json01af1f5

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.