core.subchart: a step that runs another chart and waits for it to
finish, routing on the outcome that chart finished with (ADR-0004's
2026-08-29 amendment, C1 through C3).
This type names a chart and an invoke type; it never runs either.
A subchart is not a new execution mechanism - it is a particular invoke
whose handler happens to start a child session, so it stands on the same
two-registry seam core.invoke does (ADR-0002 decision 2,
statifier-ex ADR-0051).
The child is a reference, never a body
A document is a tree and a chart is a build product of one, so a
subchart whose body lived inside the parent would be a second copy of a
document that already exists on its own, with its own id, its own
revision and its own runs. The child is therefore named by the
chart config field and this type declares no body slot at all.
How a child's outcome reaches the parent
Raised events are internal to the session that raises them, so
done.outcome.<state id>.<outcome> - the event ADR-0004's outcome
amendment gives a block - does not cross an <invoke>. What a parent
observes is the completion event and the data the child chose to send
with it (SCXML 3.7 and 5.5). C1 fixes the child half: a document
compiled for use as a child (StatifierBlocks.Compiler's
:child_use option) emits one top-level <final> per root-block
outcome, carrying <donedata><param name="outcome" expr="'<outcome>'"/>.
C2 fixes this half: the block's state routes done.invoke on
_event.data.outcome, one conditioned transition per declared outcome
and an unconditioned one last as the default path.
The condition is ===, not ==
Statifier's == against an absent _event.data.outcome is non-boolean
and raises a spurious error.execution beside the default arm's routing
(statifier-ex st-iz97); === is clean, and an explicit nil donedata
reads as null rather than as undefined. So the conditioned transitions
are written _event.data.outcome === '<outcome>'. The record fixes the
routing, not the operator; this is the campaign's recorded ruling on
which operator expresses it.
Which outcomes, and where the author says so
A block type cannot read the document it references - emit/2 is a pure
function of its block and its context, and the context deliberately
carries no palette and no other document. So the outcomes the referenced
chart declares are declared here, in the outcomes config field, one
name per line, in the order they should be routed. A subchart that
declares none has the one outcome an ordinary block has, done.
The failure outcome error is appended to whatever the author declared,
unless they declared it themselves - a child chart may well finish with
an outcome it calls error, and then the two are one outcome with one
final and one slot rather than two spellings of the same thing.
What the host knows that this type cannot (sb-r4w7)
The paragraph above is a statement about what a compile of one
document can see. A host sees more: it holds every stored document,
so it knows which of them it compiles with :child_use and what finals
each of those emits. Two things follow, and both are the editor's rather
than this type's.
- The editor offers those finals as candidates on the
outcomesfield, keyed on the document id inchart, from the host'schart_outcomesassign. It is a<datalist>on a field that is still a:string: a free-typed name validates exactly as it did. StatifierBlocks.ViewModel.outcome_findings/3reports a disagreement between what the author declared here and what the host says that chart finishes with, anchored on this key.
The comparison is against child_outcomes/1 - the author's own list -
and not outcome_names/1, because the appended failure outcome is
ADR-0068's event rather than a <final> the child reports, and a child
chart is not expected to have one. A ref the host said nothing about
produces nothing: unknown is not disagreement, which is ADR-0005
amendment 11f's posture for the datamodel repeated here for the same
reason.
None of it constrains. The disagreement is a :warning, not an error:
the document compiles either way, and what a mismatch actually costs is
a conditioned transition that can never match - a routing arm that is
dead rather than wrong. validate_config/1 is untouched, because this
type still cannot read the chart it names.
Every outcome gets a slot, on_error included
An outcome path is a slot, never a port (D13, ADR-0002's amendment
A2): every edge in a document is a parent/slot/child relationship, which
is the invariant the editor's rendered connectors rest on. So each
declared outcome gets one zero_or_one slot, on_<outcome>, holding
what runs when the child finishes that way, and on_error is exactly
core.invoke's slot with exactly core.invoke's :failure style - the
same declaration for the same concept, not a second wording of it.
A slot left empty is not a missing path: the routing transition simply
targets that outcome's <final> directly, and the block finishes there.
What it compiles to
<state id="s_blk_ELIG" initial="s_blk_ELIG__running">
<state id="s_blk_ELIG__running">
<invoke id="blk_ELIG" src="bdoc_CHILD" type="statifier_blocks:subchart"/>
<transition cond="_event.data.outcome === 'done'"
event="done.invoke" target="s_blk_ELIG__o_done"/>
<transition cond="_event.data.outcome === 'abandoned'"
event="done.invoke" target="s_blk_ELIG__o_abandoned"/>
<transition event="done.invoke" target="s_blk_ELIG__o_done"/>
<transition event="error.communication.invoke" target="s_blk_PARK"/>
</state>
...
</state><invoke> carries an explicit id (C3) so _event.invokeid is static
and a parent running subcharts in parallel can tell its concurrent
children apart by a value it knows at compile time. The id is the
block's own id, which ADR-0001 already guarantees is document-unique
and never reused, so nothing new has to be minted or kept unique.
Both done.invoke transitions and the error.communication.invoke one
match by SCXML's descriptor prefix rule and name no invocation, which is
safe for core.invoke's reason: they sit on the inner state, active only
while this block's own call is outstanding.
Where the outcome is written
assign_to names a location in the host's datamodel, so it is declared
a {:path, %{}} field - ADR-0002 decision 7's eighth field type, added
by its 2026-09-05 amendment, which names this field as the one core
field that held a path and declared nothing about it. Two things follow
and a third deliberately does not:
- the editor offers the declared datamodel paths as candidates, drawn
from
StatifierBlocks.Datamodel.candidates/3; and - a value the datamodel does not declare gets ADR-0005 clause 11e's
:infoadvisory anchored on theassign_tokey, which is a remark and not a refusal.
The field accepts a datamodel path (ADR-0011 decision 13): any
non-empty string with no whitespace in it, which is exactly what
core.assign accepts for the path it writes, read out of one
StatifierBlocks.Core.Config.datamodel_path?/1. validate_config/1 and
emit/2 are widened together, because the emission has to answer for a
config the validation would have rejected.
It is a widening and nothing else: a bare identifier is a one-segment
path, so every document written before this type existed keeps
validating and keeps compiling to the same bytes, and emit/2 still
writes the author's string verbatim into the <assign>'s location.
What it settles is that the field no longer refuses the dotted paths its
own candidate list offers - a control that offers what its validation
refuses is a defect either way round. The identical refusal on an
<assign> location elsewhere in the vocabulary is untouched: that
decision was ruled about this field, and widening the others on the
strength of one field's argument is a sweep it did not make.
What src names, and what it does not
The emitted src is the document id the author typed into chart,
and nothing else (ADR-0004's subchart-src amendment). It is not
statifier-ex ADR-0052 chart identity: that identity is a hash of emitted
bytes, so it moves every time the child is republished and cannot be
known when the parent is authored. A document id is the stable
authoring-time reference, and the host's handler registered under
statifier_blocks:subchart (st-ADR-0051) resolves it to whichever
chart the host currently publishes for that document. Pinning a
particular child revision at publish time is a host provenance
concern, carried in run metadata; the compiler does not do it.
One thing a compile of one document can decide about that id, and it
does: a subchart may not name the document it sits in. See
StatifierBlocks.Compiler.SelfReference, which also says why a cycle
through two or more documents is the host resolver's to refuse.
Summary
Functions
The outcomes the referenced chart declares, in the order the author
wrote them, defaulting to ["done"].
A compound state that runs the child chart in an inner state and
finishes at the <final> of whichever outcome the child reported.
The invoke type every core.subchart emits: the host-registered
child-chart invoke type.
core.invoke's io/1 exactly: a step with several outcomes, so
produces is :unknown rather than a join over the subtrees that reach
each one - the lattice ADR-0003 decision 4 refuses to build - and no
consumes, because a subchart reads its inputs through params.
The outcomes this block declares: the ones the author listed, then
error unless they listed it.
The outcomes field's rows: one outcome name per line, blank lines
ignored, in the order the author wrote them.
The outcomes the referenced chart declares, as the author listed them,
with error appended unless they listed it themselves (ADR-0002
amendment A1, ADR-0004's outcome amendment 2a).
One zero_or_one slot per declared outcome, named on_<outcome>, in
declaration order with on_error last.
Functions
@spec child_outcomes(StatifierBlocks.Block.config()) :: [String.t()]
The outcomes the referenced chart declares, in the order the author
wrote them, defaulting to ["done"].
Total: a field this type's validate_config/1 rejects reads as the
default rather than raising, so every callback stays answerable for a
config the compiler will refuse anyway.
A compound state that runs the child chart in an inner state and
finishes at the <final> of whichever outcome the child reported.
Every declared outcome gets one conditioned done.invoke transition, in
declaration order, and the unconditioned one comes last (C2):
document order decides which of several matching transitions is taken,
so an unconditioned transition placed anywhere but last would shadow
every conditioned one after it. Where it lands is this type's call, and
it lands on the first declared outcome - the outcome an author who
declared only one has, so a subchart that declares nothing behaves
exactly like a core.invoke.
error.communication.invoke routing is core.invoke's, unchanged: it is
emitted only when the on_error slot is occupied, and with the slot
empty the failure propagates as it does today.
Who owns what
Everything here is this block's except one transition per occupied slot:
the one leaving the slot's child for that outcome's final is attributed
to that child, because "what happens after the parking step
finishes" is a fact about the child (ADR-0004 decision 5, the rule
Emit.chain/2 follows). The src attribute's value is stamped as
coming from chart and the location's from assign_to, and each
condition as coming from outcomes, so an upstream finding inside one
is the author's typo rather than a bug in this type.
@spec invoke_type() :: String.t()
The invoke type every core.subchart emits: the host-registered
child-chart invoke type.
It is a constant rather than a config field because which handler
starts a child session is deployment state, not authoring state
(st-ADR-0051): an author picks a chart, and the host registers one
handler that knows how to run one. A host that registers nothing under
this name gets the ordinary two-registry lint
(StatifierBlocks.Compiler.InvokeTypes) rather than a runtime surprise,
which is the whole point of naming it here where the lint can see it.
core.invoke's io/1 exactly: a step with several outcomes, so
produces is :unknown rather than a join over the subtrees that reach
each one - the lattice ADR-0003 decision 4 refuses to build - and no
consumes, because a subchart reads its inputs through params.
@spec outcome_names(StatifierBlocks.Block.config()) :: [String.t()]
The outcomes this block declares: the ones the author listed, then
error unless they listed it.
Public because the editor and the tests both need the same reading of the flattened field, and two spellings of it would be two chances for them to disagree.
The outcomes field's rows: one outcome name per line, blank lines
ignored, in the order the author wrote them.
The outcomes the referenced chart declares, as the author listed them,
with error appended unless they listed it themselves (ADR-0002
amendment A1, ADR-0004's outcome amendment 2a).
One zero_or_one slot per declared outcome, named on_<outcome>, in
declaration order with on_error last.
Arity is zero_or_one for core.invoke's reason: an outcome path is
one continuation, not a list of them, and an author who wants several
steps there puts a core.sequence in it exactly as anywhere else a
single child is asked for.